02-request-lifecycle.md 12902 octets

Cycle de vie d'une requête

Ce document suit le chemin d'une requête HTTP depuis l'entrée dans Axum jusqu'à la réponse, en se concentrant sur les flux d'authentification. L'objectif est de comprendre pourquoi le code est organisé ainsi, pas seulement comment l'appeler.


Le voyage d'une requête authentifiée

sequenceDiagram
    participant C as Client
    participant R as Routeur Axum
    participant EXT as Extension AuthConfig
    participant BL as JwtBlacklistService
    participant JWT as JwtService
    participant AU as Extracteur AuthUser
    participant H as Handler
    participant SVC as Service métier
    participant DB as PostgreSQL

    C->>R: requête HTTP (Authorization: Bearer <jwt> ou cookie jwt_token)
    R->>EXT: récupère Arc<AuthConfig> depuis les extensions Axum
    R->>BL: is_blacklisted(SHA256(token))
    BL->>DB: SELECT FROM jwt_blacklist WHERE hash = ?
    DB-->>BL: résultat
    BL-->>R: false (non révoqué)
    R->>JWT: validate_token(auth_config, token)
    JWT-->>R: Claims { sub, jti, username, roles, permissions, kind }
    R->>AU: from_request_parts → AuthUser { user_id, username, roles, permissions }
    AU->>H: handler reçoit AuthUser en paramètre
    H->>SVC: appel service
    SVC->>DB: requête SeaORM
    DB-->>SVC: données
    SVC-->>H: résultat métier
    H-->>C: réponse JSON (200 OK)

La validation du token ne touche la base de données qu'une seule fois (vérification blacklist). La vérification de signature JWT est purement en mémoire — c'est intentionnel pour la scalabilité.


Login — email + mot de passe

Endpoint : POST /api/v1/auth/login
Handler : login

sequenceDiagram
    participant C as Client
    participant H as Handler login
    participant RL as RateLimitService
    participant AS as AuthService
    participant US as UserService
    participant TOTP as TotpService
    participant JWT as JwtService
    participant RT as RefreshTokenService
    participant HK as RustwardenHooks
    participant DB as PostgreSQL

    C->>H: POST /api/v1/auth/login {username, password, remember_me?}
    H->>RL: check_auth_rate_limit (5 req/min par IP)
    alt limite atteinte
        RL-->>C: 429 Too Many Requests
    end
    H->>AS: authenticate_user(db, username, password)
    AS->>DB: SELECT user WHERE username = ?
    DB-->>AS: user model
    AS->>AS: bcrypt::verify(password, hash)
    alt credentials invalides
        AS-->>C: 401 Unauthorized (message générique)
    end
    AS-->>H: User
    H->>H: vérifier email_verified (si requis par AppSettings)
    H->>TOTP: is_totp_enabled(db, user_id)
    alt 2FA activé
        TOTP->>DB: create_challenge(user_id)
        DB-->>TOTP: challenge_token
        TOTP-->>C: 200 { requires_2fa: true, challenge_token }
        Note over C: le client fait ensuite POST /api/v1/auth/2fa/verify
    end
    H->>US: get_user_roles(db, user_id)
    DB-->>US: roles[]
    H->>JWT: generate_token(auth_config, user_id, username, roles, permissions)
    JWT-->>H: access_token (JWT signé HS256)
    H->>RT: create_refresh_token(db, user_id, expiration_days)
    RT->>DB: INSERT refresh_token (hash SHA-256)
    DB-->>RT: CreatedRefreshToken
    RT-->>H: plain_token (envoyé au client)
    H->>H: construire cookie jwt_token (HttpOnly, Secure, SameSite=Strict)
    H->>HK: on_user_login(db, user_id, username)
    H-->>C: 200 LoginResponse + Set-Cookie: jwt_token

Points notables du flux de login :

  • AuthService::authenticate_user utilise bcrypt::verify en temps constant pour éviter les attaques temporelles.
  • Les messages d'erreur sont intentionnellement génériques (pas de distinction "utilisateur inconnu" / "mot de passe incorrect").
  • Le cookie jwt_token est HttpOnly — JavaScript ne peut pas le lire. Le body JSON contient aussi le token pour les clients API (mobile, SPA avec storage explicite).
  • remember_me: true allonge la durée du refresh token (remember_me_expiration_days) sans modifier l'access token.

Flux 2FA

