소프트웨어 개발/백엔드

📚[FastAPI] 5장. 인증 및 권한 관리: JWT를 활용한 보안 강화

브라더댄 2025. 1. 26. 14:04
728x90
반응형
SMALL

안녕하세요! 이번 포스팅에서는 FastAPI 애플리케이션에 **인증(Authentication)**과 권한 관리(Authorization) 기능을 추가하여 API 보안을 한층 강화하는 방법을 다루어보겠습니다. 현대 웹 서비스에서는 단순한 CRUD만으로는 충분하지 않으며, 사용자 식별권한 부여가 반드시 필요합니다.

특히 JWT(JSON Web Token)를 이용해 토큰 기반 인증을 구현하고, OAuth2 흐름을 적용함으로써 더욱 안전하고 유연한 인증 시스템을 구성할 수 있습니다. 이번 장을 통해 로그인/로그아웃, 사용자 역할(Role) 설정 등 핵심 인증 로직을 차근차근 익혀봅시다.


5.1. JWT 개념과 작동 원리

5.1.1. JWT(JSON Web Token)란?

  • JWT는 인증 정보를 JSON 형태로 담아, 서버-클라이언트 간 인증을 간편하고 확장성 있게 처리하도록 고안된 토큰 형식입니다.
  • 크게 3부분(헤더.페이로드.서명)으로 구성되어 있습니다.
    1. Header: 알고리즘 타입(HS256 등)을 명시
    2. Payload: 실제 인증 정보를 담는 부분 (예: sub, exp, iat, roles)
    3. Signature: 헤더와 페이로드를 합쳐 비밀키로 서명한 값

5.1.2. JWT 인증의 장점

  • Stateless: 세션 정보를 서버에 저장할 필요가 없으므로, 서버 부하가 줄어듭니다.
  • 확장성: 여러 서비스 간 공유가 용이해, 마이크로서비스나 외부 API 연동에도 자주 사용됩니다.
  • 자체 검증: 유효 기간(exp)이나 서명 검증을 통해, 서버가 직접 유효성을 판단합니다.

5.2. FastAPI에서의 OAuth2 Password Flow

5.2.1. OAuth2 Password Flow란?

  • OAuth2는 다양한 인증 흐름(Flow)을 정의한 표준이며, Password Flow는 “사용자 이름/비밀번호”를 직접 받고 토큰을 발급하는 가장 간단한 방식입니다.
  • FastAPI는 이 흐름을 지원하기 위한 유틸을 내장(OAuth2PasswordBearer, OAuth2PasswordRequestForm)하고 있습니다.

5.2.2. 인증 로직 개요

  1. 로그인 요청: 클라이언트가 /login 엔드포인트로 이메일/비밀번호를 전송 (POST)
  2. 사용자 검증: DB에서 사용자 정보를 조회하여 비밀번호 일치 여부 확인
  3. JWT 토큰 발급: 검증 성공 시, 만료 시간(exp) 등을 포함한 Access Token을 생성
  4. API 접근 시 인증 헤더 전송: 클라이언트는 Authorization: Bearer <token> 헤더로 토큰을 전달
  5. 서버는 토큰 검증: 만료 여부, 서명(Signature) 확인, 필요한 경우 사용자 권한(Role)도 확인

5.3. 의존성 설치 및 환경 설정

5.3.1. Poetry로 라이브러리 설치

poetry add python-jose passlib[bcrypt]
  • python-jose: JWT 토큰을 인코딩/디코딩하기 위한 라이브러리
  • passlib[bcrypt]: 비밀번호 해싱 및 검증을 위한 라이브러리

참고: bcrypt 이외에도 argon2 등 다양한 해시 알고리즘이 있습니다. 프로젝트 요구사항에 맞게 선택하세요.

5.3.2. 환경 변수 설정 (.env)

JWT_SECRET_KEY=SUPER_SECRET_KEY
JWT_ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
  • 실제 서비스에서는 .env 파일을 Git에 커밋하지 않거나, Secrets Manager를 사용하여 안전하게 관리해야 합니다.

5.4. JWT 토큰 생성 및 검증

5.4.1. 유틸 함수 (core/security.py 예시)

# app/core/security.py
import os
from datetime import datetime, timedelta
from jose import JWTError, jwt

SECRET_KEY = os.getenv("JWT_SECRET_KEY")
ALGORITHM = os.getenv("JWT_ALGORITHM", "HS256")
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", 30))

