목차
26 장

인증 — OAuth2 패스워드 플로우 + JWT

비밀번호 해싱(argon2/bcrypt), OAuth2 패스워드 플로우, JWT 발급/검증, 그리고 current_user 의존성으로 인증 흐름을 구성하는 패턴을 정리합니다.

25장 DB 연동 — SQLAlchemy 2.x + Alembic에서 DB까지 연결한 Todo가 이제 사용자별로 분리되어야 합니다. 회원가입, 로그인, 인증된 요청을 구분하는 표준 패턴 — OAuth2 패스워드 플로우 + JWT를 다룹니다.

본 챕터의 인증 코드는 시크릿 / 비밀번호 같은 민감 정보를 다루므로 31장 logging과 관측성에서 다룰 PII / secret 로깅 방지 패턴과 함께 운영됩니다.

인증 vs 인가 #

용어부터 정리.

  • 인증 (Authentication) — “당신이 누구인가?” — 로그인 자체
  • 인가 (Authorization) — “그 사람이 이 일을 해도 되는가?” — 권한 체크

본 챕터는 인증에 집중. 인가(role, permission 등)는 추가 미들웨어 영역인데, 패턴은 매우 비슷합니다.

두 갈래의 흐름 #

세션 기반토큰 기반 (JWT)
저장서버 (DB / Redis)클라이언트
상태statefulstateless
분산 시스템공유 저장소 필요자연스러움
폐기즉시 가능만료까지 또는 블랙리스트
모바일 / SPA 친화보통좋음

API 서버 (FastAPI) + SPA / 모바일 앱 조합에서는 JWT가 표준 답입니다. 세션 기반이 더 어울리는 경우도 있지만 (서버 렌더링 웹 앱), 본 책은 JWT로.

비밀번호는 절대 평문으로 저장하지 않는다 #

데이터베이스가 유출되어도 비밀번호가 노출되면 안 됩니다. 단방향 해시로 저장 — 로그인할 때 입력값을 같은 해시로 만들어 비교만.

알고리즘 선택 #

알고리즘평가
MD5, SHA-1, SHA-256❌ 절대 안 됨 (너무 빠름, GPU로 brute force)
bcrypt⭕ 오래된 표준, 충분히 안전
argon2id현재 권장 표준 (OWASP)
scrypt⭕ 안전하지만 argon2가 더 추천됨

새 프로젝트는 argon2id를 첫 선택. 단순한 코드와 안전한 기본값을 가집니다.

설치 #

argon2 + JWT
uv add argon2-cffi pyjwt

argon2-cffi가 argon2 구현체, pyjwt가 JWT 표준 라이브러리.

호환성 메모: 옛 코드에 자주 보이는 passlib은 점진적으로 유지보수가 줄고 있어, 새 프로젝트는 직접 argon2-cffi 또는 bcrypt를 쓰는 흐름이 일반화 됐습니다.

비밀번호 해싱 모듈 #

app/core/security.py
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

_hasher = PasswordHasher()    # OWASP 권장 기본값으로 초기화

def hash_password(plain: str) -> str:
    return _hasher.hash(plain)

def verify_password(plain: str, hashed: str) -> bool:
    try:
        _hasher.verify(hashed, plain)
        return True
    except VerifyMismatchError:
        return False

def needs_rehash(hashed: str) -> bool:
    return _hasher.check_needs_rehash(hashed)

needs_rehash해시 파라미터가 시간이 지나 약해졌을 때 재해시할지 검사 — 사용자가 로그인할 때 검사해서 새 파라미터로 다시 저장하는 패턴.

로그인 시 재해시 흐름
if verify_password(plain, user.hashed_password):
    if needs_rehash(user.hashed_password):
        user.hashed_password = hash_password(plain)
        await db.commit()
    return user

JWT — 토큰의 정체 #

JWT (JSON Web Token)는 세 부분으로 구성된 문자열입니다.

