모던 파이썬 실전 #4 인증 — OAuth2 패스워드 플로우 + JWT
#3에서 DB까지 연결한 Todo가 이제 사용자별로 분리되어야 합니다. 회원가입, 로그인, 인증된 요청을 구분하는 표준 패턴 — OAuth2 패스워드 플로우 + JWT를 다룹니다.
인증 vs 인가 #
용어부터 정리.
- 인증 (Authentication) — “당신이 누구인가?” — 로그인 자체
- 인가 (Authorization) — “그 사람이 이 일을 해도 되는가?” — 권한 체크
이번 글은 인증에 집중. 인가(role, permission 등)는 추가 미들웨어 영역인데, 패턴은 매우 비슷합니다.
두 갈래의 흐름 #
| 세션 기반 | 토큰 기반 (JWT) | |
|---|---|---|
| 저장 | 서버 (DB/Redis) | 클라이언트 |
| 상태 | stateful | stateless |
| 분산 시스템 | 공유 저장소 필요 | 자연스러움 |
| 폐기 | 즉시 가능 | 만료까지 또는 블랙리스트 |
| 모바일/SPA 친화 | 보통 | 좋음 |
API 서버 (FastAPI) + SPA/모바일 앱 조합에서는 JWT가 표준 답입니다. 세션 기반이 더 어울리는 경우도 있지만 (서버 렌더링 웹 앱), 이 시리즈는 JWT로.
비밀번호는 절대 평문으로 저장하지 않는다 #
데이터베이스가 유출되어도 비밀번호가 노출되면 안 됩니다. 단방향 해시로 저장 — 로그인할 때 입력값을 같은 해시로 만들어 비교만.
알고리즘 선택 #
| 알고리즘 | 평가 |
|---|---|
| MD5, SHA-1, SHA-256 | ❌ 절대 안 됨 (너무 빠름, GPU로 brute force) |
| bcrypt | ⭕ 오래된 표준, 충분히 안전 |
| argon2id | ✅ 현재 권장 표준 (OWASP) |
| scrypt | ⭕ 안전하지만 argon2가 더 추천됨 |
새 프로젝트는 argon2id를 첫 선택. 단순한 코드와 안전한 기본값을 가집니다.
설치 #
uv add argon2-cffi pyjwtargon2-cffi가 argon2 구현체, pyjwt가 JWT 표준 라이브러리.
호환성 메모: 옛 코드에 자주 보이는
passlib은 점진적으로 유지보수가 줄고 있어, 새 프로젝트는 직접argon2-cffi또는bcrypt를 쓰는 흐름이 일반화 됐습니다.
비밀번호 해싱 모듈 #
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 userJWT — 토큰의 정체 #
JWT (JSON Web Token)는 세 부분으로 구성된 문자열입니다.
xxxxx.yyyyy.zzzzz
header.payload.signature- header — 알고리즘, 타입
- payload — 클레임 (sub, exp, iat 등)
- signature — 위 둘을 시크릿으로 서명. 변조 방지
핵심: payload는 base64 디코딩하면 누구나 읽을 수 있습니다. JWT는 암호화가 아니라 서명 — 변조 방지지 비밀 유지가 아닙니다. 비밀번호 같은 민감 정보를 JWT에 담으면 안 됩니다.
표준 클레임 #
| 클레임 | 의미 |
|---|---|
sub | subject — 보통 사용자 ID |
exp | expiration — 만료 시각 (UNIX timestamp) |
iat | issued at — 발급 시각 |
nbf | not before — 이 시각 전에는 무효 |
jti | JWT ID — 폐기/블랙리스트용 |
aud | audience — 토큰의 대상자 |
iss | issuer — 발급자 |
exp는 거의 항상. 나머지는 정책에 따라.
JWT 발급/검증 #
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_secret은 #1의 pydantic-settings로 환경 변수에서 읽습니다.
JWT_SECRET=$(python -c 'import secrets; print(secrets.token_urlsafe(64))')최소 32바이트(256비트) 랜덤으로 생성하세요. 실제 환경 변수에는 python -c로 만든 랜덤 문자열을 직접 넣고요.
회원가입 / 로그인 라우트 #
Pydantic 스키마 #
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 #
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 userauthenticate는 사용자가 없는 경우와 비밀번호가 틀린 경우를 구분하지 않습니다. 둘 다 None — “이메일이 존재하는지 알려주는” 정보 누설을 막습니다.
라우트 #
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만들기
#
요청에서 토큰을 꺼내고 → 검증 → 사용자 객체를 라우트에 주입.
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 용
라우트에서 사용 #
@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)]이처럼 의존성 위에 의존성을 레이어처럼 쌓아 올릴 수 있습니다.
권한 (역할 기반) #
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에 저장 해서 폐기 가능하게 두는 게 보통.
이번 시리즈는 단순하게 access 토큰 1개로 갑니다. 실제 서비스에서는 추가하세요.
자주 만나는 함정 #
1) JWT 시크릿이 약함 #
JWT_SECRET=secret
JWT_SECRET=mypasswordJWT_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) 토큰 만료가 너무 김 #
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 vHave I Been Pwned API로 유출 비밀번호 검증을 추가하는 방식도 있습니다.
정리 #
이번 글에서 잡은 것:
- 인증(누구) vs 인가(권한)
- JWT — stateless, 클라이언트 보관, SPA/모바일 친화
- argon2id가 현재 표준 비밀번호 해싱 (OWASP 권장)
argon2-cffi+pyjwt조합- JWT 표준 클레임:
sub,exp,iat - HS256 대칭 서명, 시크릿은 32바이트+ 랜덤
OAuth2PasswordRequestForm— 표준 폼 자동 파싱OAuth2PasswordBearer— Bearer 토큰 추출CurrentUser의존성으로 라우트 인증 한 줄- 활성 사용자, 역할 체크는 의존성을 레이어로
- 함정: 약한 시크릿, payload에 민감 정보, HTTPS, 만료 길이, 비밀번호 강도
다음 글(#5 비동기와 백그라운드 작업)에서는 응답을 기다리지 않고 처리해야 할 작업 — 이메일 발송, 무거운 변환, 외부 API 호출 — 을 다루는 패턴 (BackgroundTasks, 외부 큐)을 다룹니다.