Fastapi

[FastAPI] JWT 기반 인증 (7)

Suda_777 2024. 9. 1. 22:02
반응형

 

1. 인증(Authentication) 이란

인증(Authentication)은 시스템이나 서비스가 사용자가 누구인지 확인하는 과정을 의미합니다. 즉, 사용자가 주장하는 신원이 실제로 그 사람인지 확인하는 절차입니다.

 

FastAPI에서는 두 가지 인증 방식을 모두 지원할 수 있습니다.

  • 세션 기반 인증은 Depends와 같은 의존성 주입을 통해 쉽게 구현할 수 있으며, 쿠키 기반의 세션 관리를 사용합니다.
  • 토큰 기반 인증은 OAuth2PasswordBearer와 같은 인증 스키마를 사용해 JWT 기반 인증을 구현하는 것이 일반적입니다.

그 중에서 이번 시간에는 토큰 기반 인증인 JWT(Json Web Token)에 대해 공부해 봅시다.


2. JWT 

2.1. JWT 란?

JWT는 주로 인증 목적으로 사용되며, 토큰 안에 사용자의 인증 정보를 포함하고 있어 서버가 별도로 상태를 유지할 필요가 없습니다.

 

2.2. JWT의 구성 요소

  • Header (헤더)
    • JWT의 타입과 사용할 암호화 알고리즘 정보
  • Payload (페이로드)
    • WT의 본문으로, 클레임(Claims)이라고 불리는 정보가 있음
    • 클레임은 사용자에 대한 정보(예: 사용자 ID, 이메일)나 토큰의 만료 시간(exp), 발행자(iss) 등의 메타데이터를 포함할 수 있음
    • 페이로드에는 민감한 정보는 포함하지 않는 것이 좋습니다, 왜냐하면 JWT는 기본적으로 인코딩되지만 암호화되지 않기 때문
  • Signature (서명)
    • 서명은 헤더와 페이로드를 합친 후, 지정된 비밀키와 함께 암호화 알고리즘을 사용하여 생성
    • 서명은 토큰의 무결성을 검증하기 위해 사용됩니다. 즉, 토큰이 생성된 후에 수정되지 않았음을 확인할 수 있습니다.

2.3. JWT의 사용 방식

  1. 토큰 발급: 사용자가 로그인에 성공하면, 서버는 JWT를 생성해 클라이언트(브라우저 또는 앱)에게 전달합니다.
  2. 토큰 저장: 클라이언트는 받은 JWT를 보통 브라우저의 로컬 스토리지나 쿠키에 저장합니다.
  3. 요청 시 토큰 사용: 클라이언트는 이후의 요청에서 JWT를 포함해 서버에 보냅니다. 주로 HTTP 헤더(Authorization: Bearer <JWT>)에 토큰을 포함시킵니다.
  4. 토큰 검증: 서버는 받은 JWT를 비밀키를 사용해 검증하고, 유효한 토큰이라면 요청을 처리하고, 그렇지 않다면 요청을 거부합니다.

2.4. JWT 장점

  • 무상태성: JWT는 서버가 세션 상태를 유지할 필요가 없으므로, 확장성이 뛰어나며 분산 시스템에서 유리합니다.
  • 자가 포함성: JWT 자체에 사용자 정보가 포함되어 있으므로, 추가적인 데이터베이스 조회 없이 사용자를 인증할 수 있습니다.
  • 다양한 사용: JWT는 인증뿐만 아니라 사용자 권한 검증, 정보 교환 등 다양한 용도로 사용할 수 있습니다.

2.5. JWT 단점

  • 토큰 크기: JWT는 페이로드에 많은 정보를 포함할 경우 크기가 커질 수 있습니다. 이로 인해 네트워크 대역폭을 더 많이 차지할 수 있습니다.
  • 토큰 무효화 어려움: JWT는 발급된 이후엔 별도의 만료 처리가 없으면 유효 기간 동안 계속 사용될 수 있습니다. 즉, 토큰을 즉시 무효화하는 것이 어렵습니다.
  • 보안 취약점: JWT 자체는 인코딩만 되어있고 암호화되어 있지 않기 때문에, 민감한 정보를 포함하지 않아야 합니다.

