FastAPI 로그인 기능 구현

본 글은 Claude Sonnet 4로 작성한 글입니다.


💡 로그인 기능의 기본 원리

인증과 인가의 차이

인증(Authentication)은 사용자가 누구인지 확인하는 과정이다. 인가(Authorization)는 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정이다.

세션 기반 인증

전통적인 웹 애플리케이션에서는 세션을 사용한다. 서버가 사용자 로그인 정보를 메모리나 데이터베이스에 저장하고, 클라이언트에게 세션 ID를 쿠키로 전달한다.

토큰 기반 인증

REST API에서는 JWT(JSON Web Token)를 주로 사용한다. 서버가 사용자 정보를 암호화한 토큰을 생성하고, 클라이언트가 이를 헤더에 포함하여 요청을 보낸다.

⚡️ FastAPI 로그인 기능 구현

$ pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] python-multipart
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import JWTError, jwt
from passlib.context import CryptContext
from datetime import datetime, timedelta
from pydantic import BaseModel

app = FastAPI()

# JWT 설정
SECRET_KEY = "your-secret-key-here"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

# 비밀번호 암호화 설정
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
security = HTTPBearer()

데이터 모델 정의

class UserCreate(BaseModel):
    username: str
    password: str

class UserLogin(BaseModel):
    username: str
    password: str

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

# 간단한 사용자 저장소 (실제로는 데이터베이스 사용)
fake_users_db = {}

비밀번호 해싱 함수

def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

def get_password_hash(password):
    return pwd_context.hash(password)

JWT 토큰 생성 및 검증

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

def verify_token(credentials: HTTPAuthorizationCredentials = Depends(security)):
    try:
        payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise HTTPException(
                status_code=status.HTTP_401_UNAUTHORIZED,
                detail="Invalid authentication credentials",
                headers={"WWW-Authenticate": "Bearer"},
            )
        return username
    except JWTError:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Invalid authentication credentials",
            headers={"WWW-Authenticate": "Bearer"},
        )

회원가입 엔드포인트

@app.post("/register")
async def register(user: UserCreate):
    if user.username in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_400_BAD_REQUEST,
            detail="Username already registered"
        )
    
    hashed_password = get_password_hash(user.password)
    fake_users_db[user.username] = {
        "username": user.username,
        "hashed_password": hashed_password
    }
    
    return {"message": "User created successfully"}

로그인 엔드포인트

@app.post("/login", response_model=Token)
async def login(user: UserLogin):
    if user.username not in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password"
        )
    
    stored_user = fake_users_db[user.username]
    if not verify_password(user.password, stored_user["hashed_password"]):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password"
        )
    
    access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
    access_token = create_access_token(
        data={"sub": user.username}, expires_delta=access_token_expires
    )
    
    return {"access_token": access_token, "token_type": "bearer"}

보호된 엔드포인트

@app.get("/protected")
async def protected_route(current_user: str = Depends(verify_token)):
    return {"message": f"Hello {current_user}, this is a protected route!"}

현재 사용자 정보 조회

@app.get("/me")
async def get_current_user(current_user: str = Depends(verify_token)):
    if current_user not in fake_users_db:
        raise HTTPException(
            status_code=status.HTTP_404_NOT_FOUND,
            detail="User not found"
        )
    
    return {
        "username": current_user,
        "message": "User information retrieved successfully"
    }

▶️ 실행 방법

서버 실행

$ uvicorn main:app --reload

API 테스트

  1. 회원가입: POST /register
  2. 로그인: POST /login
  3. 보호된 경로 접근: GET /protected (Authorization 헤더에 Bearer 토큰 포함)

🔐 보안 고려사항

  • SECRET_KEY 관리: 환경 변수나 설정 파일을 사용하여 SECRET_KEY를 안전하게 관리해야 한다.
  • HTTPS 사용: 프로덕션 환경에서는 반드시 HTTPS를 사용해야 한다. 토큰이 네트워크를 통해 전송되기 때문이다.
  • 토큰 만료 시간: 적절한 토큰 만료 시간을 설정하여 보안을 강화해야 한다.
  • 비밀번호 정책: 강력한 비밀번호 정책을 구현하여 사용자 계정을 보호해야 한다.
  • 데이터베이스 연동: 실제 프로덕션 환경에서는 SQLAlchemy나 Tortoise ORM을 사용하여 데이터베이스와 연동해야 한다.
# SQLAlchemy 예시
from sqlalchemy import create_engine, Column, Integer, String
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker

Base = declarative_base()

class User(Base):
    __tablename__ = "users"
    
    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    hashed_password = Column(String)

FastAPI의 의존성 주입 시스템을 활용하면 인증 로직을 깔끔하게 구현할 수 있다. JWT 토큰을 사용한 인증은 stateless하므로 마이크로서비스 아키텍처에 적합하다.