JWT를 이용한 사용자 인증 구현
1. 라우팅 및 세팅
1.1 URL 정보
이번 챕터의 URL 구성은 아래와 같습니다. 테스트할 순서대로 작성되었습니다.
| 경로 | 함수명 | 메서드 | 설명 |
|---|---|---|---|
| /signup | signup | POST | 회원가입 |
| /token | login | POST | 회원가입된 정보로 토큰 발급 |
| /me | read_me | GET | 발급 받은 토큰으로 회원 정보 확인 |
1.2 기본 세팅
이번 실습 폴더는 04_4_jwt_auth입니다. VSC 터미널에서 사용할 명령어 입니다. 가상환경은 벗어난 상태에서 실행해야 합니다. 앞 실습의 서버가 실행 중이면 Ctrl + C로 멈추고, 만약 터미널 입력창 앞에 (venv)라고 되어 있다면 deactivate 명령어로 가상환경을 나간 상태에서 cd ..으로 상위 폴더로 나와 아래 명령어를 실행해주세요.
mkdir 04_4_jwt_auth
cd 04_4_jwt_auth
python -m venv venv
.\venv\Scripts\Activate.ps1
pip install "fastapi[standard]" pyjwt "pwdlib[argon2]"
macOS/Linux에서는 python -m venv venv 대신 python3 -m venv venv를, 활성화 명령 대신 source ./venv/bin/activate를 사용합니다. 이후 명령은 가상환경이 활성화된 상태에서 실행합니다.
| 패키지 | 역할 |
|---|---|
pyjwt | JWT를 생성하고 검증하기 위한 라이브러리 |
pwdlib[argon2] | 비밀번호를 해시로 바꾸고 대조하는 라이브러리 |
멀티파트 요청(파일 업로드나 폼 데이터처럼 복수의 데이터를 전송하는 요청)을 처리하는 python-multipart는 fastapi[standard]에 이미 포함되어 있어 따로 설치하지 않아도 됩니다.
python-jose와 passlib을 쓴 코드를 봤다면
인터넷 자료와 AI가 만들어 주는 FastAPI 인증 코드는 거의 대부분 아래 두 라이브러리를 씁니다.
# 오래된 방식
from jose import jwt, JWTError
from passlib.context import CryptContext
두 라이브러리 모두 오랫동안 새 릴리스가 나오지 않고 있으며, FastAPI 공식 문서도 지금은 PyJWT와 pwdlib로 바꿔 설명하고 있습니다. 특히 passlib은 최신 파이썬과 bcrypt 조합에서 설치 직후 에러가 나는 경우가 있어, 처음 배우는 분들이 여기서 많이 막힙니다.
인증은 보안과 직결되는 영역입니다. 유지보수되지 않는 라이브러리에 이 부분을 맡기지 않는 것이 좋습니다. AI가 만들어 준 인증 코드에서 from jose import나 passlib이 보이면 그대로 쓰지 마시고 아래 표대로 바꿔주세요.
| 오래된 방식 | 지금 방식 |
|---|---|
from jose import jwt | import jwt (PyJWT) |
jose.JWTError | jwt.InvalidTokenError |
CryptContext(schemes=["bcrypt"]) | PasswordHash.recommended() |
pwd_context.hash(pw) | password_hash.hash(pw) |
pwd_context.verify(a, b) | password_hash.verify(a, b) |
2. JWT 구현 방식과 기본 세팅
JWT를 구현하기 위해 DB를 사용해야 하지만, 이번 실습에서는 DB 대신 메모리 내 파이썬 데이터 구조를 사용하여 데이터를 저장할 것입니다. 또한 복잡도를 낮추기 위해 리프레시 토큰을 구현하지 않습니다. 이는 과제로 남겨두었습니다. 이는 개념을 간단히 설명하기 위한 것이며, 실제 애플리케이션에서는 보통 앞서 학습한 데이터베이스를 사용합니다. 5장에서 이 코드를 데이터베이스 버전으로 옮깁니다.
또 JWT를 구현하기 위해는 User가 있어야 합니다. User는 일반 테이블보다 고려해야 할 사항이 많습니다. 예를 들어, 패스워드를 저장할 때는 해싱을 해야 합니다. 관리자가 패스워드를 DB에서 확인하더라도, 어떤 패스워드인지 알 수 없게 해야 하기 때문입니다. 비밀번호 해싱은 지금부터 제대로 합니다. 학습용이라도 비밀번호를 그대로 저장하는 코드는 쓰지 않는 편이 좋습니다. 그렇게 배우면 습관이 되기 때문입니다.
2.1 비밀번호를 해시로 저장한다는 것
해시는 원래 값으로 되돌릴 수 없는 일방향 변환입니다.
| 비밀번호 그대로 저장 | 해시로 저장 | |
|---|---|---|
| DB에 보이는 값 | test1234 | $argon2id$v=19$m=65536,... |
| 관리자가 볼 수 있나 | 볼 수 있습니다 | 볼 수 없습니다 |
| DB가 유출되면 | 모든 계정이 뚫립니다 | 바로 뚫리지는 않습니다 |
| 로그인 확인 방법 | 값을 비교 | 입력값을 해시해서 비교 |
사용자가 다른 사이트에서도 같은 비밀번호를 쓰는 경우가 많기 때문에, 비밀번호 유출은 우리 서비스만의 문제로 끝나지 않습니다.
pwdlib의 PasswordHash.recommended()는 현재 권장되는 알고리즘(Argon2)을 골라줍니다. 알고리즘 이름을 직접 고르지 않아도 되며, 권장 알고리즘이 바뀌면 라이브러리 업데이트만으로 따라갈 수 있습니다.
이번 실습에서는 간단한 User를 구현하고, JWT를 이용하여 사용자 인증을 구현해보겠습니다.
3. 코드 구현
복잡도를 최소화 했기 때문에 코드가 짧습니다. main.py 파일에 아래 코드를 작성합니다. 전체 코드이며, 이어서 부분별로 설명합니다.
from datetime import datetime, timedelta, timezone
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pwdlib import PasswordHash
from pydantic import BaseModel, Field
app = FastAPI(title="JWT 인증 예제")
# ------------------------------------------------------------------
# 설정
# ------------------------------------------------------------------
# 실제 서비스에서는 환경 변수로 관리해야 합니다(7장에서 다룹니다).
SECRET_KEY = "9f2c8e1b47a5d3f6089b2e7c4a1d5f83b6e0c9a2d7f4b1e8c3a6d9f2b5e8c1a4"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
# 간단한 사용자 저장소 (실제 환경에서는 데이터베이스를 사용합니다)
# {"licat": {"username": "licat", "hashed_password": "..."}}
users_db: dict[str, dict] = {}
password_hash = PasswordHash.recommended()
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
# ------------------------------------------------------------------
# 모델
# ------------------------------------------------------------------
class UserCreate(BaseModel):
username: str = Field(min_length=3, max_length=20)
password: str = Field(min_length=8)
class UserPublic(BaseModel):
username: str
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
# ------------------------------------------------------------------
# 인증 유틸리티
# ------------------------------------------------------------------
def create_access_token(username: str) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
payload = {"sub": username, "exp": expire}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> UserPublic:
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="자격 증명을 확인할 수 없습니다",
headers={"WWW-Authenticate": "Bearer"},
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except jwt.InvalidTokenError:
raise credentials_exception
username = payload.get("sub")
if username is None or username not in users_db:
raise credentials_exception
return UserPublic(username=username)
CurrentUser = Annotated[UserPublic, Depends(get_current_user)]
# ------------------------------------------------------------------
# 엔드포인트
# ------------------------------------------------------------------
@app.post("/signup", status_code=status.HTTP_201_CREATED, tags=["인증"])
def signup(user_data: UserCreate) -> UserPublic:
"""회원가입을 합니다. 비밀번호는 해시로 변환되어 저장됩니다."""
if user_data.username in users_db:
raise HTTPException(status_code=400, detail="이미 사용 중인 아이디입니다")
users_db[user_data.username] = {
"username": user_data.username,
"hashed_password": password_hash.hash(user_data.password),
}
return UserPublic(username=user_data.username)
@app.post("/token", tags=["인증"])
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
"""아이디와 비밀번호를 확인하고 액세스 토큰을 발급합니다."""
user = users_db.get(form_data.username)
if user is None or not password_hash.verify(
form_data.password, user["hashed_password"]
):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="아이디 또는 비밀번호가 올바르지 않습니다",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=create_access_token(form_data.username))
@app.get("/me", tags=["인증"])
def read_me(current_user: CurrentUser) -> UserPublic:
"""토큰으로 확인한 현재 로그인 사용자를 반환합니다."""
return current_user
3.1 OAuth2PasswordBearer가 하는 일
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
여기서 OAuth2PasswordBearer를 설명하기 위해 OAuth2를 설명할 필요가 있습니다. OAuth2는 인증 및 권한 부여를 위한 업계 표준 프로토콜입니다. 주요 목적은 사용자의 비밀번호를 공유하지 않고도 인증을 할 수 있게 하는 것에 목적이 있습니다. 여러 특징이 있지만 가장 중요한 특징으로는 헤더에 "Bearer {token}" 형식으로 토큰을 담아 보낸다는 특징이 있습니다. 여기서 OAuth2PasswordBearer는 FastAPI에서 OAuth2를 사용하여 토큰을 처리합니다. 이 한 줄이 세 가지 일을 합니다.
- 요청의
Authorization헤더에서Bearer뒤의 문자열을 꺼내옵니다. - 헤더가 없으면 자동으로 401 에러를 반환합니다.
/docs화면 오른쪽 위에Authorize버튼을 만들어 줍니다.
tokenUrl="token"은 "토큰을 받으려면 /token으로 요청하라"는 정보입니다. 실제 동작에 영향을 주는 것이 아니라 문서에 표시되는 안내입니다.
3.2 OAuth2PasswordRequestForm이 JSON이 아닌 이유
로그인 엔드포인트만 요청 본문이 JSON이 아니라 폼 데이터입니다.
def login(form_data: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
OAuth2PasswordRequestForm은 username과 password라는 이름의 폼 필드를 받습니다. OAuth2 표준이 그렇게 정해두었기 때문입니다. 이메일로 로그인하는 서비스라도 필드 이름은 username이어야 합니다.
.http 파일에서는 아래와 같이 보냅니다.
POST http://127.0.0.1:8000/token
Content-Type: application/x-www-form-urlencoded
username=licat&password=test1234
Content-Type이 application/json이 아닌 점에 주의하세요. JSON으로 보내면 422 에러가 납니다.
표준을 따르면 얻는 것
굳이 폼 데이터를 쓰는 이유가 궁금할 수 있습니다. 표준을 따르면 /docs의 Authorize 버튼이 그대로 동작하고, OAuth2를 지원하는 다른 클라이언트 도구들도 별도 설정 없이 붙습니다. 직접 만든 규칙이었다면 모두 따로 설명해야 합니다.
3.3 의존성을 타입 별칭으로 묶기
CurrentUser = Annotated[UserPublic, Depends(get_current_user)]
@app.get("/me")
def read_me(current_user: CurrentUser) -> UserPublic:
return current_user
앞 절에서 SessionDep을 만들었던 것과 같은 방식입니다. 로그인이 필요한 엔드포인트마다 current_user: CurrentUser만 적으면 됩니다. 이 한 줄이 붙는 순간 그 엔드포인트는 토큰 없이 호출할 수 없게 됩니다.
의존성 주입이 인증에서 특히 유용한 이유가 여기에 있습니다. "이 API는 로그인이 필요하다"는 것이 함수 시그니처에 드러나고, 자동 문서에도 자물쇠 표시로 나타납니다.
프론트엔드에서는 아래와 같이 토큰을 붙여 요청합니다. 다만 여기서는 CORS 에러가 발생하므로 테스트할 때는 .http 파일이나 Swagger UI로만 진행해주세요. CORS 에러를 해결하기 위한 미들웨어 설정은 5장에서 다룹니다.
fetch('http://127.0.0.1:8000/me', {
method: 'GET',
headers: {
'Authorization': 'Bearer ' + token
}
})
.then(response => response.json())
.then(data => {
console.log('Success:', data);
})
4. 애플리케이션 실행
터미널에서 다음 명령어를 실행하여 애플리케이션을 시작합니다.
fastapi dev
5. API 테스트
5.1 .http 파일로 테스트하기
api.http 파일을 만들고 아래 내용을 넣습니다.
@baseUrl = http://127.0.0.1:8000
### 1. 회원가입
POST {{baseUrl}}/signup
Content-Type: application/json
{
"username": "licat",
"password": "test1234"
}
### 2. 로그인해서 토큰 받기
# @name login
POST {{baseUrl}}/token
Content-Type: application/x-www-form-urlencoded
username=licat&password=test1234
### 3. 토큰으로 내 정보 조회
GET {{baseUrl}}/me
Authorization: Bearer {{login.response.body.access_token}}
### 4. 토큰 없이 조회 (401이 나와야 정상입니다)
GET {{baseUrl}}/me
### 5. 잘못된 토큰으로 조회 (401이 나와야 정상입니다)
GET {{baseUrl}}/me
Authorization: Bearer this.is.not.a.valid.token
### 6. 비밀번호를 틀리게 입력 (401이 나와야 정상입니다)
POST {{baseUrl}}/token
Content-Type: application/x-www-form-urlencoded
username=licat&password=wrongpassword
2번 요청 위의 # @name login이 핵심입니다. 이 이름을 붙여두면 3번 요청에서 {{login.response.body.access_token}}으로 방금 받은 토큰을 바로 꺼내 쓸 수 있습니다. 토큰을 복사해서 붙여넣는 과정이 사라집니다.
순서대로 실행하면서 아래를 확인해보세요.
- 1번 응답에
password가 없습니다. 응답 모델이 걸러냈습니다. - 3번이 200으로 성공합니다.
- 4번, 5번, 6번이 모두 401입니다.
- 30분을 기다린 뒤 3번을 다시 실행하면 401이 됩니다.
5.2 Swagger UI로 테스트하기
http://127.0.0.1:8000/docs에 접속하면 오른쪽 위에 Authorize 버튼이 생긴 것을 볼 수 있습니다.
Authorize버튼을 클릭합니다.- username과 password 입력칸이 나옵니다. 회원가입할 때 쓴 값을 넣습니다.
Authorize를 누르면 Swagger UI가 대신/token을 호출해 토큰을 받아 보관합니다.- 이제
/me의Try it out을 눌러 실행하면 토큰이 자동으로 붙어 나갑니다.
토큰 문자열을 직접 다루지 않아도 되는 것이 OAuth2PasswordBearer를 쓴 덕분입니다. 자물쇠가 잠긴 엔드포인트에는 로그인이 필요합니다.
6. 저장된 비밀번호 확인해보기
해싱이 실제로 되고 있는지 확인해보겠습니다. /me 아래에 임시 엔드포인트를 하나 추가합니다.
@app.get("/debug/users", tags=["임시"])
def debug_users() -> dict:
"""학습용입니다. 실제 서비스에는 절대 만들면 안 되는 엔드포인트입니다."""
return users_db
GET /debug/users를 호출하면 아래와 비슷한 값이 나옵니다.
{
"licat": {
"username": "licat",
"hashed_password": "$argon2id$v=19$m=65536,t=3,p=4$..."
}
}
test1234라는 원래 비밀번호는 어디에도 없습니다. 그런데도 로그인이 되는 이유는, 로그인할 때 입력받은 값을 같은 방식으로 해시해서 저장된 값과 비교하기 때문입니다.
같은 비밀번호로 다른 계정을 하나 더 만들어보세요. 해시 값이 서로 다르게 나옵니다. 매번 다른 무작위 값(솔트)을 섞어 해시하기 때문입니다. 이 덕분에 "해시 값이 같으면 비밀번호도 같다"는 추론이 불가능해집니다.
확인이 끝났으면 이 엔드포인트는 지워주세요.
7. 지금 코드에 남아 있는 한계
이 코드는 학습용이며, 실제 서비스로 쓰기에는 아래가 부족합니다. 무엇이 부족한지 아는 것도 중요합니다.
| 한계 | 어떻게 해결하나 |
|---|---|
| 서버를 껐다 켜면 사용자가 사라집니다 | 데이터베이스에 저장합니다 (5장) |
| 비밀 키가 코드에 적혀 있습니다 | 환경 변수로 옮깁니다 (7장) |
| 로그아웃을 해도 토큰이 유효합니다 | 수명을 짧게 두거나 서버에 무효 목록을 둡니다 |
| 토큰이 만료되면 다시 로그인해야 합니다 | 리프레시 토큰을 구현합니다 (연습문제) |
| 비밀번호를 8자 이상만 검사합니다 | 유출된 비밀번호 목록 대조 등을 추가합니다 |
연습 문제
- 리프레시 토큰을 추가하여 토큰 갱신 기능을 구현해보세요.
/refresh엔드포인트를 만들고, 리프레시 토큰의 수명은 7일로 설정해보세요. - 앞 절에서 배운 SQLAlchemy를 사용하여 사용자 정보를 데이터베이스에 저장하도록 코드를 수정해보세요.
/me외에 로그인이 필요한 엔드포인트를 하나 더 만들어보세요. 예를 들어POST /posts를 만들고, 작성자를current_user.username으로 자동으로 채워보세요.- 토큰의 만료 시간을 10초로 바꾸고, 발급 직후와 15초 뒤의 응답이 어떻게 다른지 확인해보세요.
- 회원가입 시 이미 있는 아이디를 다시 등록하면 어떤 응답이 오는지 확인하고, 응답 메시지에 어떤 정보까지 담는 것이 적절할지 생각해보세요. 힌트로, "이미 있는 아이디입니다"라는 메시지는 그 아이디가 가입되어 있다는 사실을 외부에 알려줍니다.