0040-authenticate-user-email-verified.md 16771 octets

RFC 0040 — AuthService::authenticate_user honore validation_email_required

  • Auteur : EBO (origine audit Gitrust / patch 1.0.1)
  • Date : 2026-09-18
  • Statut : Implemented (2026-10-01 ; Accepted 2026-09-18)
  • Apps consommatrices concernées : Gitrust (SSR / appels directs au service) ; rustwarden-ui (déjà via POST /api/v1/auth/login) ; tout consommateur de rustwarden-core qui appelle AuthService::authenticate_user
  • Crate cible : rustwarden-core
  • Origine : plan docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md (Task 17). Contrat limiteur : finding E-2 / layer auth_ip_rate_limit 1.0.1.

Summary

Déplacer le contrôle « e-mail vérifié » de auth/handlers.rs login vers AuthService::authenticate_user, avec le même AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true), pour que Gitrust et tout SSR qui appelle le service ne puissent plus le contourner.

Motivation

Problème concret

Le login JSON fait :

  1. AuthService::authenticate_user (~L109–121) : verify_credentials + log must_change_password (sans regarder email_verified).
  2. login (~L313–340) : lit le setting puis bloque.
    let email_validation_required =
        crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
            &db,
            &["validation_email_required", "validation_email_requise"],
            true,
        )
        .await
        .unwrap_or(true);

    if email_validation_required && !user.email_verified {
        let reason = "email_not_verified";
        // ...
        AuthLoggingService::log_blocked_login(...).await?;
        return Err(AppError::Validation(
            "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
        ));
    }

Call sites de authenticate_user dans ce dépôt : uniquement handlers::login. Gitrust (et tout handler SSR custom) qui appelle le service saute les lignes 313–340 : mot de passe OK + email_verified=false → session/JWT quand même.

verify_credentials (~L50) reste le primitive « mot de passe seulement » — utilisé par totp_handler (~L336) pour re-prompt password au setup 2FA. Ne pas y coller le check e-mail.

Bootstrap admin

ensure_default_admin (~L86) pose déjà email_verified = Set(true) après create_user. Ne pas retravailler ce seed. L’admin par défaut passe le nouveau check. Les users register restent email_verified=false (create_user ~L50).

Qui est impacté

  • Gitrust / apps qui login via le service au lieu de /api/v1/auth/login.
  • rustwarden-ui : login.html fait déjà fetch('/api/v1/auth/login') — comportement identique après déplacement (le handler délègue).
  • Utilisateurs non vérifiés : toujours bloqués si le setting est vrai (défaut true).

Si on ne fait rien

Politique validation_email_required = théâtre HTTP. Un consommateur « bien intentionné » qui factorise le login sur authenticate_user ouvre un bypass. RFC 0038 (un-verify au change d’e-mail) serait inefficace sur ces chemins.

Urgence

Pas un patch 1.0.1. Durcissement de la porte d’auth unique. Breaking comportemental voulu pour les callers service.

Non-goals

  • Pas de nouveau middleware browser obligatoire (E-2). Le limiteur login est le layer auth_ip_rate_limit (~L223) monté sur auth_api_routes (/api/v1/auth/login, register, refresh, forgot-password, 2FA verify, resend-verification). Helper public inchangé : check_auth_rate_limit(&HeaderMap).
  • SSR POST /login : soit appeler check_auth_rate_limit (ou le layer), soit passer par POST /api/v1/auth/login (comme rustwarden-ui). Cette RFC n’ajoute pas de layer CSRF/rate-limit sur les routes HTML.
  • Pas de rework default_admin_service / seed email_verified=true.
  • Pas de check dans verify_credentials (setup TOTP, re-auth password).
  • Pas de révocation des JWT déjà émis pour un compte non vérifié.
  • Pas de changement du message d’erreur actuel (français, AppError::Validation) au premier PR — l’UI parse err.error / err.message.
  • Pas de newtype / nouvel AppError::EmailNotVerified dans cette RFC (open question).
  • Pas d’OAuth : le signup OAuth a son propre email_verified provider.

API proposée

Signature inchangée :

// crates/rustwarden-core/src/services/auth_service.rs
impl AuthService {
    /// Authenticate user (password + optional email-verified policy).
    ///
    /// 1. [`Self::verify_credentials`]
    /// 2. Log `must_change_password` (does not block)
    /// 3. If `validation_email_required` / `validation_email_requise`
    ///    (default `true`) and `!user.email_verified` → [`AppError::Validation`]
    pub async fn authenticate_user(
        db: &DatabaseConnection,
        identifier: &str,
        password: &str,
    ) -> Result<user::Model, AppError> { /* ... */ }
}

Corps après Accept (proposition ; Draft, ne compile pas tel quel tant que les use ne sont pas ajoutés) :