JWT 모양
xxxxx.yyyyy.zzzzz
header.payload.signature
  • header — 알고리즘, 타입
  • payload — 클레임 (sub, exp, iat 등)
  • signature — 위 둘을 시크릿으로 서명. 변조 방지

핵심: payload는 base64 디코딩하면 누구나 읽을 수 있습니다. JWT는 암호화가 아니라 서명 — 변조 방지지 비밀 유지가 아닙니다. 비밀번호 같은 민감 정보를 JWT에 담으면 안 됩니다.

표준 클레임 #

클레임의미
subsubject — 보통 사용자 ID
expexpiration — 만료 시각 (UNIX timestamp)
iatissued at — 발급 시각
nbfnot before — 본 시각 전에는 무효
jtiJWT ID — 폐기 / 블랙리스트용
audaudience — 토큰의 대상자
ississuer — 발급자

exp는 거의 항상. 나머지는 정책에 따라.

JWT 발급 / 검증 #

app/core/security.py 추가
from datetime import datetime, timedelta, timezone
import jwt
from app.core.config import settings

ALGORITHM = "HS256"

def create_access_token(subject: str, expires_minutes: int = 60) -> str:
    now = datetime.now(timezone.utc)
    payload = {
        "sub": subject,
        "iat": now,
        "exp": now + timedelta(minutes=expires_minutes),
    }
    return jwt.encode(payload, settings.jwt_secret, algorithm=ALGORITHM)

def decode_token(token: str) -> dict:
    return jwt.decode(token, settings.jwt_secret, algorithms=[ALGORITHM])

HS256대칭 키 서명 — 같은 시크릿으로 서명하고 검증. 단일 서버에서 충분합니다. 여러 서버가 검증만 해야 하면 RS256 (비대칭 — RSA 공개 / 개인 키)으로.

시크릿 관리 #

jwt_secret22장pydantic-settings로 환경 변수에서 읽습니다.

.env
JWT_SECRET=$(python -c 'import secrets; print(secrets.token_urlsafe(64))')

최소 32바이트 (256비트) 랜덤으로 생성하세요. 실제 환경 변수에는 python -c로 만든 랜덤 문자열을 직접 넣고요.

회원가입 / 로그인 라우트 #

Pydantic 스키마 #

app/schemas/user.py
from pydantic import BaseModel, EmailStr, Field

class UserCreate(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=128)

class UserOut(BaseModel):
    id: int
    email: EmailStr
    model_config = {"from_attributes": True}

class Token(BaseModel):
    access_token: str
    token_type: str = "bearer"

CRUD #

app/services/user.py
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.core.security import hash_password, verify_password

async def create_user(db: AsyncSession, email: str, password: str) -> User:
    user = User(email=email, hashed_password=hash_password(password))
    db.add(user)
    await db.commit()
    await db.refresh(user)
    return user

async def get_user_by_email(db: AsyncSession, email: str) -> User | None:
    result = await db.execute(select(User).where(User.email == email))
    return result.scalar_one_or_none()

async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
    user = await get_user_by_email(db, email)
    if user is None:
        return None
    if not verify_password(password, user.hashed_password):
        return None
    return user

authenticate사용자가 없는 경우와 비밀번호가 틀린 경우를 구분하지 않습니다. 둘 다 None — “이메일이 존재하는지 알려주는” 정보 누설을 막습니다.

라우트 #

app/api/auth.py
from fastapi import APIRouter, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from typing import Annotated
from app.api.deps import DBSession
from app.schemas.user import UserCreate, UserOut, Token
from app.services import user as user_service
from app.core.security import create_access_token

router = APIRouter(prefix="/auth", tags=["auth"])

@router.post("/register", response_model=UserOut, status_code=201)
async def register(payload: UserCreate, db: DBSession) -> UserOut:
    existing = await user_service.get_user_by_email(db, payload.email)
    if existing:
        raise HTTPException(409, "이미 가입된 이메일")
    user = await user_service.create_user(db, payload.email, payload.password)
    return user