def create_access_token(data: dict):
    to_encode = data.copy()
    expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    to_encode.update({"exp": expire})
    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
    return encoded_jwt

def verify_access_token(token: str):
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        return payload  # payload 내에 sub, exp 등 정보가 들어 있음
    except JWTError:
        return None
  • create_access_token: 사용자 식별 정보(sub 또는 user_id)와 만료 시간을 페이로드에 담아 JWT 문자열을 생성
  • verify_access_token: 토큰이 유효한지(서명, 만료 시간 등) 확인 후, 정상이라면 페이로드 반환

주의: 실제 서비스에서는 만료 시간, 리프레시 토큰, 키 로테이션 등 보안을 강화해야 합니다.

5.4.2. 비밀번호 해싱 (passlib 활용)

from passlib.context import CryptContext

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(password: str) -> str:
    return pwd_context.hash(password)

def verify_password(plain_password: str, hashed_password: str) -> bool:
    return pwd_context.verify(plain_password, hashed_password)
  • 사용자 입력 비밀번호는 절대 평문으로 저장하지 말고, 해시(단방향 암호화)한 뒤 DB에 저장해야 합니다.
  • 로그인 시에는 verify_password를 사용해 입력 비밀번호가 해시와 일치하는지 검사합니다.

5.5. 로그인 & 사용자 인증 엔드포인트

5.5.1. 로그인 API 예시

# app/api/v1/endpoints/auth.py
from fastapi import APIRouter, Depends, HTTPException
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session

from app.api.deps import get_db
from app.core.security import create_access_token, verify_password
from app.crud.user import get_user_by_email
from app.schemas.token import Token

router = APIRouter()

@router.post("/login", response_model=Token)
def login(
    db: Session = Depends(get_db), 
    form_data: OAuth2PasswordRequestForm = Depends()
):
    # 1) 사용자 찾기
    user = get_user_by_email(db, form_data.username)
    if not user:
        raise HTTPException(status_code=400, detail="Incorrect email or password")
    # 2) 비밀번호 검증
    if not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(status_code=400, detail="Incorrect email or password")
    # 3) Access Token 발급
    access_token = create_access_token(data={"sub": str(user.id)})
    return {
        "access_token": access_token,
        "token_type": "bearer"
    }
  • OAuth2PasswordRequestForm는 기본적으로 username, password 필드를 지원하며, FastAPI에서 로그인 폼을 처리할 때 자주 사용됩니다. (이 예시에서는 username을 이메일로 활용)
  • 성공 시, JWT를 담은 access_token과 token_type을 응답합니다.

5.5.2. Token Pydantic 스키마 (schemas/token.py)

from pydantic import BaseModel

class Token(BaseModel):
    access_token: str
    token_type: str

5.6. 보호된 엔드포인트 접근

5.6.1. 토큰 검증 의존성 (get_current_user)

# app/api/deps.py
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jose import JWTError
from sqlalchemy.orm import Session

from app.core.security import verify_access_token
from app.crud.user import get_user_by_id
from app.db.session import SessionLocal

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login")

def get_db():
    db = SessionLocal()
    try:
        yield db
    finally:
        db.close()

def get_current_user(token: str = Depends(oauth2_scheme), db: Session = Depends(get_db)):
    payload = verify_access_token(token)
    if payload is None:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid token",
            headers={"WWW-Authenticate": "Bearer"},
        )
    user_id: str = payload.get("sub")
    if not user_id:
        raise HTTPException(status_code=401, detail="Token payload invalid")

    user = get_user_by_id(db, int(user_id))
    if not user:
        raise HTTPException(status_code=401, detail="User not found")
    return user
  • OAuth2PasswordBearer: FastAPI가 Authorization: Bearer <token> 헤더로부터 토큰을 추출해줍니다.
  • verify_access_token: 서명 검증, 만료 시간 검증 수행. 무효하면 401 Unauthorized 예외 발생.
  • get_user_by_id: 유효한 토큰이면 실제 DB에서 사용자 정보를 가져옵니다. 존재하지 않으면 여전히 401로 처리.

5.6.2. 보호된 API 사용 예시

# app/api/v1/endpoints/user.py (일부 예시)
from fastapi import APIRouter, Depends
from app.models.user import User
from app.api.deps import get_current_user

router = APIRouter()