pub async fn authenticate_user(
    db: &DatabaseConnection,
    identifier: &str,
    password: &str,
) -> Result<user::Model, AppError> {
    let user = Self::verify_credentials(db, identifier, password).await?;

    if user.must_change_password {
        tracing::info!("User {} should change password (flag set)", user.username);
    }

    let email_validation_required =
        crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
            db,
            &["validation_email_required", "validation_email_requise"],
            true,
        )
        .await
        .unwrap_or(true);

    if email_validation_required && !user.email_verified {
        tracing::warn!(
            "Login attempt blocked (unverified email): {} ({})",
            user.username,
            user.email
        );
        let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login(
            db,
            &user.email,
            "email_not_verified",
            None,
        )
        .await;
        return Err(AppError::Validation(
            "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
        ));
    }

    Ok(user)
}

Notes d’implémentation :

  • unwrap_or(true) : lookup setting en erreur → fail-closed (exiger la vérif), comme le handler.
  • log_blocked_login(..., hooks: None) : l’audit_logs row est écrite ; on_audit_event du handler HTTP ne part plus de login sauf si on ajoute un paramètre hooks (open question). Gitrust qui appelait le service n’avait aucun check : un audit_log sans hook reste un plus.
  • Handler login : supprimer le bloc 313–340 (évite double lecture setting + double log). Enchaîner TOTP / tokens comme aujourd’hui.
  • Coût : la même requête app_settings qu’aujourd’hui, déplacée.

Usage consommateur (Gitrust / SSR)

use rustwarden_core::error::AppError;
use rustwarden_core::middleware::rate_limit::check_auth_rate_limit;
use rustwarden_core::services::auth_service::AuthService;
use axum::http::HeaderMap;
use sea_orm::DatabaseConnection;

async fn ssr_login(
    db: &DatabaseConnection,
    headers: &HeaderMap,
    identifier: &str,
    password: &str,
) -> Result<(), AppError> {
    // E-2: this RFC does not add a browser middleware.
    if let Err(resp) = check_auth_rate_limit(headers) {
        let _ = resp; // map to AppError::TooManyRequests at impl time
        return Err(AppError::TooManyRequests {
            retry_after_secs: 60,
        });
    }
    let _user = AuthService::authenticate_user(db, identifier, password).await?;
    Ok(())
}

Chemin recommandé : ne pas dupliquer, fetch/POST /api/v1/auth/login (layer auth_ip_rate_limit déjà là, ConnectInfo via RustwardenApp::run).

check_auth_rate_limit sans ConnectInfo clé auth:unknown si TRUSTED_PROXY_HOPS=0 — c’est le contrat 1.0.1 ; les apps SSR doivent préférer le layer ou into_make_service_with_connect_info.

Analyse de compatibilité

Checklist :

  • API existante non modifiée → signature authenticate_user inchangée (compile).
  • API existante étendue avec défaut → N/A.
  • API existante modifiée ou supprimée → breaking comportemental pour tout caller service qui login un user email_verified=false alors que le setting est true.
  • Nouveau comportement par défaut du service (le HTTP l’avait déjà). Pas de feature-flag au-delà du setting existant.
  • Changement de schéma DB → non.
  • Changement de format sérialisé → le JSON login inchangé (même AppError::Validation).

SemVer : minor Security documenté, ou major processus §1. Open question. Gitrust doit s’attendre au blocage (c’est le bugfix).

HTTP login : non-breaking fonctionnel (même message, même setting).

Alternatives considérées

Minimum 3 alternatives avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif.

Alternative A (recommandée) : déplacer le check dans authenticate_user

Pros : une porte, impossible à contourner via le service. Même setting / même message / même fail-closed. Admin seed déjà email_verified=true. Handler allégé. RFC 0038 devient effective partout.

Cons : breaking pour Gitrust SSR. on_audit_event du handler login ne voit plus ce blocage sauf hooks passé au service (None au v1). Un SELECT settings sur tous les callers service (voulus).

Alternative B : documenter « les consommateurs doivent recopier le check »

Pros : zéro diff core service ; HTTP inchangé.

Cons : exactement le gap actuel. Deux implémentations (HTTP vs Gitrust) dérivent. validation_email_requise oublié d’un côté. Contredit « lowest common denominator » du processus.

Alternative C (ne rien faire)

Pros : zéro coût, zéro risque de casser un login Gitrust non vérifié (s’il en existe en prod).

Cons : bypass service à vie ; 0038 inefficace hors HTTP ; dette audit Task 17.

Testing plan

Tests unitaires / service (DB de test) :

  • Happy path : user email_verified=true, setting true → authenticate_user Ok(user) (credentials valides).
  • Happy path setting off : validation_email_required=false, user non vérifié → Ok(user).
  • Erreur 1 — input invalide : mauvais mot de passe → AppError::InvalidCredentials avant le check e-mail (pas d’oracle « compte existe mais non vérifié » vs « mauvais mot de passe » au-delà de ce que verify_credentials fait déjà).
  • Erreur 2 — contrainte politique : setting true (ou absent, défaut true), email_verified=false, bon mot de passe → AppError::Validation avec le message français actuel ; une row audit_logs login_blocked / email_not_verified.
  • Erreur 3 — lookup setting DbErr : unwrap_or(true) → user non vérifié bloqué (fail-closed), pas de panic ; user vérifié toujours Ok.
  • Admin bootstrap : ensure_default_admin puis authenticate_user(ADMIN_USERNAME, ADMIN_PASSWORD) → Ok (email déjà true). Ne pas modifier le seed ; c’est un test de non-régression.