@router.post("/login", response_model=Token)
async def login(
    form: Annotated[OAuth2PasswordRequestForm, Depends()],
    db: DBSession,
) -> Token:
    user = await user_service.authenticate(db, form.username, form.password)
    if user is None:
        raise HTTPException(
            status.HTTP_401_UNAUTHORIZED,
            "Incorrect email or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    token = create_access_token(subject=str(user.id))
    return Token(access_token=token)

OAuth2PasswordRequestForm의 정체 #

form: OAuth2PasswordRequestForm = Depends()표준 OAuth2 패스워드 플로우 폼을 자동으로 파싱합니다.

  • application/x-www-form-urlencoded 본문에서 username, password를 받음
  • 우리 도메인은 이메일이지만, OAuth2 표준 필드 이름은 username. 그대로 쓰세요.
  • Swagger UI에서 자동으로 “Authorize” 버튼 활성화

의존성으로 current_user 만들기 #

요청에서 토큰을 꺼내고 → 검증 → 사용자 객체를 라우트에 주입.

app/api/deps.py 확장
from typing import Annotated
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from jwt import InvalidTokenError
from app.core.security import decode_token
from app.models.user import User
from app.services import user as user_service

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")

async def get_current_user(
    token: Annotated[str, Depends(oauth2_scheme)],
    db: DBSession,
) -> User:
    credentials_exc = HTTPException(
        status.HTTP_401_UNAUTHORIZED,
        "유효하지 않은 토큰",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = decode_token(token)
        user_id = int(payload["sub"])
    except (InvalidTokenError, KeyError, ValueError):
        raise credentials_exc

    user = await db.get(User, user_id)
    if user is None:
        raise credentials_exc
    return user

CurrentUser = Annotated[User, Depends(get_current_user)]

OAuth2PasswordBearer가 하는 일 #

  • Authorization: Bearer xxxxx 헤더에서 토큰 추출
  • 헤더가 없거나 형식이 틀리면 자동으로 401 응답
  • Swagger UI의 “Authorize” 버튼이 이걸 보고 동작
  • tokenUrl="/auth/login"은 토큰 발급 엔드포인트 — 문서 / UI 용

라우트에서 사용 #

app/api/todos.py 확장
@router.post("/", response_model=TodoOut, status_code=201)
async def create_todo(
    payload: TodoCreate,
    current_user: CurrentUser,
    db: DBSession,
) -> Todo:
    todo = Todo(
        **payload.model_dump(),
        owner_id=current_user.id,
    )
    db.add(todo)
    await db.commit()
    await db.refresh(todo)
    return todo

@router.get("/", response_model=list[TodoOut])
async def list_my_todos(
    current_user: CurrentUser,
    db: DBSession,
) -> list[Todo]:
    result = await db.execute(
        select(Todo).where(Todo.owner_id == current_user.id)
    )
    return list(result.scalars())

current_user: CurrentUser 한 줄이 들어가면:

  • 토큰이 없거나 무효면 자동 401
  • 유효하면 current_user가 인증된 사용자 객체

라우트 안에서 토큰 검증 코드를 한 줄도 안 적었습니다. 공통 로직이 의존성으로 깔끔히 풀려 있습니다.

추가 패턴 #

활성 / 비활성 사용자 #

활성 사용자만
async def get_current_active_user(current_user: CurrentUser) -> User:
    if not current_user.is_active:
        raise HTTPException(403, "비활성 계정")
    return current_user

ActiveUser = Annotated[User, Depends(get_current_active_user)]

의존성 위에 의존성. 레이어처럼 쌓아 올립니다.

권한 (역할 기반) #

role 체크
def require_role(role: str):
    async def checker(user: CurrentUser) -> User:
        if user.role != role:
            raise HTTPException(403, f"{role} 권한 필요")
        return user
    return checker

AdminUser = Annotated[User, Depends(require_role("admin"))]

@router.delete("/admin-only")
async def admin_only(admin: AdminUser): ...

Refresh 토큰 (선택) #

Access 토큰을 짧게 (15분~1시간) + Refresh 토큰을 길게 (7일~30일) 두는 패턴. Access가 만료되면 Refresh로 새 Access를 발급. 보안과 사용성의 균형. Refresh 토큰은 DB에 저장 해서 폐기 가능하게 두는 게 보통.

본 책의 4부는 단순하게 access 토큰 1개로 갑니다. 실제 서비스에서는 추가하세요.

자주 만나는 함정 #

1) JWT 시크릿이 약함 #

나쁜 예
JWT_SECRET=secret
JWT_SECRET=mypassword
좋은 예
JWT_SECRET=$(python -c 'import secrets; print(secrets.token_urlsafe(64))')

짧은 시크릿은 brute force 가능합니다.

2) JWT payload에 민감 정보 #