Si TotpService::is_totp_enabled retourne true, le handler n'émet pas de JWT. Il crée un challenge temporaire et retourne { requires_2fa: true, challenge_token }. Le client doit ensuite appeler POST /api/v1/auth/2fa/verify avec le code TOTP à 6 chiffres ou un code de récupération.


Refresh token

Endpoint : POST /api/v1/auth/refresh
Handler : refresh_token

sequenceDiagram
    participant C as Client
    participant H as Handler refresh_token
    participant RL as RateLimitService
    participant RT as RefreshTokenService
    participant US as UserService
    participant JWT as JwtService
    participant DB as PostgreSQL

    C->>H: POST /api/v1/auth/refresh { refresh_token: "abc123..." }
    H->>RL: check_auth_rate_limit
    H->>RT: validate_refresh_token(db, plain_token)
    RT->>RT: SHA256(plain_token) → hash
    RT->>DB: SELECT WHERE hash = ? AND NOT revoked AND NOT expired
    DB-->>RT: refresh_token model
    RT->>RT: has_been_used() ?
    alt token déjà utilisé (attaque de réutilisation)
        RT->>DB: revoke_all_tokens_for_user (sécurité maximale)
        RT-->>C: 401 TokenReused — toutes les sessions révoquées
    end
    RT-->>H: old_token model
    H->>US: get_user_by_id(db, old_token.user_id)
    DB-->>US: user
    H->>US: get_user_roles + get_permissions
    H->>JWT: generate_token(auth_config, …)
    JWT-->>H: nouveau access_token
    H->>RT: create_refresh_token(db, user_id, expiration_days)
    DB-->>RT: new CreatedRefreshToken
    H->>RT: mark_as_replaced(db, old_id, new_id)
    DB-->>RT: OK
    H-->>C: 200 RefreshResponse { access_token, refresh_token, expires_in } + Set-Cookie

La rotation automatique signifie que chaque refresh token n'est utilisable qu'une seule fois. Après usage, il est marqué replaced et un nouveau token est émis. Si le serveur détecte une tentative de réutilisation d'un token déjà remplacé, il révoque toutes les sessions de l'utilisateur — signal fort d'un vol de token.


Logout

Endpoint : POST /api/v1/auth/logout
Handler : logout

sequenceDiagram
    participant C as Client
    participant H as Handler logout
    participant JWT as JwtService
    participant BL as JwtBlacklistService
    participant RT as RefreshTokenService
    participant HK as RustwardenHooks
    participant DB as PostgreSQL

    C->>H: POST /api/v1/auth/logout (Authorization: Bearer ou cookie)
    H->>H: extraire JWT (priorité : header > cookie)
    H->>JWT: validate_token(auth_config, jwt)
    JWT-->>H: Claims { sub: user_id, ... }
    H->>BL: add_to_blacklist(db, jwt, user_id)
    BL->>BL: SHA256(jwt) → hash
    BL->>DB: INSERT jwt_blacklist (hash, user_id, expires_at)
    H->>RT: revoke_all_tokens_for_user(db, user_id)
    DB-->>RT: nombre de tokens révoqués
    H->>HK: on_user_logout(db, user_id)
    H->>H: construire cookie jwt_token vide (MaxAge=0)
    alt requête HTML (Accept: text/html)
        H-->>C: 302 Redirect /login + Set-Cookie: jwt_token= (expiré)
    else requête API
        H-->>C: 200 { message, tokens_revoked } + Set-Cookie: jwt_token= (expiré)
    end

Le logout effectue deux opérations complémentaires : blacklister l'access token courant (révocation immédiate, même si le JWT n'est pas encore expiré) ET révoquer tous les refresh tokens de l'utilisateur. La réponse est adaptée selon le header Accept — redirect pour les formulaires HTML, JSON pour les clients API.


Extraction d'utilisateur dans un handler

L'extracteur AuthUser est le mécanisme standard pour accéder à l'identité de l'appelant dans un handler Axum.

use axum::{routing::get, Router, Json};
use rustwarden_core::auth::extractors::AuthUser;

async fn get_profile(user: AuthUser) -> Json<serde_json::Value> {
    Json(serde_json::json!({
        "user_id": user.user_id,
        "username": user.username,
        "roles": user.roles,
        "permissions": user.permissions,
    }))
}

let app: Router = Router::new().route("/profile", get(get_profile));