Tests d’intégration (handler HTTP) :

  • POST /api/v1/auth/login user non vérifié → même 4xx / body qu’aujourd’hui (après suppression du bloc handler).
  • POST /api/v1/auth/login reste derrière auth_ip_rate_limit (tests existants auth/routes.rs).
  • verify_credentials seul (chemin TOTP UI) : user non vérifié + bon password → toujours Ok (pas de régression setup 2FA).

Tests de non-régression :

  • Consommateurs compilent.
  • must_change_password toujours logged, jamais bloquant dans authenticate_user.
  • rustwarden-ui login page : pas de nouveau middleware ; continue d’appeler l’API.

Doctests :

  • Rustdoc anglais sur authenticate_user : politique e-mail + pointeur verify_credentials. Exemple I/O en ignore. Contrat lisible sans DB.

Benchs : un get_bool_with_fallback déjà payé par HTTP. Pas de nouveau bcrypt. Pas de seuil 10 %.

Les snippets ssr_login sont documentaires ; TooManyRequests mapping est indicatif.

Migration guide

Section à copier telle quelle dans le CHANGELOG.md au moment du merge.

### API changes

**Before (v1.0.1)** :
Le contrôle `email_verified` vivait dans `auth::handlers::login`
(`get_bool_with_fallback(["validation_email_required",
"validation_email_requise"], true)`). `AuthService::authenticate_user`
ne vérifiait que le mot de passe. Un SSR / Gitrust qui appelait le
service contournait la politique.

**After (vX.Y.Z)** :
Le même contrôle (mêmes clés, défaut `true`, fail-closed, même
`AppError::Validation` français) est dans `authenticate_user`.
Le handler `login` ne le duplique plus. `verify_credentials` inchangé
(re-auth TOTP). `ensure_default_admin` pose déjà `email_verified=true`
— pas de changement de seed.

Breaking pour les callers de `authenticate_user` : un compte non
vérifié est désormais refusé si le setting est actif.

E-2 : pas de nouveau middleware browser. Limiteur =
`auth_ip_rate_limit` sur `/api/v1/auth/*`. Un `POST /login` SSR doit
appeler `check_auth_rate_limit` **ou** passer par l'API.

```rust
let user = AuthService::authenticate_user(db, identifier, password).await?;

Migration steps

  1. Bump rustwarden-core à la version qui livre RFC 0040.
  2. Gitrust : grep authenticate_user — s'attendre au Validation e-mail non vérifié ; ne pas réimplémenter le check à côté.
  3. Routes SSR POST /login : check_auth_rate_limit ou proxy API. Pas d'obligation de nouveau layer.
  4. Vérifier que l'admin par défaut se connecte toujours (seed email_verified=true).
  5. Aucune migration SQL.

## Open questions

Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :

- [x] **`hooks` sur `authenticate_user`** : rester `log_blocked_login(..., None)` (recommandé v1, signature stable) vs `Option<&Arc<dyn RustwardenHooks>>` (breaking ou surcharge) ? **Résolu (2026-09-18) : v1 : `log_blocked_login(..., None)`, pas de paramètre `hooks` (signature stable).**
- [x] **`AppError::EmailNotVerified`** : garder `Validation` (compat UI) vs variante dédiée (meilleur i18n, breaking JSON) ? **Résolu (2026-09-18) : garder `AppError::Validation` avec le message français actuel (compat UI qui parse `err.error`/`err.message`).**
- [x] **SemVer** : minor Security vs major (comportement service) ? **Résolu (2026-09-18) : 1.1.0 minor Security.**
- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.**

## Tracking issue

À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 17, E-2 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.

---

## Score d'évaluation (rempli par les reviewers)

Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.

| Critère | Note (1-5) | Commentaire |
|---|---|---|
| Généralité | 5 | Tous les callers `authenticate_user` ; Gitrust n’a plus de bypass. |
| Stabilité API | 4 | Signature identique ; breaking comportemental service (voulu, HTTP inchangé). |
| Performance | 5 | Même `get_bool_with_fallback` qu’avant, juste déplacé ; pas de bcrypt extra. |
| Sécurité | 5 | Ferme le trou SSR/service ; fail-closed ; `verify_credentials` reste utilisable. |
| Complexité d'implémentation | 5 | Déplacer ~25 lignes + tests ; seed admin intact. |
| Charge de maintenance | 5 | Une politique, un endroit ; plus de drift handler vs service. |
| Documentation | 4 | RFC + CHANGELOG + rustdoc + contrat E-2 rate-limit. |
| **Total** | **33/35** | (≥ 25 pour Accepted) |