Tóm tắt: uv add "fastapi[standard]" pyjwt "pwdlib[argon2]" → lưu mật khẩu dạng hash Argon2 → endpoint POST /token nhận form username/password (OAuth2PasswordRequestForm) và trả JWT ký HS256 có exp → OAuth2PasswordBearer đọc header Authorization: Bearer ... → dependency get_current_user giải mã và kiểm tra token. Đã chạy thử với FastAPI 0.162, PyJWT 2.15, pwdlib 0.3. Nền tảng FastAPI xem FastAPI cơ bản.
Luồng hoạt động
sequenceDiagram participant C as Client participant A as FastAPI C->>A: POST /token (form username, password) A->>A: pwdlib verify hash A-->>C: access_token (JWT, exp 30 phút) C->>A: GET /users/me + Authorization Bearer A->>A: OAuth2PasswordBearer lấy token A->>A: jwt.decode kiểm chữ ký và exp A-->>C: 200 user, hoặc 401 + WWW-Authenticate
Cấu hình
Cài và tạo secret
uv add "fastapi[standard]" pyjwt "pwdlib[argon2]"
export JWT_SECRET="$(openssl rand -hex 32)" # production: lấy từ secret manager
export DEMO_PASSWORD='***' # chỉ cho ví dụ dướiHướng dẫn cũ dùng python-jose và passlib: cả hai gần như không còn bảo trì. FastAPI docs hiện dùng PyJWT và pwdlib. Gói PyPI tên pyjwt nhưng import là jwt; đừng cài nhầm gói jwt khác tên.
Ứng dụng
import os
from datetime import UTC, datetime, timedelta
from typing import Annotated
import jwt
from fastapi import Depends, FastAPI, HTTPException, status
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from jwt.exceptions import InvalidTokenError
from pwdlib import PasswordHash
from pydantic import BaseModel
SECRET_KEY = os.environ["JWT_SECRET"] # openssl rand -hex 32
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE = timedelta(minutes=30)
password_hash = PasswordHash.recommended() # Argon2
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
DUMMY_HASH = password_hash.hash("dummy")
# Demo: thay bằng bảng users trong DB
USERS = {
"netops": {
"username": "netops",
"hashed_password": password_hash.hash(os.environ["DEMO_PASSWORD"]),
"scopes": ["read"],
"disabled": False,
}
}
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class User(BaseModel):
username: str
scopes: list[str] = []
disabled: bool = False
def authenticate(username: str, password: str) -> User | None:
row = USERS.get(username)
if row is None:
password_hash.verify(password, DUMMY_HASH) # tốn thời gian như user thật
return None
if not password_hash.verify(password, row["hashed_password"]):
return None
return User(**row)
def create_access_token(sub: str, expires: timedelta = ACCESS_TOKEN_EXPIRE) -> str:
now = datetime.now(UTC)
payload = {"sub": sub, "iat": now, "exp": now + expires}
return jwt.encode(payload, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]) -> User:
cred_exc = 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]) # check chữ ký + exp
username = payload.get("sub")
except InvalidTokenError:
raise cred_exc from None
row = USERS.get(username) if username else None
if row is None:
raise cred_exc
return User(**row)
async def get_active_user(user: Annotated[User, Depends(get_current_user)]) -> User:
if user.disabled:
raise HTTPException(status_code=403, detail="Inactive user")
return user
CurrentUser = Annotated[User, Depends(get_active_user)]
app = FastAPI()
@app.post("/token")
async def login(form: Annotated[OAuth2PasswordRequestForm, Depends()]) -> Token:
user = authenticate(form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect username or password",
headers={"WWW-Authenticate": "Bearer"},
)
return Token(access_token=create_access_token(user.username))
@app.get("/users/me")
async def me(user: CurrentUser) -> User:
return userGọi thử
TOKEN=$(curl -s -X POST localhost:8000/token \
-d username=netops -d "password=$DEMO_PASSWORD" | jq -r .access_token)
curl -s localhost:8000/users/me -H "Authorization: Bearer $TOKEN"
# Giải mã phần payload để xem (KHÔNG kiểm chữ ký, chỉ để debug)
uv run python -c 'import jwt,sys; print(jwt.decode(sys.argv[1], options={"verify_signature": False}))' "$TOKEN"Trên /docs có nút Authorize: nhập username/password, Swagger UI tự gọi /token và gắn Bearer cho mọi request.
Test
from datetime import timedelta
from fastapi.testclient import TestClient
from main import app, create_access_token
client = TestClient(app)
def test_expired_token_rejected():
expired = create_access_token("netops", expires=timedelta(seconds=-1))
r = client.get("/users/me", headers={"Authorization": f"Bearer {expired}"})
assert r.status_code == 401Thành phần
| Thành phần | Vai trò |
|---|---|
OAuth2PasswordRequestForm |
Đọc form username, password, scope (content-type application/x-www-form-urlencoded) |
OAuth2PasswordBearer(tokenUrl="token") |
Lấy token từ header Authorization: Bearer, thiếu thì trả 401; tokenUrl chỉ để Swagger UI biết chỗ login |
PasswordHash.recommended() |
Hash Argon2, verify() so mật khẩu với hash |
jwt.encode / jwt.decode |
Ký / kiểm chữ ký, tự kiểm exp và báo ExpiredSignatureError (lớp con của InvalidTokenError) |
Claim sub |
Định danh user; iat, exp dùng datetime có timezone UTC |
Phân quyền theo vai trò: thêm claim (vd "scopes": ["read"]) khi tạo token rồi viết dependency require_scope("write") kiểm tra trong get_current_user. Với nhiều service cần xác minh token mà không giữ secret chung, dùng RS256/ES256 (private key ký, public key verify; cần pyjwt[crypto]) hoặc để IdP (Keycloak, Authentik) phát token.
Lỗi thường gặp
| Triệu chứng | Nguyên nhân | Cách xử lý |
|---|---|---|
POST /token trả 422 |
Gửi JSON thay vì form | Gửi -d username=... -d password=... (form-urlencoded); cần python-multipart (có trong fastapi[standard]) |
401 Not authenticated |
Thiếu header hoặc sai dạng | Header đúng: Authorization: Bearer <token> |
| 401 sau một thời gian dùng được | Token hết hạn (exp) |
Đăng nhập lại; cần phiên dài thì làm refresh token riêng, đừng tăng exp lên nhiều ngày |
| Mọi token cũ bị từ chối sau deploy | JWT_SECRET đổi giữa các lần chạy hoặc khác nhau giữa các worker |
Đặt secret cố định qua env/secret manager, giống nhau trên mọi instance |
DecodeError: It is required that you pass in a value for the "algorithms" argument |
Gọi jwt.decode thiếu algorithms= |
Luôn truyền algorithms=["HS256"] cố định, không lấy từ header token |
AttributeError: module 'jwt' has no attribute 'encode' |
Cài nhầm gói jwt thay vì pyjwt |
uv remove jwt && uv add pyjwt |
| Token lộ trong log reverse proxy | Truyền token qua query string | Chỉ dùng header; không log header Authorization |