backend / fastapi
Authentification OAuth2 et JWT
Explication
Ce que vous allez apprendre
- Comprendre pourquoi un mot de passe doit toujours être hashé, jamais stocké en clair
- Générer et vérifier un JWT avec une date d'expiration
- Implémenter un endpoint
/logincompatible avec le standard OAuth2 password flow - Extraire l'utilisateur courant à partir d'un token dans une dépendance FastAPI
- Comprendre le rôle d'un refresh token face à un access token de courte durée
Dans quel contexte ?
Un audit de sécurité externe sur app/routers/auth.py révèle que les mots de passe sont stockés en clair dans la colonne password de la table users. En cas de fuite de la base de données — un scénario qui arrive régulièrement même dans des entreprises sérieuses — tous les comptes utilisateurs seraient immédiatement compromis, y compris sur d'autres services où les mêmes utilisateurs auraient réutilisé leur mot de passe. Migrer vers un hashage bcrypt via passlib rend cette fuite bien moins dommageable : les mots de passe hashés restent inutilisables sans un travail de calcul prohibitif.
Le problème, d'abord
Une API doit souvent savoir QUI fait une requête, pour décider ce qu'elle a le droit de faire. Comme HTTP est un protocole "sans état", chaque requête est indépendante des précédentes.
Il faut donc un mécanisme pour transporter cette information d'identité de façon fiable à chaque appel. Sans redemander un mot de passe à chaque fois.
Le JWT (JSON Web Token) répond à ce besoin. C'est un jeton signé cryptographiquement qui contient des informations comme l'identifiant de l'utilisateur et une date d'expiration.
Le point essentiel à comprendre : le serveur peut VÉRIFIER que ce jeton n'a pas été modifié. Grâce à la signature, calculée avec une clé secrète que lui seul connaît, sans avoir besoin de consulter une base de données à chaque requête.
Avant d'aller plus loin, une règle absolue à connaître sur les mots de passe. Un mot de passe ne doit JAMAIS être stocké tel quel, car en cas de fuite de la base de données, tous les comptes seraient immédiatement compromis.
Le hashage, avec bcrypt ici, transforme le mot de passe en une empreinte à sens unique. Impossible de retrouver le mot de passe d'origine à partir du hash, mais possible de vérifier qu'un mot de passe saisi lui correspond.
Une fois cette base posée, voyons comment ce jeton circule concrètement : le flux OAuth2 "password". Le client envoie identifiant et mot de passe une fois à /login, et reçoit un access token en retour.
Il présente ensuite ce token à chaque requête protégée suivante, dans l'en-tête Authorization: Bearer .... FastAPI reconnaît nativement ce standard, ce qui active le bouton "Authorize" dans la documentation Swagger.
Il reste une dernière question : pourquoi utiliser un refresh token séparé ? Un access token a une durée de vie volontairement courte, pour limiter les dégâts s'il est volé.
Le refresh token, plus long à vivre mais utilisé uniquement pour obtenir un nouvel access token, permet à l'utilisateur de rester connecté sans se réauthentifier constamment. Chaque access token reste ainsi éphémère, tout en préservant le confort de l'utilisateur.
Piège de sécurité
Ne jamais stocker un mot de passe en clair, même temporairement, même en développement. Un hash bcrypt (pwd_context.hash(password)) est irréversible par conception : c'est précisément ce qui protège les comptes en cas de fuite de la base de données.
| Jeton | Durée de vie typique | Usage |
|---|---|---|
| Access token | Courte (15-30 min) | Authentifier chaque requête à l'API |
| Refresh token | Longue (jours/semaines) | Obtenir un nouvel access token sans re-login |
Commandes & code
Authentification OAuth2 et JWT
pip install "python-jose[cryptography]" "passlib[bcrypt]" python-multipart# app/core/security.py
from datetime import datetime, timedelta, timezone
from passlib.context import CryptContext
from jose import jwt, JWTError
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
SECRET_KEY = "change-me-en-production" # à charger depuis les settings, jamais en dur
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 30
def hash_password(password: str) -> str:
return pwd_context.hash(password)
def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed)
def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
to_encode = data.copy()
expire = datetime.now(timezone.utc) + (expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
def decode_access_token(token: str) -> dict:
try:
return jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise ValueError("Token invalide ou expiré")# app/routers/auth.py — endpoint de login compatible OAuth2PasswordRequestForm
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from typing import Annotated
from sqlalchemy.orm import Session
router = APIRouter(prefix="/auth", tags=["auth"])
@router.post("/login")
def login(
form_data: Annotated[OAuth2PasswordRequestForm, Depends()],
db: Annotated[Session, Depends(get_db)],
):
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Email ou mot de passe incorrect",
headers={"WWW-Authenticate": "Bearer"},
)
access_token = create_access_token(data={"sub": str(user.id), "role": user.role})
return {"access_token": access_token, "token_type": "bearer"}# app/dependencies.py — dépendance pour extraire l'utilisateur courant depuis le token
from fastapi.security import OAuth2PasswordBearer
from fastapi import Depends, HTTPException, status
from typing import Annotated
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") # utilisé par Swagger UI pour le bouton "Authorize"
def get_current_user(
token: Annotated[str, Depends(oauth2_scheme)],
db: Annotated[Session, Depends(get_db)],
) -> User:
try:
payload = decode_access_token(token)
user_id = int(payload.get("sub"))
except (ValueError, TypeError):
raise HTTPException(status_code=401, detail="Token invalide")
user = db.get(User, user_id)
if not user:
raise HTTPException(status_code=401, detail="Utilisateur introuvable")
return user# Utilisation dans un endpoint protégé
@router.get("/me")
def read_current_user(current_user: Annotated[User, Depends(get_current_user)]):
return {"id": current_user.id, "email": current_user.email}# Refresh token — renouveler l'access token sans redemander les identifiants
REFRESH_SECRET_KEY = "autre-secret-pour-le-refresh"
def create_refresh_token(user_id: int) -> str:
expire = datetime.now(timezone.utc) + timedelta(days=7)
return jwt.encode({"sub": str(user_id), "exp": expire}, REFRESH_SECRET_KEY, algorithm=ALGORITHM)
@router.post("/refresh")
def refresh_token(refresh_token: str, db: Annotated[Session, Depends(get_db)]):
try:
payload = jwt.decode(refresh_token, REFRESH_SECRET_KEY, algorithms=[ALGORITHM])
except JWTError:
raise HTTPException(status_code=401, detail="Refresh token invalide")
user = db.get(User, int(payload["sub"]))
new_access_token = create_access_token(data={"sub": str(user.id), "role": user.role})
return {"access_token": new_access_token, "token_type": "bearer"}Résumé
OAuth2PasswordBearerintègre le flux d'authentification directement dans Swagger UI (bouton "Authorize").- Les mots de passe sont hashés avec bcrypt via
passlib, jamais stockés ou comparés en clair. - Le JWT porte les claims essentielles (
sub,role,exp) et est vérifié à chaque requête protégée. - Un refresh token séparé (secret différent, durée de vie longue) permet de renouveler l'access token sans re-login.
Exercices pratiques
Mission : l'audit de sécurité qui découvre des mots de passe en clair
Objectif : Migrer un stockage de mots de passe en clair vers bcrypt et corriger un endpoint /login vulnérable.
Contexte
Un audit de sécurité externe sur app/routers/auth.py révèle que la colonne password de la table users contient des mots de passe en clair, et que login() compare directement form_data.password == user.password. L'auditeur exige une migration vers bcrypt avant la prochaine mise en production.