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à viaPOST /api/v1/auth/login) ; tout consommateur derustwarden-corequi appelleAuthService::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 / layerauth_ip_rate_limit1.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 :
AuthService::authenticate_user(~L109–121) :verify_credentials+ logmust_change_password(sans regarderemail_verified).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.htmlfait 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é surauth_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 appelercheck_auth_rate_limit(ou le layer), soit passer parPOST /api/v1/auth/login(commerustwarden-ui). Cette RFC n’ajoute pas de layer CSRF/rate-limit sur les routes HTML. - Pas de rework
default_admin_service/ seedemail_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 parseerr.error/err.message. - Pas de newtype / nouvel
AppError::EmailNotVerifieddans cette RFC (open question). - Pas d’OAuth : le signup OAuth a son propre
email_verifiedprovider.
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_logsrow est écrite ;on_audit_eventdu handler HTTP ne part plus deloginsauf si on ajoute un paramètrehooks(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_settingsqu’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_userinchangé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=falsealors 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
logininchangé (mêmeAppError::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_userOk(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::InvalidCredentialsavant le check e-mail (pas d’oracle « compte existe mais non vérifié » vs « mauvais mot de passe » au-delà de ce queverify_credentialsfait déjà). - Erreur 2 — contrainte politique : setting true (ou absent, défaut true),
email_verified=false, bon mot de passe →AppError::Validationavec le message français actuel ; une rowaudit_logslogin_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é toujoursOk. - Admin bootstrap :
ensure_default_adminpuisauthenticate_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/loginuser non vérifié → même 4xx / body qu’aujourd’hui (après suppression du bloc handler).POST /api/v1/auth/loginreste derrièreauth_ip_rate_limit(tests existantsauth/routes.rs).verify_credentialsseul (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_passwordtoujours logged, jamais bloquant dansauthenticate_user.rustwarden-uilogin page : pas de nouveau middleware ; continue d’appeler l’API.
Doctests :
- Rustdoc anglais sur
authenticate_user: politique e-mail + pointeurverify_credentials. Exemple I/O enignore. 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
- Bump
rustwarden-coreà la version qui livre RFC 0040. - Gitrust : grep
authenticate_user— s'attendre auValidatione-mail non vérifié ; ne pas réimplémenter le check à côté. - Routes SSR
POST /login:check_auth_rate_limitou proxy API. Pas d'obligation de nouveau layer. - Vérifier que l'admin par défaut se connecte toujours (seed
email_verified=true). - 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) |
GitRust