3. FastAPI에서 JWT 사용 법

3.1. 로그인 (토큰 발급)

3.1.1. JWT 발급을 위한 설정

JWT 발급 설정에는 아래와 같은 설정을 합니다.

  • 비밀키(secret key)
  • 사용할 알고리즘
  • 토큰의 만료 시간(expiration time)

OAuth2PasswordBearer는 로그인 시 토큰을 받기 위한 경로를 정의합니다.

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jose import JWTError, jwt
from datetime import datetime, timedelta
from typing import Optional

# JWT 발급을 위한 설정
SECRET_KEY = "your_secret_key"  # 비밀키 (노출되지 않도록 환경 변수로 관리하는 것이 좋습니다)
ALGORITHM = "HS256"  # 사용할 알고리즘
ACCESS_TOKEN_EXPIRE_MINUTES = 30  # 토큰 만료 시간 (분 단위)

app = FastAPI()

# OAuth2PasswordBearer는 로그인 시 토큰을 받기 위한 경로를 정의합니다.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

 

3.1.2. JWT 생성 함수

  • data: 딕셔너리 형태로 유저의 정보를 포함.
  • data에 토큰 만료 시간 추가
  • jwt.encode() 메소드를 이용해 토큰 생성
    • 데이터, 시크릿 키, 알고리즘 내용이 들어감
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
    to_encode = data.copy()
    if expires_delta:
        expire = datetime.utcnow() + expires_delta
    else:
        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

 

3.1.3. FastAPI에서의 JWT 토큰 발급

모델 정의 및 데이터베이스 설정

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker, declarative_base


# 데이터베이스 설정
DATABASE_URL = "postgresql+asyncpg://user:password@localhost/dbname"

engine = create_async_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(
    autocommit=False, autoflush=False, bind=engine, class_=AsyncSession
)
Base = declarative_base()

# DB 의존성
async def get_db():
    async with SessionLocal() as db:
        yield db
# 사용자 모델 정의
from sqlalchemy import Column, Integer, String

class User(Base):
    __tablename__ = "users"

    id = Column(Integer, primary_key=True, index=True)
    username = Column(String, unique=True, index=True)
    hashed_password = Column(String)

 

상세 기능 정의

from passlib.context import CryptContext
from jose import JWTError, jwt
from datetime import timedelta, datetime
from sqlalchemy.future import select

# 사용자 조회 함수
async def get_user(db: AsyncSession, username: str):
    result = await db.execute(select(User).filter(User.username == username))
    return result.scalars().first()

# 패스워드 해싱 유틸리티
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

# 비밀번호 확인
def verify_password(plain_password, hashed_password):
    return pwd_context.verify(plain_password, hashed_password)

# 토큰 생성
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

 

 

from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm

# JWT 설정
SECRET_KEY = "your_secret_key"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30

app = FastAPI()

# 로그인 및 토큰 발급 엔드포인트
@app.post("/token", response_model=dict)
async def login_for_access_token(form_data: OAuth2PasswordRequestForm = Depends(), db: AsyncSession = Depends(get_db)):
    user = await get_user(db, form_data.username)
    if not user or not verify_password(form_data.password, user.hashed_password):
        raise HTTPException(
            status_code=status.HTTP_401_UNAUTHORIZED,
            detail="Incorrect username or password",
            headers={"WWW-Authenticate": "Bearer"},
        )
    
    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"}
  • OAuth2PasswordRequestForm 는 POST 요청을 보낼 때, 폼 데이터로 제출된 사용자 이름과 비밀번호를 쉽게 추출할 수 있도록 도와줍니다. 
  • 먼저, 데이터베이스에서 유저 정보를 불러옵니다.
  • 비밀번호가 맞는지 확인합니다.
  • 토큰 만료일을 추가하고, 토큰을 생성합니다.

3.2. JWT 토큰 인증 (로그인 후 권한 인증)

  • 클라이언트 요청: 클라이언트가 JWT 토큰을 Authorization 헤더에 포함시켜 /users/me 엔드포인트로 요청을 보냅니다.