나쁜 예
payload = {"sub": "u1", "password": "...", "ssn": "..."}

JWT payload는 누구나 읽을 수 있습니다. 사용자 ID 같은 식별자만.

3) HTTPS 없음 #

토큰은 HTTPS로만 전송. HTTP 위에서는 누구나 토큰을 가로챌 수 있습니다. 개발은 localhost (HTTPS 면제), 프로덕션은 무조건 HTTPS.

4) 토큰 만료가 너무 김 #

나쁜 예: 7일짜리 access 토큰
expires_minutes=10080

만료가 길수록 탈취 시 피해 시간이 깁니다. 15분~1시간이 권장. 길게 쓰고 싶으면 refresh 토큰 패턴.

5) 비밀번호 강도 검증 없음 #

좋은 예: 강도 검증
class UserCreate(BaseModel):
    email: EmailStr
    password: str = Field(min_length=8, max_length=128)

    @field_validator("password")
    @classmethod
    def strong_enough(cls, v: str) -> str:
        if v.lower() in {"password", "12345678", "qwerty"}:
            raise ValueError("너무 흔한 비밀번호")
        return v

Have I Been Pwned API로 유출 비밀번호 검증을 추가하는 방식도 있습니다.

연습문제 #

  1. argon2-cffihash_password / verify_password / needs_rehash 세 함수를 작성하세요. 같은 비밀번호를 두 번 해시했을 때 다른 결과가 나오는지 (salt 자동), verify가 둘 다 OK인지 확인합니다.
  2. pyjwtcreate_access_token(subject: str) / decode_token(token: str)를 작성하세요. 일부러 만료된 토큰 (exp가 과거)를 만들어 decode_tokenExpiredSignatureError를 던지는지 확인합니다.
  3. OAuth2PasswordBearer + Depends(get_current_user)current_user 의존성을 구현하고, GET /me 라우트에서 인증된 사용자 정보를 반환하도록 하세요. Swagger UI의 “Authorize” 버튼으로 로그인 → 보호된 엔드포인트 호출까지 전체 흐름을 직접 확인합니다.

한 줄 요약: 인증은 누구 / 인가는 권한. JWT는 stateless, 클라이언트 보관, SPA / 모바일 친화. argon2id가 비밀번호 해싱 표준 (OWASP). JWT 표준 클레임은 sub / exp / iat, HS256 대칭 서명, 시크릿 32바이트+ 랜덤. OAuth2PasswordRequestForm 폼 자동 파싱, OAuth2PasswordBearer 헤더 토큰 추출. CurrentUser 의존성으로 라우트 인증 한 줄. 함정은 약한 시크릿 / payload 민감 정보 / HTTP / 만료 길이 / 비밀번호 강도.

다음 챕터 #

다음 27장 비동기와 백그라운드 작업에서는 응답을 기다리지 않고 처리해야 할 작업 — 이메일 발송, 무거운 변환, 외부 API 호출 — 을 다루는 패턴 (BackgroundTasks, 외부 큐)을 다룹니다.

X