Cours Python · Module 6: Async et FastAPI

Authentification JWT - les jetons d'accès

6 min de lecture
Dans cette leçon6

Bienvenue ! Ici Darwin, avec l'authentification JWT pour FastAPI !

Notre Safari API accepte et enregistre déjà les observations, mais pour l'instant n'importe qui peut ajouter dans la base qu'il a vu un pingouin dans la savane. Il nous faut une barrière où le touriste montre son laissez-passer avant de modifier quoi que ce soit.

JWT (JSON Web Tokens) est un standard de jetons d'accès - au lieu de cookies de session, on utilise des jetons sans état (stateless tokens) !

"Sans état" signifie que le serveur ne garde pas de liste des personnes connectées. Tout ce dont il a besoin voyage dans le jeton lui-même, et le serveur vérifie seulement sa signature. Le prix à payer : un jeton émis ne peut pas être facilement révoqué avant son expiration, c'est pourquoi les jetons d'accès ont une durée de vie courte.

Analogie safari : Un JWT, c'est comme un laissez-passer électronique de safari - il contient toutes les infos sur le touriste (ID, permissions), il est signé numériquement (impossible à falsifier) et il a une date d'expiration (expires) !

De quoi se compose un jeton

Un JWT, ce sont trois morceaux de texte reliés par des points : header.payload.signature. Le header indique quel algorithme a signé le jeton. Le payload contient les données, les fameux claims, par exemple sub (la personne concernée) et exp (la date de fin de validité). La signature est calculée à partir des deux premières parties et d'une clé secrète. Le header et le payload sont seulement encodés en Base64URL, pas chiffrés : tout le monde peut les lire, alors ne mets jamais de mot de passe dans un jeton. On ne peut falsifier qu'un laissez-passer sans sceau, et personne ne reproduit le sceau sans la clé.

Installation

On installe une bibliothèque de jetons, une bibliothèque de hachage des mots de passe et python-multipart, dont FastAPI a besoin pour lire les formulaires :

1pip install python-jose[cryptography] passlib[bcrypt] python-multipart

Tu trouveras ce trio dans beaucoup de projets. La documentation actuelle de FastAPI recommande pourtant PyJWT et pwdlib avec l'algorithme Argon2 (pip install pyjwt "pwdlib[argon2]"), car passlib n'a pas eu de nouvelle version depuis 2020. Je te montre la différence à la fin de la leçon.

Hachage des mots de passe

Les mots de passe n'entrent jamais tels quels dans la base. On stocke un hash - une empreinte à sens unique à partir de laquelle on ne peut pas retrouver l'original. CryptContext de passlib cache les détails de l'algorithme bcrypt :

1from passlib.context import CryptContext
2
3pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
4
5def hash_password(password: str) -> str:
6    return pwd_context.hash(password)
7
8def verify_password(plain_password: str, hashed_password: str) -> bool:
9    return pwd_context.verify(plain_password, hashed_password)

Tu appelles hash_password une seule fois, à l'inscription. À la connexion, verify_password hache le mot de passe saisi et compare le résultat avec l'empreinte stockée : le mot de passe lui-même n'est conservé nulle part.

Création des jetons JWT

D'abord les constantes : la clé secrète, l'algorithme et la durée de vie du jeton. HS256 est une signature HMAC avec SHA-256, où la même clé signe et vérifie :

1from jose import JWTError, jwt
2from datetime import datetime, timedelta, timezone
3
4SECRET_KEY = "your-secret-key-keep-it-secret"
5ALGORITHM = "HS256"
6ACCESS_TOKEN_EXPIRE_MINUTES = 30

La clé de l'exemple n'est qu'un espace réservé. Génère une vraie clé avec openssl rand -hex 32 et garde-la dans une variable d'environnement, jamais dans le dépôt.

Voici maintenant la fonction qui délivre le laissez-passer. Elle copie les données, ajoute le claim exp et signe le tout :

1def create_access_token(data: dict):
2    to_encode = data.copy()
3    expire = datetime.now(timezone.utc) + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
4    to_encode.update({"exp": expire})
5    encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
6    return encoded_jwt

data.copy() protège le dict de l'appelant contre toute modification. On calcule l'heure avec datetime.now(timezone.utc). Dans du code plus ancien, y compris certains exercices, tu verras datetime.utcnow() : depuis Python 3.12, cette méthode est marquée comme dépréciée, car elle renvoie une heure sans fuseau horaire.

Le décodage fonctionne dans l'autre sens :

1def decode_token(token: str):
2    try:
3        payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
4        return payload
5    except JWTError:
6        return None

