07-security-model.md 40115 octets

07 — Modèle de sécurité

Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden : comment les tokens sont forgés et révoqués, comment le CSRF est bloqué, comment les mots de passe et les secrets OAuth sont protégés, et où se trouvent les limites connues. L'objectif est de donner à un auditeur ou à un intégrateur une vue complète et honnête — y compris des points d'attention explicites.


Vue d'ensemble des contrôles

Vecteur d'attaqueContrôle mitigantFichier
Usurpation d'identité (token forgé)Signature HMAC-SHA256, validation issuer + audienceauth/jwt.rs
Replay de JWT révoquéBlacklist jwt_blacklist consultée à chaque requêteservices/jwt_blacklist_service.rs
Vol de password hashbcrypt coût 12 (min), résistant aux GPUservices/password_service.rs
Exposition de secret OAuth/TOTP en DBChiffrement AES-256-GCM avant insertionservices/crypto_service.rs
CSRF (Cross-Site Request Forgery)Token de session 256 bits, comparaison constant-timemiddleware/csrf.rs
Brute-force login / reset passwordRate limiter in-process, fenêtre glissanteservices/rate_limit_service.rs
Timing oracle sur comparaisonssubtle::ConstantTimeEq partoutmiddleware/csrf.rs, services/personal_token_service.rs
XSS via injection de contenuCSP appliquée sur toutes les réponsesapp.rs
Clickjackingframe-ancestors 'none' dans CSP + X-Frame-Optionsapp.rs
Open redirectValidation du paramètre redirect (doit commencer par /)middleware auth_required
Fuite de session via TOTP brute-forceMax 5 tentatives par challenge, 5 min TTLservices/totp_service.rs
Rejeu d'un code TOTP valide (multi-réplicas)user_totp.last_used_step consommé par compare-and-set SQL (RFC 0037)services/totp_service.rs
Injection via username (chemins d'URL, logs, templates)Newtype Username sur les frontières d'écriture (RFC 0036)types/username.rs
Prise de contrôle par changement d'e-mailFlux en deux temps : mot de passe courant exigé, adresse écrite seulement après clic sur le lien envoyé à la nouvelle boîte, alerte + lien de signalement à l'ancienne, sessions révoquées à la confirmation (RFC 0041)services/email_change_service.rs
Confirmation déclenchée par le préchargement d'un lien (passerelle anti-phishing)Page interstitielle : aucun GET n'a d'effet de bord, l'action passe par le POST d'un formulaire (RFC 0041)auth/email_change_pages.rs
Contournement de la politique « e-mail vérifié » par appel direct au serviceContrôle déplacé dans AuthService::authenticate_user (RFC 0040)services/auth_service.rs
Élévation de privilèges via lien OAuthVérification email vérifié par le providerservices/oauth/service.rs
Déni de service mémoire sur rate limiterCap à 100 000 entrées distinctesservices/rate_limit_service.rs

Cryptographie utilisée

JWT — HS256

Les tokens JWT sont signés avec l'algorithme HS256 (HMAC-SHA256) via la crate jsonwebtoken 10. La clé est lue depuis la variable d'environnement JWT_SECRET. AuthConfig rejette au démarrage les valeurs trop courtes ou les placeholders connus (changeme, secret, etc.).

Chaque token porte un jti (JWT ID) unique (UUID v4) qui permet la révocation individuelle sans invalider les autres tokens de l'utilisateur.

La validation exige :

  • la signature HMAC correcte,
  • exp non dépassé,
  • iss et aud correspondant à AuthConfig::jwt_issuer.

bcrypt pour les mots de passe

Les mots de passe sont hachés avec bcrypt. Le coût par défaut est 12, configurable via la variable BCRYPT_COST (clampé entre 12 et 20 pour éviter des valeurs non sécurisées ou bloquantes). Le hachage est réalisé sur un thread bloquant dédié (tokio::task::spawn_blocking) pour ne pas bloquer le runtime async.

coût effectif = BCRYPT_COST.clamp(12, 20) (défaut : 12)
durée typique à coût 12 : ~100–300 ms

AES-256-GCM pour les secrets en base

Les secrets sensibles stockés en base (secrets TOTP, codes de backup TOTP) sont chiffrés avec AES-256-GCM via la crate aes-gcm. La clé est lue depuis ENCRYPTION_KEY (ou OAUTH_ENCRYPTION_KEY — alias legacy), encodée en hexadécimal (64 caractères = 32 octets).

Format de stockage : enc:<base64(nonce || ciphertext)>. Le préfixe enc: permet de distinguer les valeurs chiffrées des valeurs legacy en clair (compatibilité migration).

Le nonce (12 octets) est généré aléatoirement par OsRng pour chaque chiffrement. Un nonce différent par opération garantit la sécurité sémantique.

Référence : services/crypto_service.rs

SHA-256 pour les personal access tokens

Le plaintext d'un PAT (32 octets aléatoires encodés hex, soit 64 caractères) est haché avec SHA-256 avant stockage. Seul le digest hex est persisté. Le plaintext est retourné une seule fois lors de la création et n'est jamais journalisé.

Comparaisons constant-time

Partout où une comparaison de token peut exposer un timing oracle, Rustwarden utilise subtle::ConstantTimeEq :

  • Validation du token CSRF (header vs session)
  • Re-vérification du hash PAT après lookup SQL
  • Validation du token de réinitialisation de mot de passe

JWT — anatomie

flowchart LR
    subgraph Header
        ALG["alg: HS256"]
        TYP["typ: JWT"]
    end
    subgraph Payload
        SUB["sub: UUID utilisateur"]
        JTI["jti: UUID unique du token"]
        ISS["iss: jwt_issuer"]
        AUD["aud: jwt_issuer"]
        EXP["exp: timestamp expiration"]
        IAT["iat: timestamp émission"]
        USERNAME["username: string"]
        ROLES["roles: string[]"]
        PERMS["permissions: string[]"]
        KIND["kind: local | remote"]
        RID["remote_id: UUID? (RFC 0007)"]
    end
    subgraph Signature
        HMAC["HMAC-SHA256(header.payload, JWT_SECRET)"]
    end

    Header --> Payload --> Signature

Les claims kind et remote_id ont été introduits par RFC 0007 pour permettre aux middlewares de distinguer un compte local d'une identité fédérée (remote_identities.id) sans requête en base. Les tokens antérieurs à RFC 0007 désérialisent kind comme local par défaut.

Important : les claims ne sont pas chiffrés, uniquement signés. Ne jamais y placer de données sensibles (mot de passe, secret).


JWT — états et transitions

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

    Generated --> Active : Émis au client

    Active --> Expired : exp atteint
    Active --> Blacklisted : logout / révocation admin\nJwtBlacklistService::blacklist()
    Active --> Revoked : refresh token réutilisé\n(détection de vol)

    Expired --> [*] : rejeté par validate_token() (ExpiredSignature)
    Blacklisted --> [*] : rejeté par JwtBlacklistService::is_blacklisted()
    Revoked --> [*] : rejeté (blacklisté + tous les tokens de la chaîne révoqués)

La blacklist est nettoyée en arrière-plan : les entrées dont expires_at < NOW() sont supprimées car elles ne peuvent plus être présentées avec une signature valide.


CSRF

Le middleware CSRF protège toutes les routes non-GET/HEAD/OPTIONS contre les requêtes forgées depuis un autre domaine.

sequenceDiagram
    participant B as Navigateur
    participant S as Rustwarden

    B->>S: GET /form
    S->>S: get_or_create_csrf_token(session)
    Note right of S: Génère 32 octets aléatoires\nencodés hex (64 chars)
    S->>B: 200 OK<br/>cookie: session=...<br/>HTML: &lt;input name="csrf_token" value="..."&gt;<br/>ou header X-CSRF-Token injecté par JS

    B->>S: POST /submit<br/>X-CSRF-Token: &lt;token&gt; (ou form field)
    S->>S: Extraire token depuis header ou form body
    S->>S: csrf_tokens_match(incoming, session_token)<br/>subtle::ConstantTimeEq
    alt Token valide
        S->>B: 200 OK
    else Token absent ou incorrect
        S->>B: 403 Forbidden
    end

Bypass pour les API SPA : si la requête porte un header Authorization: Bearer ..., le middleware CSRF est court-circuité. Un client API qui transporte son JWT manuellement n'est pas vulnérable au CSRF car le navigateur ne peut pas ajouter ce header automatiquement depuis un autre domaine.

Deux modes :

  • csrf_protection (défaut) : token uniquement dans le header X-CSRF-Token.
  • csrf_protection_with_config avec accept_form_body = true : accepte aussi le champ csrf_token dans un corps application/x-www-form-urlencoded, utile pour les formulaires SSR sans JavaScript. Les corps multipart ne sont jamais bufférisés (risque d'uploads volumineux).

Référence : middleware/csrf.rs


Rate limiting

flowchart TD
    REQ[Requête entrante] --> EXTRACT[Extraire client_id\nIP ou user_id]
    EXTRACT --> LOOKUP[Lookup dans HashMap en mémoire\nMutex&lt;HashMap&lt;String, WindowCounter&gt;&gt;]
    LOOKUP --> WINDOW{Fenêtre expirée ?}
    WINDOW -->|Oui| RESET[Réinitialiser compteur\nwindow_start = now]
    WINDOW -->|Non| CHECK
    RESET --> CHECK[count += 1\ncount > max ?]
    CHECK -->|Non| OK[Passer la requête]
    CHECK -->|Oui| REJECT[429 Too Many Requests\nRetry-After: N secondes]
    LOOKUP --> CAPCHECK{Taille map >= 100 000 ?}
    CAPCHECK -->|Oui et clé inconnue| REJECT

Deux modes disponibles :

  • Fenêtre fixe (check / check_window) : compteur remis à zéro à chaque fin de fenêtre. Simple et peu coûteux.
  • Fenêtre glissante (check_sliding_window) : timestamp de chaque tentative stocké, rejet si plus de N tentatives dans les derniers W secondes. Plus strict, utilisé pour les flows sensibles (reset de mot de passe, envoi de code TOTP).

Limitation connue : le rate limiter est in-process uniquement. Dans un déploiement multi-réplicas, chaque instance maintient son propre compteur. Un attaquant qui répartit ses requêtes entre N réplicas peut multiplier son quota par N. La solution prévue en V2 est un backend partagé (Redis). Cette limitation est documentée dans le code source lui-même.

Référence : services/rate_limit_service.rs


Complexité des mots de passe

La politique de mot de passe est appliquée lors de la création et du changement de mot de passe via le service de validation. Le middleware password_complexity vérifie :

  • Longueur minimale configurable (défaut : 8 caractères)
  • Au moins une majuscule, une minuscule, un chiffre (selon configuration)
  • Pas de correspondance avec les placeholders connus

Les administrateurs peuvent forcer un changement de mot de passe via le flag must_change_password sur la table users. Le middleware auth_required détecte ce flag et redirige vers le formulaire de changement avant de laisser passer toute autre requête.


Politique de nommage des comptes (Username)

Depuis la RFC 0036, un username n'est plus une String libre bornée en longueur : c'est un newtype validé, rustwarden_core::types::Username, re-exporté par le prelude. Il est calqué sur TeamSlug et suit les règles de GitHub.

Règles appliquées par Username::parse

RègleDétail
Longueur3 à 39 caractères, mesurés après trim()
Charset[a-z0-9-] uniquement (minuscules ASCII, chiffres, tiret)
MajusculesRejetées, jamais repliées : Alice renvoie une erreur, il ne devient pas alice
TiretsNi en tête ni en queue, pas de -- consécutifs
Noms réservéstypes::team_slug::RESERVED + EXTRA_RESERVED (me, explore)

Le rejet des majuscules plutôt qu'un to_lowercase() silencieux est un choix de sécurité : un fold implicite permettrait à Alice et alice d'entrer en collision sur la même ligne, ou de créer un compte visuellement différent d'un compte existant. Toute violation renvoie AppError::Validation → HTTP 400.

Où la règle est appliquée

Une seule porte de validation — UserService::create_user — plus un contrôle de frontière publique sur POST /api/v1/auth/register (défense en profondeur, RegisterRequest.username est borné à 39 côté validator).

flowchart TD
    REG["POST /api/v1/auth/register"] --> PARSE1["Username::parse()"]
    ADMIN["Handler admin / provisioning"] --> CREATE
    PARSE1 --> CREATE["UserService::create_user()"]
    CREATE --> PARSE2["Username::parse() — porte unique"]
    PARSE2 --> INSERT[("INSERT users")]

    BOOT["ensure_default_admin()"] --> ALLOW["create_user_allow_reserved()\nUsername::parse_allow_reserved()"]
    ALLOW --> INSERT

    PROFILE["update_profile(username = Some(x))"] --> SAME{"x == username courant ?"}
    SAME -->|Oui| NOOP["No-op — grandfathering préservé"]
    SAME -->|Non| PARSE3["Username::parse() + contrôle d'unicité"]
    PARSE3 --> UPDATE[("UPDATE users")]

create_user_allow_reserved est pub(crate) et réservé à ensure_default_admin : il accepte les noms réservés (pour que ADMIN_USERNAME=admin continue de booter) mais applique toujours le charset et la longueur. AdminConfig::validate délègue au même parse permissif — un ADMIN_USERNAME avec une majuscule ou un _ fait échouer le boot sur le chemin de création de l'admin.

Grandfathering

Aucun CHECK SQL n'a été ajouté, aucune migration ne réécrit les lignes existantes. Conséquences :

  • un compte historique hors charset (John_Doe, alice.bob) se connecte toujours — les chemins de lecture n'appellent jamais parse ;
  • update_profile ne valide que sur un vrai rename. Si le client renvoie le username courant (les formulaires de profil postent tous les champs), c'est un no-op complet ;
  • ce même compte ne peut pas être renommé vers une autre valeur hors charset.

Utiliser Username dans votre propre code

Utile dès que vous générez ou acceptez un identifiant avant de le confier au framework (import LDAP, provisioning SSO, seed, CLI d'administration) :

use rustwarden_core::prelude::*;   // Username, UserService, AppError

async fn provision(
    db: &sea_orm::DatabaseConnection,
    raw_login: &str,
    email: String,
    password_hash: String,
) -> Result<(), AppError> {
    // Échoue tôt, avec un message exploitable, avant tout accès base.
    let username = Username::parse(raw_login)?;

    UserService::create_user(db, username.into_inner(), email, password_hash, true).await?;
    Ok(())
}

Pour n'afficher qu'un message de validation côté formulaire :

if let Err(e) = Username::parse(&form.username) {
    return Ok(render_form_with_error(&e.to_string()));
}

Les candidats générés automatiquement par OAuth passent par OAuthService::generate_unique_username, qui produit déjà des valeurs conformes : minuscules [a-z0-9], base tronquée à 30 caractères (marge pour les suffixes -NNN et -<hex8>), suffixe -user si la base tombe sur un nom réservé.


TOTP / 2FA

sequenceDiagram
    participant U as Utilisateur
    participant API as Rustwarden
    participant DB as Base de données

    Note over U,DB: Enrôlement TOTP

    U->>API: POST /api/v1/totp/setup
    API->>API: Secret::generate_secret() (totp-rs)
    API->>API: CryptoService::encrypt(secret_base32)
    API->>DB: INSERT user_totp (enabled=false, secret_encrypted=...)
    API->>U: otpauth:// URI + QR code base64

    U->>API: POST /api/v1/totp/verify-and-enable\n{ code: "123456" }
    API->>DB: SELECT user_totp WHERE user_id = ...
    API->>API: CryptoService::decrypt(secret_encrypted)
    API->>API: TOTP::check_current(code)
    API->>API: Générer 10 codes de backup (bcrypt hashés, AES-GCM chiffrés)
    API->>DB: UPDATE user_totp SET enabled=true, backup_codes_encrypted=...
    API->>U: 200 OK + codes de backup (affichage unique)

    Note over U,DB: Login avec 2FA

    U->>API: POST /api/v1/auth/login { username, password }
    API->>API: Vérifier password (bcrypt)
    API->>DB: SELECT user_totp WHERE user_id AND enabled=true
    alt TOTP activé
        API->>DB: INSERT totp_challenges (token, expires_at=+5min, max_attempts=5)
        API->>U: 200 { totp_required: true, challenge_token: "..." }
        U->>API: POST /api/v1/auth/totp { challenge_token, code }
        API->>DB: SELECT totp_challenges WHERE challenge_token=...
        API->>API: Vérifier expiration + tentatives
        API->>API: TOTP::check_current(code)
        API->>DB: DELETE totp_challenges (consommer le challenge)
        API->>U: JWT + refresh token
    else TOTP non activé
        API->>U: JWT + refresh token
    end

L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — conformément à la spécification RFC 6238 et à la compatibilité avec tous les authenticators standard (Google Authenticator, Authy, etc.).

Référence : services/totp_service.rs

Plafonds de tentatives (1.0.1)

ContrôleValeurConstante
Tentatives par challenge5MAX_CHALLENGE_ATTEMPTS
Somme des tentatives par utilisateur / 15 min15MAX_ATTEMPTS_PER_WINDOW
TTL d'un challenge5 minCHALLENGE_EXPIRY_MINUTES
TTL de la garde anti-rejeu in-process90 sREPLAY_GUARD_TTL_SECS

Le plafond global est vérifié à la création du challenge (TotpService::create_challenge) et compte la somme des tentatives sur tous les challenges de la fenêtre, et non le nombre de challenges saturés : créer un nouveau challenge ne remet pas le budget à zéro. Un dépassement renvoie AppError::TotpMaxAttemptsExceeded.

Anti-rejeu persistant — last_used_step (RFC 0037)

Un code TOTP reste valide pendant toute sa fenêtre de 30 secondes, plus le skew ±1 accepté par totp-rs, soit ~90 secondes d'exploitabilité. En 1.0.1, la protection contre le rejeu était une HashMap in-process (OnceLock) : elle ne couvrait qu'un seul processus. Dans un déploiement multi-réplicas, un code intercepté et rejoué sur un autre réplica passait.

La RFC 0037 déplace l'autorité en base. La migration m20260918_000010_totp_last_used_step ajoute user_totp.last_used_step BIGINT NULL, et la consommation du pas se fait par compare-and-set :

sequenceDiagram
    participant R1 as Réplica 1
    participant R2 as Réplica 2
    participant DB as user_totp

    Note over R1,DB: Code "123456" valide pour le pas t
    R1->>R1: matching_totp_step(secret, code) → Some(t)
    R1->>DB: UPDATE ... SET last_used_step = t<br/>WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < t)
    DB-->>R1: rows_affected = 1 → Ok(true)

    Note over R2,DB: Rejeu du même code sur un autre réplica
    R2->>R2: matching_totp_step(secret, code) → Some(t)
    R2->>DB: même UPDATE conditionnel
    DB-->>R2: rows_affected = 0 → Ok(false)

Points importants :

  • le pas persisté est celui qui a réellement matché (matching_totp_step, qui rejoue generate() sur t-1, t, t+1 après un TOTP::check constant-time), jamais now / 30 — sinon un code accepté au skew -1 n'aurait pas été consommé ;
  • la condition last_used_step IS NULL OR last_used_step < $step est strictement croissante : le même pas ne peut pas être consommé deux fois ;
  • un rejeu renvoie Ok(false), exactement comme un code invalide. Pas d'oracle de rejeu : l'attaquant ne peut pas distinguer « déjà utilisé » de « faux » ;
  • verify_and_enable (le code d'activation du 2FA) consomme lui aussi le pas, avant de basculer enabled = true : le code de setup ne peut pas servir de login immédiat sur un autre réplica ;
  • une erreur base remonte en AppError — le garde ne fail jamais open ;
  • la garde in-process de 1.0.1 est conservée après le CAS, en défense en profondeur ;
  • last_used_step IS NULL signifie « aucun pas consommé depuis la migration » : le premier code présenté après migration est toujours accepté (grandfathering des lignes existantes).

Ce que l'anti-rejeu ne garantit PAS

  • Il ne protège pas contre un MITM temps réel. Un proxy de phishing type Evilginx intercepte le code et le rejoue lui-même avant l'utilisateur légitime : c'est la première consommation du pas, elle gagne le CAS. Le TOTP n'est pas phishing-resistant par construction ; seuls WebAuthn/FIDO2 le sont, et ils sont hors périmètre de Rustwarden aujourd'hui.
  • Il ne protège pas contre un secret TOTP compromis. Qui détient le secret génère des codes pour les pas suivants.
  • Il ne protège pas le vol de session post-authentification. Une fois le JWT émis, le TOTP n'intervient plus — voir blacklist JWT et rotation des refresh tokens.
  • Il ne couvre pas les codes de backup, qui suivent leur propre cycle (consommés par suppression du hash bcrypt de la liste chiffrée).

Cycle de vie de l'e-mail vérifié

Le flag users.email_verified (et son horodatage email_verified_at) conditionne l'entrée dans le compte — c'est-à-dire l'authentification, pas les sessions déjà ouvertes. Trois moments structurent son cycle de vie.

stateDiagram-v2
    [*] --> NonVerifie : register (email_verified = false)
    NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token
    Verifie --> Verifie : changement d'adresse confirmé<br/>EmailChangeService::confirm_change (RFC 0041)<br/>nouvelle adresse écrite DÉJÀ vérifiée
    NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents)

    note right of NonVerifie
        Prochain login refusé si
        validation_email_required = true
        (défaut, fail-closed).
        Session en cours NON révoquée.
    end note

    note right of Verifie
        RFC 0041 : un compte vérifié ne
        redevient jamais non vérifié.
        Tant que le lien n'est pas suivi,
        rien ne change en base.
        La confirmation révoque en revanche
        TOUS les refresh tokens.
    end note

1. Inscription

UserService::create_user insère toujours email_verified = false. L'exception est le bootstrap : ensure_default_admin force email_verified = true sur l'administrateur par défaut, sinon le premier compte ne pourrait pas se connecter.

2. Changement d'adresse (RFC 0041)

Depuis la RFC 0041, UserService::update_profile n'écrit plus jamais users.email : un vrai changement renvoie une AppError::Validation qui renvoie vers EmailChangeService, un echo de l'adresse courante reste un no-op. Le changement réel est un flux en deux temps, et c'est la clé du modèle de menace : tant que le lien envoyé à la nouvelle boîte n'est pas suivi, rien ne change en base.

ÉtapeComportement
1. DemandeEmailChangeService::request_change re-authentifie avec le mot de passe courant — une session volée seule ne suffit pas à ouvrir une prise de contrôle. Valide format et unicité, consomme toute demande pendante du compte, écrit une ligne pending_email_changes (token SHA-256, expires_at = email_validation_expiration_hours). users.email et email_verified ne bougent pas
1bis. MailsDeux envois : lien de confirmation vers la nouvelle adresse, avis + lien de signalement vers l'ancienne. Pas best-effort : si la mise en file échoue, request_change échoue et rien n'est écrit
2. ConfirmationLe POST de la page interstitielle écrit la nouvelle adresse déjà vérifiée (email_verified = true), révoque tous les refresh tokens du compte, invalide les demandes de changement de mot de passe pendantes, trace audit_logs email_changed
2bis. SignalementLe POST de la page de signalement marque la demande, trace en audit_logs (WARN) et alerte tous les administrateurs. Il ne bloque pas le changement
Limiteur3 demandes / heure / utilisateur (email_change:{user_id}, budget distinct) + budget IP sur POST /api/v1/auth/email-change
LogsAdresses masquées (mask_email → jo***@ex***) dans les logs, l'audit, les hooks et le corps de réponse — aucune adresse complète en clair dans le trail SIEM

Le chemin admin n'est plus un effet de bord : PUT /admin/users/:id ne change plus l'adresse. Le recours opérateur est EmailChangeService::admin_force_change, qui exige le rôle admin, révoque les sessions de la cible, notifie les deux boîtes et trace email_changed_by_admin (WARN) + on_audit_event. Le hook on_email_verified n'est pas émis : il n'y a eu aucune preuve de possession.

L'avis vers l'ancienne adresse reste le signal d'alarme du scénario « session volée, l'attaquant change l'e-mail pour verrouiller le compte » — et il porte désormais un lien d'action, pas seulement une information.

Pourquoi le signalement n'annule pas le changement

Le lien envoyé à l'ancienne adresse alerte, il ne décide pas. C'est délibéré : donner à cette boîte un pouvoir de veto reviendrait à donner ce veto à un attaquant qui la contrôle déjà, et à bloquer la reprise de contrôle légitime d'un utilisateur qui change d'adresse précisément parce qu'il a perdu l'accès à l'ancienne. Le signalement produit une ligne audit_logs WARN et un mail à tous les administrateurs : la décision de suspendre le compte est humaine et tracée.

Pourquoi un GET ne confirme jamais rien

Les deux liens envoyés par mail pointent sur un GET qui rend une page portant un formulaire ; c'est le POST du bouton qui agit. Les passerelles anti-phishing et les clients mail préchargent les liens : un GET qui agirait laisserait un scanner réécrire silencieusement l'identité de connexion d'un compte, ou réveiller tous les administrateurs pour rien. Ces pages sont du HTML minimal autoportant produit par le core — ni Askama, ni rustwarden-ui, ni JavaScript, ni ressource externe — donc montables en headless, et servies sur le canal machine (jamais derrière la couche CSRF, sans quoi elles seraient inutilisables depuis une boîte mail).

Ce que le flux en deux temps ferme

Mode de panneStatut
Faute de frappe = lock-outFermé. L'adresse candidate n'est jamais écrite : le lien part dans le vide, la demande expire, le compte n'a jamais bougé et reste vérifié sur son adresse d'origine
Fenêtre « adresse changée, session toujours valide »Fermée. La confirmation révoque tous les refresh tokens du compte
Couplage avec validation_email_requiredSupprimé. La nouvelle adresse est écrite déjà vérifiée : un changement d'adresse ne bloque jamais le login suivant
Changement d'adresse par effet de bord d'une mise à jour de profilFermé. Un seul chemin utilisateur, un seul chemin opérateur, tous deux audités

Risque résiduel : recours perdu et faute de frappe à l'inscription

Recours perduUn utilisateur qui a perdu à la fois son mot de passe et l'accès à son adresse courante ne peut plus se débloquer seul : request_change exige le mot de passe, et le reset de mot de passe passe par l'adresse stockée. C'est un recours administrateur (admin_force_change) après vérification d'identité hors bande.
Faute de frappe à l'inscriptionHors périmètre de la RFC 0041 : register écrit l'adresse saisie sans double vérification, et un compte jamais vérifié reste bloqué au login si validation_email_required = true. Même recours administrateur.
Recommandation consommateurAjouter une double saisie dans l'écran d'inscription (le seul où elle a encore une valeur), et documenter la procédure de vérification d'identité hors bande avant tout admin_force_change.

3. Où vit la politique de login (RFC 0040)

Le contrôle « login refusé si l'e-mail n'est pas vérifié » vit dans AuthService::authenticate_user, pas dans le handler HTTP. C'est le changement de la RFC 0040 : tant que le contrôle vivait dans POST /api/v1/auth/login, une application consommatrice avec son propre SSR pouvait appeler le service et contourner la politique sans s'en rendre compte.

FonctionContrôle e-mailUsage attendu
AuthService::authenticate_userOuiPorte d'authentification complète — à utiliser par défaut
AuthService::verify_credentialsNonPrimitive « mot de passe seul » : re-prompt avant setup TOTP, ré-authentification sensible

Détails du contrôle :

  • réglage validation_email_required, alias legacy validation_email_requise, lu via AppSettingsService::get_bool_with_fallback ;
  • défaut true et fail-closed : une erreur de lookup en base vaut « vérification requise » (unwrap_or(true)) ;
  • il s'exécute après la vérification du mot de passe : un attaquant ne peut pas utiliser le message d'erreur comme oracle d'existence de compte ;
  • le rejet est un AppError::Validation (HTTP 400) portant un message français destiné à l'utilisateur final.
use rustwarden_core::services::auth_service::AuthService;
use rustwarden_core::error::AppError;

// SSR maison : la politique s'applique, sans code supplémentaire.
match AuthService::authenticate_user(&db, &form.username, &form.password).await {
    Ok(user) => issue_session(user).await,
    // Message déjà rédigé pour l'utilisateur ; proposer « renvoyer l'email ».
    Err(AppError::Validation(msg)) => render_login_with_error(&msg, /* show_resend = */ true),
    Err(AppError::InvalidCredentials) => render_login_with_error("Identifiants invalides.", false),
    Err(e) => Err(e),
}

Limite documentée (décision v1) : sur ce chemin, AuthLoggingService::log_blocked_login est appelé avec hooks = None. La ligne audit_logs (action = "login_blocked") est toujours écrite, mais le hook RustwardenHooks::on_audit_event / auth.login_blocked n'est plus émis. Une alerte branchée sur ce hook doit basculer sur la table audit_logs.


En-têtes de sécurité (CSP, COOP, CORP)

Le middleware security_headers_middleware (app.rs) pose les en-têtes de sécurité sur toutes les réponses. Depuis la RFC 0039, une partie est configurable via SecurityHeadersConfig.

En-têteConfigurableValeur par défaut
Content-Security-PolicyOuivoir ci-dessous
Cross-Origin-Opener-PolicyOui, opt-innon émis (None)
Cross-Origin-Resource-PolicyOui, opt-innon émis (None)
X-Content-Type-OptionsNonnosniff
X-Frame-OptionsNonDENY
X-XSS-ProtectionNon0 (auditeur legacy désactivé — il introduisait ses propres failles)
Referrer-PolicyNonstrict-origin-when-cross-origin
Permissions-PolicyNoncamera=(), microphone=(), geolocation=()
Strict-Transport-SecurityNonmax-age=31536000; includeSubDomains, sauf si APP_DEBUG=true

CSP par défaut

default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self';
frame-ancestors 'none';
base-uri 'self';
form-action 'self'

Point d'attention, toujours d'actualité : la politique par défaut autorise 'unsafe-inline' et 'unsafe-eval'. 'unsafe-eval' est requis par la build standard d'Alpine.js (qui évalue ses expressions x-*), 'unsafe-inline' par HTMX et les scripts inline des templates. Ces directives affaiblissent significativement la protection XSS.

La RFC 0039 n'a pas durci ce défaut : elle n'ajoute qu'une API. Le défaut de 1.1.0 est bit-identique à celui de 1.0.1 — un test de non-dérive (default_csp_matches_the_historical_literal) le verrouille. La constante CSP_WITHOUT_UNSAFE_EVAL est exposée mais non utilisée par Default : la bascule est une Phase 2, conditionnée à la migration vers la build Alpine CSP (plans/PLAN_CSP_HTMX.md).

Autrement dit : un consommateur qui n'appelle jamais .security_headers(..) n'obtient aucune amélioration automatique de sa CSP — et aucune régression non plus.

Configurer les en-têtes

use rustwarden_core::prelude::*;   // RustwardenApp, SecurityHeadersConfig

let app = RustwardenApp::builder()
    .from_env()
    .security_headers(
        SecurityHeadersConfig {
            // App headless ou UI maison sans Alpine : on peut durcir.
            content_security_policy:
                "default-src 'self'; script-src 'self'; style-src 'self'; \
                 img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \
                 form-action 'self'"
                    .into(),
            ..SecurityHeadersConfig::default()
        }
        // Équivaut à COOP: same-origin + CORP: same-origin.
        .with_same_origin_isolation(),
    )
    .build()
    .await?;

Détails d'implémentation utiles à connaître :

  • la config est capturée dans un Arc à la construction du layer, pas lue depuis une Extension. Le layer d'en-têtes est plus externe que les Extension(...) : au moment où la requête le traverse, les extensions ne sont pas encore posées ;
  • une CSP syntaxiquement invalide (caractères de contrôle) ne panique pas : elle est remplacée par le défaut avec un warn! ;
  • une valeur COOP/CORP invalide fait sauter l'en-tête avec un warn!, sans casser la réponse ;
  • frame-ancestors 'none' + X-Frame-Options: DENY couvrent le clickjacking sur les navigateurs modernes comme legacy.

Avant d'activer with_same_origin_isolation(), vérifier qu'aucune intégration cross-origin (iframe, CDN d'assets, widget tiers) n'en dépend.


Open redirect

Le paramètre redirect du middleware auth_required est sanitisé avant utilisation. La protection appliquée :

  1. La valeur doit commencer par / (chemin absolu sur le même domaine).
  2. La valeur ne doit pas commencer par // (protocole relatif — cela pointerait vers un domaine externe).
  3. Les octets nuls sont rejetés.

Toute valeur qui ne passe pas ces contrôles est remplacée par le chemin par défaut (/). Cette protection empêche les redirections vers des domaines externes après login.


Session storage

Rustwarden utilise tower-sessions pour la gestion de session. Trois backends sont disponibles :

BackendFeature CargoÉtat
MemoryStore(défaut, aucune feature)Fonctionnel — perte des sessions au redémarrage
SeaOrmSessionStore(intégré dans le core)Fonctionnel — sessions persistantes en PostgreSQL
RedisSessionStoreredis-sessionCassé — ne compile pas, éviter --all-features

En production, le backend SeaORM est recommandé pour la persistance et la compatibilité multi-instance (à condition d'utiliser la session comme état de passage uniquement, pas comme cache d'autorisation).


Sécurité email

Le transport SMTP et la lecture IMAP (détection des bounces) exposent deux options de configuration potentiellement dangereuses :

  • accept_invalid_certs (SMTP) : accepte les certificats TLS auto-signés ou expirés.
  • accept_invalid_certs (IMAP) : même comportement pour la lecture des bounces.

Quand ces options sont activées, un warning est journalisé au démarrage :

WARN rustwarden_core: SMTP accept_invalid_certs=true — do not use in production

Ces options ne doivent jamais être activées en production. Elles existent uniquement pour faciliter le développement local avec des serveurs SMTP internes (ex. MailHog) qui n'ont pas de certificat valide.


Audit trail

Le service auth_logging enregistre les événements d'authentification et d'administration dans la table audit_logs.

sequenceDiagram
    participant H as Handler Axum
    participant AL as auth_logging service
    participant DB as audit_logs

    H->>H: Action métier (login, logout, changement de rôle...)
    H->>AL: log_action(db, user_id, action, target_user_id, level, message)
    AL->>DB: INSERT audit_logs (id, user_id, action, target_user_id, level, message, created_at)
    Note right of DB: ON DELETE SET NULL sur user_id et target_user_id\nLes logs persistent après suppression d'un utilisateur

Les colonnes actor_remote_identity_id et target_remote_identity_id (ajoutées par la migration fediverse) permettent d'enregistrer des actions impliquant des identités distantes tout en maintenant la même structure de table.


Modèle de menace STRIDE

Catégorie STRIDEExemples de menacesContrôles en place
Spoofing (usurpation)Token JWT forgé, réutilisation d'un token révoqué, phishing OAuthSignature HS256, blacklist JWT, validation state OAuth
Tampering (altération)Modification des claims JWT, corruption des secrets chiffrésSignature HMAC, AES-GCM avec tag d'intégrité
Repudiation (répudiation)Nier une action d'administration ou un loginTable audit_logs avec ON DELETE SET NULL
Information Disclosure (fuite d'info)Exposition du hash de mot de passe, secrets OAuth en DB, timing oraclesbcrypt, AES-GCM, subtle::ConstantTimeEq, erreurs opaques
Denial of Service (déni de service)Brute-force login, spam de reset password, remplissage de la blacklistRate limiter (max 100 000 entrées), cleanup tasks
Elevation of Privilege (élévation)Contournement RBAC, lien OAuth sans email vérifié, PAT avec scope trop largerequire_permission!, vérification email_verified, scopes explicites

Référence aux audits existants