@router.get("/me")
def read_current_user(current_user: User = Depends(get_current_user)):
    return {
        "id": current_user.id,
        "email": current_user.email,
        "role": current_user.role
    }
  • 이 엔드포인트는 JWT 토큰이 필요하므로, 미인증 상태에서 호출하면 401 에러가 발생합니다.
  • 토큰을 헤더에 담아 보낸다면, 인증을 통과해 current_user 정보를 얻을 수 있습니다.

5.7. 권한(Role) 관리

5.7.1. Role 필드와 접근 제한

  • 사용자 테이블 혹은 스키마에 role 필드를 둡니다. 예: "user", "admin", "manager" 등.
  • 인증만으로는 “누구냐?”를 확인할 수 있지만, “무엇을 할 수 있느냐?”(권한)는 role 등에 따라 세밀하게 제어합니다.

5.7.2. 예시: 관리자 전용 API 데코레이터

# app/api/deps.py
from fastapi import HTTPException, status

def get_admin_user(current_user: User = Depends(get_current_user)):
    if current_user.role != "admin":
        raise HTTPException(
            status_code=status.HTTP_403_FORBIDDEN,
            detail="Not enough permissions"
        )
    return current_user
# app/api/v1/endpoints/admin.py
from fastapi import APIRouter, Depends
from app.models.user import User
from app.api.deps import get_admin_user

router = APIRouter()

@router.get("/stats")
def get_admin_stats(admin_user: User = Depends(get_admin_user)):
    # 관리자만 접근 가능한 통계 API
    return {"data": "Some sensitive stats..."}
  • 403 Forbidden을 활용해 “인증은 되었지만, 권한이 없는” 경우를 구분합니다.
  • 운영 환경에서 종종 관리자 권한(또는 여러 Role)을 분기 처리해 특별 액션을 수행합니다.

5.8. 실제 활용 사례

5.8.1. 전자상거래(E-Commerce)

  • 회원 가입 시 비밀번호를 bcrypt 해시로 저장
  • 로그인 시 JWT 발급, 주문/결제/장바구니 API 접근 시 Bearer 토큰 필수
  • 관리자 Role은 상품 등록, 재고 관리, 매출 통계 페이지에 접근 가능

5.8.2. 사내 업무 시스템

  • 직원은 본인 정보만 수정 가능, 인사팀(HR Role)은 모든 직원 정보 수정 가능
  • JWT는 만료 시간이 짧게 설정되어 보안 강화
  • 사내 SSO와 연동해 Role 정보를 한 번에 가져오는 경우도 있음

5.8.3. 마이크로서비스 아키텍처

  • API Gateway에서 먼저 JWT 인증을 수행하고, 유효한 요청만 마이크로서비스로 전달
  • Role이 여러 개일 수도 있으며, 토큰에 권한(Scopes) 정보를 담아 각 마이크로서비스에서 해석
  • Redis나 다른 캐시를 사용해 로그아웃 처리(JWT 블랙리스트) 로직을 구현하기도 함

모범 사례

  1. 비밀번호는 항상 안전한 알고리즘(bcrypt, Argon2 등)으로 해시
  2. 만료 시간을 명확히 설정하고, 리프레시 토큰을 통해 재발급 체계 마련
  3. HTTPS 전송: JWT는 평문으로 중요한 정보를 담으므로, HTTPS가 필수
  4. 민감 정보(private_claims)는 가급적 토큰에 담지 않거나, 암호화/최소화
  5. 사용자 로깅, 감사 기록(Audit Trail)을 통해 무단 접근 시도를 추적

5.9. 마무리

이번 5장에서는 FastAPI 애플리케이션에 JWT 기반 인증을 구현하고, 권한(Role)에 따라 접근을 제어하는 방법을 배웠습니다. 구체적으로:

  1. JWT의 개념과 작동 원리를 이해하고
  2. OAuth2 Password Flow를 활용해 로그인/토큰 발급 과정을 구성했으며
  3. Bearer 토큰을 검증해 인증/인가를 처리하는 실전 코드를 작성했습니다.
  4. 권한 관리를 통해 관리자 전용 API 등 세분화된 접근 제어를 실습해봤습니다.

다음 장에서는 테스트 및 디버깅에 대해 좀 더 구체적으로 알아볼 예정입니다. 인증/권한 관련 로직은 오류가 발생하면 서비스 전반에 치명적 영향을 줄 수 있으므로, 충분한 테스트가 매우 중요합니다. 실무에서도 꼭 PytestCI 환경을 구축하여 자동화된 테스트로 문제를 조기에 발견하시길 권장드립니다!

728x90
반응형
LIST