jwt.decode vérifie la signature et rejette automatiquement un jeton dont l'exp est dépassé. La liste algorithms compte : elle dit quels algorithmes tu acceptes, donc personne ne peut glisser un jeton signé avec un autre. Tout problème se termine ici par la valeur None.

Endpoint de connexion

Place à la barrière. OAuth2PasswordBearer extrait le jeton de l'en-tête Authorization: Bearer <token>, et tokenUrl indique l'adresse de connexion utilisée par le bouton "Authorize" de la documentation Swagger :

1from fastapi import FastAPI, Depends, HTTPException, status
2from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
3
4app = FastAPI()
5
6oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")

Cet objet ne vérifie rien tout seul. Si l'en-tête manque, il répond immédiatement 401, et s'il est présent, il transmet le texte brut du jeton.

L'endpoint de connexion accepte un formulaire avec les champs username et password, c'est pour cela qu'il fallait python-multipart :

1@app.post("/token")
2async def login(form_data: OAuth2PasswordRequestForm = Depends()):
3    # Vérifie l'utilisateur dans la base de données
4    user = authenticate_user(form_data.username, form_data.password)
5
6    if not user:
7        raise HTTPException(
8            status_code=status.HTTP_401_UNAUTHORIZED,
9            detail="Incorrect username or password"
10        )
11
12    access_token = create_access_token(data={"sub": user.username})
13    return {"access_token": access_token, "token_type": "bearer"}

La fonction authenticate_user ne fait pas partie de FastAPI : c'est ton propre code, qui cherche l'utilisateur dans la base et appelle verify_password. La réponse avec access_token et token_type: "bearer" est le format exigé par la spécification OAuth2.

Enfin, la dépendance qui transforme un jeton en utilisateur, et la route protégée :

1async def get_current_user(token: str = Depends(oauth2_scheme)):
2    payload = decode_token(token)
3    if not payload:
4        raise HTTPException(status_code=401, detail="Invalid token")
5
6    username = payload.get("sub")
7    return {"username": username}
8
9@app.get("/protected")
10async def protected_route(current_user: dict = Depends(get_current_user)):
11    return {"message": f"Bonjour {current_user['username']} !"}

protected_route ne sait rien des jetons : elle reçoit un utilisateur tout prêt via Depends, exactement comme la session de base de données dans la leçon précédente. La documentation de FastAPI ajoute à une telle exception 401 l'en-tête headers={"WWW-Authenticate": "Bearer"}, comme le veut le standard - je te conseille de faire pareil.

Test :

Vérifions tout depuis le terminal : d'abord la connexion, puis une requête avec le jeton.

1# Connexion
2curl -X POST http://localhost:8000/token \
3  -d "username=darwin&password=secret123"
4
5# Réponse : {"access_token":"eyJ...", "token_type":"bearer"}
6
7# Route protégée
8curl http://localhost:8000/protected \
9  -H "Authorization: Bearer eyJ..."

Le jeton de la réponse commence par eyJ, car c'est à cela que ressemble le début encodé du JSON {". Sans l'en-tête Authorization, la route /protected renvoie 401.

La version avec PyJWT

Si tu suis la documentation actuelle de FastAPI, seuls les imports et le nom de l'exception changent :

1import jwt
2from jwt.exceptions import InvalidTokenError
3
4# Tu appelles jwt.encode(...) et jwt.decode(...) exactement comme plus haut,
5# et dans decode_token tu attrapes InvalidTokenError au lieu de JWTError

Le reste du code, y compris OAuth2PasswordBearer et les endpoints, ne change pas. Dans les nouveaux projets, choisis PyJWT.

Prochaine leçon : les tests avec pytest ! On vérifiera automatiquement que la barrière laisse passer les bons touristes.

Retiens : un JWT est un laissez-passer avec un sceau et une date d'expiration - tout le monde peut le lire, mais personne ne peut le falsifier sans la clé.

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Qu'est-ce que le JWT (JSON Web Token) ?

  2. 2. De quelles trois parties se compose un JWT ?

Voici 2 des 3 questions de cette leçon. Réponds aux autres dans le jeu.

Tâches pratiques dans le jeu

  • Tri horizontal

    À quoi ressemble l'en-tête Authorization avec un jeton Bearer ?

  • Tri vertical

    Ordonne la fonction de création d'un jeton JWT :

  • Clique dans l'ordre

    Ordonne le décodage d'un jeton JWT :

  • Éditeur de code

    Écris un endpoint GET /me qui exige un jeton JWT et renvoie les données de l'utilisateur courant.

Articles utiles