Ce que fait AuthUser en coulisse (voir extractors.rs) :

  1. Cherche Authorization: Bearer <token> dans les headers — priorité absolue.
  2. Si absent, cherche le cookie jwt_token (HttpOnly, posé au login).
  3. Lit Arc<AuthConfig> depuis les extensions Axum (chargé une fois au démarrage).
  4. Appelle JwtService::validate_token — vérification de signature et d'expiration.
  5. Consulte JwtBlacklistService::is_blacklisted — vérification de révocation.
  6. Construit AuthUser { user_id, username, roles, permissions } depuis les Claims.

Si l'une de ces étapes échoue, Axum retourne 401 Unauthorized automatiquement — le handler n'est jamais appelé.

Vérification de rôle ou de permission dans un handler :

async fn admin_only(user: AuthUser) -> Result<Json<serde_json::Value>, StatusCode> {
    if !user.has_role("admin") {
        return Err(StatusCode::FORBIDDEN);
    }
    // Le rôle "admin" implique toutes les permissions (has_permission retourne true)
    Ok(Json(serde_json::json!({ "ok": true })))
}

async fn check_permission(user: AuthUser) -> Result<Json<serde_json::Value>, StatusCode> {
    if !user.has_permission("users.list") {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(Json(serde_json::json!({ "ok": true })))
}

Diagramme d'état du JWT

stateDiagram-v2
    [*] --> Generated : JwtService::generate_token()

    Generated --> Active : émis au client\n(login ou refresh)

    Active --> Expired : exp < now\n(automatique)
    Active --> Blacklisted : logout explicite\nJwtBlacklistService::add_to_blacklist()
    Active --> Revoked : révocation globale\n(réutilisation refresh token détectée)

    Blacklisted --> [*] : nettoyage périodique\n(jwt_blacklist_cleanup task)
    Expired --> [*]
    Revoked --> [*]

    note right of Active
        Durée configurable via
        JWT_EXPIRATION_MINUTES
        (défaut: 15 min)
    end note

    note right of Blacklisted
        Hash SHA-256 stocké en DB
        jusqu'à l'expiration naturelle
    end note

Un token Blacklisted reste dans la table jwt_blacklist jusqu'à sa date d'expiration naturelle. La tâche de fond jwt_blacklist_cleanup purge les entrées expirées pour limiter la croissance de la table.


Bearer vs Cookie — tableau comparatif

CritèreAuthorization: BearerCookie jwt_token
Client cibleAPI mobile, SPA avec storage, services inter-serveursNavigateur web (formulaires HTML, HTMX)
Stockage côté clientlocalStorage / sessionStorage / keychainCookie HttpOnly (inaccessible à JS)
Vulnérabilité XSSExposé si stocké dans localStorageProtégé par HttpOnly
Vulnérabilité CSRFImmunisé (header custom)Nécessite le middleware CSRF
Priorité d'extractionPrioritaire — vérifié en premierFallback si header absent
Posé automatiquementNon — le client doit stocker et envoyerOui — Set-Cookie au login
Effacé au logoutNon — le client doit supprimerOui — Set-Cookie: jwt_token=; MaxAge=0

La priorité Bearer > Cookie est délibérée : un client API peut toujours surcharger le cookie en envoyant un header explicite, par exemple pour tester avec un token de test sans toucher aux cookies du navigateur.

Recommandation pratique :

  • SPA découplée ou application mobile : utiliser Authorization: Bearer exclusivement, stocker le token en mémoire (pas localStorage) ou dans un stockage sécurisé natif.
  • Interface HTML rendue côté serveur (HTMX, formulaires) : laisser le framework gérer le cookie — rien à faire côté client.
  • Services inter-serveurs : Authorization: Bearer avec un Personal Access Token (/api/v1/auth/tokens).

Flux du jwt_extractor

Le module jwt_extractor centralise la logique d'extraction du token brut depuis la requête. Il est utilisé par l'extracteur AuthUser, le middleware auth_required, et le handler logout — tous trois appliquent la même règle : header avant cookie.

jwt_extractor::extract_jwt_from_request(request)
  ├── Authorization: Bearer <token>  →  extract_bearer_token(header)
  └── Cookie: jwt_token=<token>      →  cookie_jar.get("jwt_token")

Si ni l'un ni l'autre n'est présent, la fonction retourne une erreur, et le middleware décide de la réponse (401 pour l'extracteur API, redirect /login pour le middleware HTML).