GET /users/me
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
  • Depends(oauth2_scheme)가 호출되어, FastAPI는 요청의 Authorization 헤더에서 JWT 토큰을 추출합니다.
  • get_current_user
    • jwt.decode() 함수는 디코딩된 페이로드를 딕셔너리 형식으로 반환, 딕셔너리에는 토큰의 내용이 들어있습니다. 이 과정에서 토큰이 서버에서 설정한 SECRET_KEY로 서명된 것이 맞는지, 그리고 지정된 알고리즘(ALGORITHM)을 사용했는지 검증합니다.
      • 토큰, 시크릿키, 알고리즘이 들어감
    • payload: 딕셔너리 형태로, 'sub'은 키값임, 'sub'은 토큰의 주체, 보통 사용자 ID나 이름이 들어갑니다.
from fastapi import Depends, HTTPException
from fastapi.security import OAuth2PasswordBearer

oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

def get_current_user(token: str = Depends(oauth2_scheme)):
    credentials_exception = HTTPException(
        status_code=status.HTTP_401_UNAUTHORIZED,
        detail="Could not validate credentials",
        headers={"WWW-Authenticate": "Bearer"},
    )
    try:
        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
        username: str = payload.get("sub")
        if username is None:
            raise credentials_exception
    except JWTError:
        raise credentials_exception
    return username
    
@app.get("/users/me")
async def read_users_me(current_user: str = Depends(get_current_user)):
    return {"username": current_user}

 

 

위의 방식은 토큰을 로그인시 토큰을 발급해주면, 프론트엔드에서 토큰을 저장한다. 이후, 토큰을 사용할 때에도 Authorization 헤더에 JWT를 넣어 FastAPI에서 인증을 통과한다.

 

아래는 프론트엔드 프레임워크 중 하나인 Next.js 에서 로그인을 하고 받은 응답으로 토큰을 받아 저장하는 에시코드 입니다.

// pages/login.js

import { useState } from 'react';
import { useRouter } from 'next/router';

export default function Login() {
    const [username, setUsername] = useState('');
    const [password, setPassword] = useState('');
    const router = useRouter();

    const handleLogin = async (event) => {
        event.preventDefault();

        const res = await fetch('/token', { // 여기서 토큰을 달라고 요청하고 결과를 받음
            method: 'POST',
            headers: {
                'Content-Type': 'application/x-www-form-urlencoded',
            },
            body: new URLSearchParams({
                'username': username,
                'password': password,
            }),
        });

        if (res.ok) {
            const data = await res.json();
            sessionStorage.setItem('accessToken', data.access_token); // 토큰을 저장함
            console.log('Token stored in session storage:', data.access_token);
            router.push('/profile'); // 로그인 성공 후 프로필 페이지로 이동
        } else {
            console.error('Login failed');
        }
    };

    return (
        <form onSubmit={handleLogin}>
            <input
                type="text"
                value={username}
                onChange={(e) => setUsername(e.target.value)}
                placeholder="Username"
            />
            <input
                type="password"
                value={password}
                onChange={(e) => setPassword(e.target.value)}
                placeholder="Password"
            />
            <button type="submit">Login</button>
        </form>
    );
}

 

아래는 /users/me 요청 시 Authorization 헤더에 JWT 토큰을 포함하는 예제코드 입니다.

// pages/profile.js

import { useEffect, useState } from 'react';

export default function Profile() {
    const [profile, setProfile] = useState(null);

    useEffect(() => {
        const fetchProfile = async () => {
            const token = sessionStorage.getItem('accessToken'); // 저장된 토큰 불러옴

            if (!token) {
                console.error('No token found in session storage');
                return;
            }

            const res = await fetch('/users/me', {
                method: 'GET',
                headers: {
                    'Authorization': `Bearer ${token}`, // 여기에 토큰을 넣어줌
                    'Content-Type': 'application/json',
                },
            });

            if (res.ok) {
                const data = await res.json();
                setProfile(data);
            } else {
                console.error('Failed to fetch user profile');
            }
        };

        fetchProfile();
    }, []);

    if (!profile) {
        return <div>Loading...</div>;
    }

    return (
        <div>
            <h1>User Profile</h1>
            <p>Username: {profile.username}</p>
            {/* 필요한 추가 정보들 */}
        </div>
    );
}
반응형