change mail secure

EBO <eric.bouhana@softalys.com> committé le 2026-09-19 20:54

37ac492d0e94029a29f47374487be07d74396dc3

1 parent(s)

36 fichiers modifiés +7188 -449
M CHANGELOG.md
+119 -30
@@ -57,6 +57,40 @@ acceptées** (usernames hors charset, login sans e-mail vérifié via appel serv
57 57 `CSP_WITHOUT_UNSAFE_EVAL` est fournie mais **non utilisée** par `Default` : le
58 58 durcissement du défaut est une **Phase 2** ultérieure, après la migration Alpine CSP
59 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.
60 94
61 95 ### Changed
62 96
@@ -97,10 +131,16 @@ acceptées** (usernames hors charset, login sans e-mail vérifié via appel serv
97 131 passant par `parse_allow_reserved`. Auparavant une saisie comme `Admin`,
98 132 `admin_user` ou `jean.dupont` générait un projet dont le premier `build()`
99 133 échouait sur `username_format` sans message actionnable.
100 -- `rustwarden-ui` — `PUT /settings/profile` n'enqueue plus l'alerte
101 - `account_modified` pour un changement d'e-mail : le core s'en charge (RFC 0038).
102 - L'alerte « username modifié » n'est envoyée que sur un **vrai** rename
103 - (le formulaire de profil renvoyant tous les champs, un echo ne déclenche plus rien).
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é.
104 144
105 145 ### Security
106 146
@@ -119,29 +159,61 @@ acceptées** (usernames hors charset, login sans e-mail vérifié via appel serv
119 159 remontent en `AppError` : le garde ne fail jamais open.
120 160 `NULL` signifie « aucun pas consommé depuis la migration » : le premier code
121 161 présenté après migration est toujours accepté (grandfathering des lignes existantes).
122 -- **Revalidation e-mail sur changement d'adresse**
123 - ([`docs/rfcs/0038-email-change-revalidation.md`](docs/rfcs/0038-email-change-revalidation.md)) —
124 - `UserService::update_profile` bascule `email_verified = false` et
125 - `email_verified_at = NULL` dès que l'adresse change (comparaison exacte après
126 - `trim`). Un mail de validation part vers la **nouvelle** adresse (via
127 - `EmailValidationService::send_validation_email`, qui invalide au passage les
128 - tokens de validation précédents) et une alerte `account_modified` — **sans lien
129 - de validation** — part vers l'**ancienne**. Echo de l'adresse courante = no-op
130 - complet (aucun flag touché, aucun mail). Vaut aussi pour le chemin **admin**
131 - (`PUT /admin/users/:id` de `rustwarden-ui` passe par `update_profile`).
132 - Notifications **best-effort** : si `EmailConfig::from_env()` échoue, l'update en
133 - base réussit quand même et les envois sont sautés avec un warning — l'utilisateur
134 - peut demander un nouveau lien via `POST /api/v1/auth/resend-verification`.
135 - Les adresses sont masquées dans les logs (`jo***@ex***`).
136 - **Portée exacte** : seul le **prochain login** est bloqué. `update_profile` ne
137 - révoque ni les refresh tokens ni les JWT d'accès, et `refresh_token` ne relit
138 - pas `email_verified` — la session en cours reste valide jusqu'à son expiration
139 - naturelle. **Limites connues (follow-up RFC)** : (a) révocation des refresh
140 - tokens au changement d'e-mail ; (b) une faute de frappe sur la nouvelle adresse
141 - enferme le compte sans recours self-service (les deux endpoints
142 - `resend-verification` renvoient vers l'adresse **stockée**) — seul un admin
143 - débloque via `PUT /admin/users/{id}`. La v2 `pending_email_changes` supprimerait
144 - ce risque. Détail : [`docs/developer-guide/07-security-model.md`](docs/developer-guide/07-security-model.md)
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)
145 217 § *Cycle de vie de l'e-mail vérifié*.
146 218 - **Charset `username` appliqué aux frontières d'écriture**
147 219 ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) —
@@ -182,11 +254,28 @@ acceptées** (usernames hors charset, login sans e-mail vérifié via appel serv
182 254 4. **Jouer la migration avant de déployer le binaire** :
183 255 `cargo run --bin migrate` (ou `auto_migrate(true)` sur un déploiement
184 256 mono-instance).
185 -5. `cargo clippy --workspace -- -D warnings` puis les smoke tests auth
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
186 267 (login, register, changement d'e-mail, 2FA).
187 -6. Retirer les workarounds locaux devenus redondants : newtype `Username` maison,
268 +7. Retirer les workarounds locaux devenus redondants : newtype `Username` maison,
188 269 overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`,
189 - tout patch re-vérifiant `email_verified` après `authenticate_user`.
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.
190 279
191 280 Table **Avant / Après** par RFC, greps de repérage et pièges connus :
192 281 [`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md)
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."
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/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
+87 -1
@@ -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 ///
@@ -50,6 +50,10 @@ pub fn auth_api_routes() -> Router<DatabaseConnection> {
50 50 "/api/v1/auth/resend-verification/by-username",
51 51 post(handlers::resend_verification_by_username),
52 52 )
53 + .route(
54 + "/api/v1/auth/email-change",
55 + post(email_change_handlers::request_email_change),
56 + )
53 57 .layer(axum::middleware::from_fn(
54 58 crate::middleware::rate_limit::auth_ip_rate_limit,
55 59 ));
@@ -64,6 +68,16 @@ pub fn auth_api_routes() -> Router<DatabaseConnection> {
64 68 .route(
65 69 "/api/v1/auth/verify-email/{token}",
66 70 get(handlers::verify_email),
71 + )
72 + .route(
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),
76 + )
77 + .route(
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),
67 81 );
68 82
69 83 limited.merge(unlimited)
@@ -220,4 +234,76 @@ mod tests {
220 234 "11th request to /2fa/verify from 192.0.2.80 must be 429"
221 235 );
222 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 + }
223 309 }
M crates/rustwarden-core/src/lib.rs
+1 -0
@@ -63,6 +63,7 @@ pub mod prelude {
63 63 pub use crate::services::role_service::RoleService;
64 64 pub use crate::services::team_service::{TeamRole, TeamService};
65 65 pub use crate::services::user_service::UserService;
66 + pub use crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor;
66 67 pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor;
67 68 pub use crate::types::TeamSlug;
68 69 pub use crate::types::Username;
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
+3 -0
@@ -51,6 +51,8 @@ pub mod m20260423_000008_create_teams;
51 51 pub mod m20260510_000009_fediverse_interop;
52 52 /// Migration 10: nullable `user_totp.last_used_step` anti-replay column (RFC 0037).
53 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;
54 56
55 57 use sea_orm_migration::prelude::*;
56 58
@@ -67,6 +69,7 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
67 69 Box::new(m20260423_000008_create_teams::Migration),
68 70 Box::new(m20260510_000009_fediverse_interop::Migration),
69 71 Box::new(m20260918_000010_totp_last_used_step::Migration),
72 + Box::new(m20260919_000011_pending_email_changes::Migration),
70 73 ]
71 74 }
72 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 +}
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/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/user_service.rs
+152 -259
@@ -9,21 +9,6 @@ use sea_orm::{
9 9 };
10 10 use uuid::Uuid;
11 11
12 -/// Mask an email address for log output (e.g. `jo***@ex***`).
13 -///
14 -/// Keeps at most the first two characters of the local part and of the
15 -/// domain so log lines never contain a full address (SIEM-friendly).
16 -fn mask_email(email: &str) -> String {
17 - match email.split_once('@') {
18 - Some((local, domain)) => {
19 - let local_visible: String = local.chars().take(2).collect();
20 - let domain_visible: String = domain.chars().take(2).collect();
21 - format!("{local_visible}***@{domain_visible}***")
22 - }
23 - None => "***".to_string(),
24 - }
25 -}
26 -
27 12 /// Service for managing users
28 13 ///
29 14 /// This service provides business logic for user operations,
@@ -210,32 +195,32 @@ impl UserService {
210 195 ///
211 196 /// Allows updating username and/or email with validation for uniqueness.
212 197 ///
213 - /// # Email revalidation (RFC 0038)
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.
214 206 ///
215 - /// Changing the email address clears verification: when the new address
216 - /// (exact comparison after trim) differs from the stored one,
217 - /// `email_verified` is set to `false` and `email_verified_at` to `NULL`
218 - /// in the same update. A validation email is then queued for the **new**
219 - /// address (previous validation tokens are invalidated) and an
220 - /// `account_modified` alert — without any validation link — is queued for
221 - /// the **old** address. Both notifications are best-effort: if the email
222 - /// configuration is unavailable
223 - /// ([`EmailConfig::from_env`](crate::config::email::EmailConfig::from_env)
224 - /// fails), they are skipped with a warning and the database update still
225 - /// succeeds; the user can request a new link via
226 - /// `POST /api/v1/auth/resend-verification`.
207 + /// Echoing the **current** address stays a no-op — profile forms submit
208 + /// every field — and a username-only rename is unaffected.
227 209 ///
228 - /// Echoing the current email is a no-op for the verification flags, and a
229 - /// username-only rename never triggers revalidation.
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.
230 213 ///
231 214 /// ```rust,ignore
232 215 /// use rustwarden_core::services::user_service::UserService;
233 216 ///
234 - /// // Changing email clears verification until the new address is confirmed.
235 - /// let user =
236 - /// UserService::update_profile(&db, user_id, None, Some("new@example.com".into())).await?;
237 - /// assert!(!user.email_verified);
238 - /// assert!(user.email_verified_at.is_none());
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();
239 224 /// ```
240 225 pub async fn update_profile(
241 226 db: &DatabaseConnection,
@@ -249,8 +234,6 @@ impl UserService {
249 234
250 235 let mut updated_user: user::ActiveModel = user.clone().into();
251 236 let mut changes = false;
252 - // RFC 0038: previous address, `Some` only when the email actually changes.
253 - let mut previous_email: Option<String> = None;
254 237
255 238 if let Some(new_username) = &username {
256 239 let trimmed = new_username.trim();
@@ -274,29 +257,20 @@ impl UserService {
274 257 // Same username: no-op — grandfathering preserved.
275 258 }
276 259
260 + // RFC 0041: `update_profile` never writes `users.email`.
277 261 if let Some(new_email) = &email {
278 262 let trimmed = new_email.trim();
279 - if !trimmed.contains('@') || !trimmed.contains('.') {
280 - return Err(AppError::Validation("Invalid email format".to_string()));
281 - }
282 -
283 - if let Some(existing_user) = Self::get_user_by_email(db, trimmed).await? {
284 - if existing_user.id != user_id {
285 - return Err(AppError::Validation(format!(
286 - "Email '{}' already exists",
287 - trimmed
288 - )));
289 - }
290 - }
291 -
292 - // RFC 0038: exact comparison after trim. Echoing the current
293 - // address is a no-op (verification flags untouched, no emails).
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.
294 266 if trimmed != user.email.as_str() {
295 - updated_user.email = Set(trimmed.to_string());
296 - updated_user.email_verified = Set(false);
297 - updated_user.email_verified_at = Set(None);
298 - previous_email = Some(user.email.clone());
299 - changes = true;
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 + ));
300 274 }
301 275 }
302 276
@@ -307,117 +281,10 @@ impl UserService {
307 281 updated_user.updated_at = Set(Utc::now());
308 282 let user = updated_user.update(db).await?;
309 283
310 - if let Some(old_email) = previous_email {
311 - Self::notify_email_change(db, &user, &old_email).await;
312 - }
313 -
314 284 tracing::info!("Profile updated for user {}", user_id);
315 285 Ok(user)
316 286 }
317 287
318 - /// Best-effort notifications after an email change (RFC 0038).
319 - ///
320 - /// Queues a validation email for the new address (the validation service
321 - /// invalidates previous tokens itself) and an `account_modified` alert —
322 - /// without any validation link — for the old address. This never fails:
323 - /// the profile update already succeeded, so every error here is only
324 - /// logged. When the email configuration is unavailable both notifications
325 - /// are skipped and the user can rely on
326 - /// `POST /api/v1/auth/resend-verification`.
327 - async fn notify_email_change(db: &DatabaseConnection, user: &user::Model, old_email: &str) {
328 - // SIEM trail: never log full addresses.
329 - tracing::warn!(
330 - user_id = %user.id,
331 - old_email = %mask_email(old_email),
332 - new_email = %mask_email(&user.email),
333 - "Email address changed; verification reset until the new address is validated"
334 - );
335 -
336 - let email_config = match crate::config::email::EmailConfig::from_env() {
337 - Ok(config) => config,
338 - Err(e) => {
339 - tracing::warn!(
340 - user_id = %user.id,
341 - "Email configuration unavailable ({}); validation and alert emails skipped after email change",
342 - e
343 - );
344 - return;
345 - }
346 - };
347 -
348 - // Validation email to the NEW address (same helper as `register`).
349 - use crate::services::email_validation_service::EmailValidationService;
350 - let validation_service = EmailValidationService::new(email_config.clone());
351 - if let Err(e) = validation_service.send_validation_email(db, user.id).await {
352 - tracing::warn!(
353 - user_id = %user.id,
354 - "Failed to queue validation email after email change: {}",
355 - e
356 - );
357 - }
358 -
359 - // Alert to the OLD address (reuses the account_modified template,
360 - // which carries no validation link).
361 - use crate::templates::email_templates::{AccountChange, AccountModifiedTemplate};
362 - let app_name = email_config
363 - .smtp_from_name
364 - .clone()
365 - .unwrap_or_else(crate::config::app::default_app_name);
366 - // Same locale pattern as `EmailValidationService::send_validation_email`.
367 - let locale = Some("fr".to_string()); // TODO: Get from user preferences or Accept-Language header
368 - let template = AccountModifiedTemplate::new(
369 - locale,
370 - user.username.clone(),
371 - Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
372 - vec![AccountChange {
373 - field: "Email".to_string(),
374 - old_value: Some(old_email.to_string()),
375 - new_value: user.email.clone(),
376 - }],
377 - Some(app_name),
378 - );
379 - let subject = match template.locale.as_str() {
380 - "fr" => "Compte modifié",
381 - "de" => "Konto geändert",
382 - "es" => "Cuenta modificada",
383 - _ => "Account Modified",
384 - }
385 - .to_string();
386 - let body_html = match template.render_html() {
387 - Ok(html) => html,
388 - Err(e) => {
389 - tracing::warn!(
390 - user_id = %user.id,
391 - "Failed to render account-modified template after email change: {}",
392 - e
393 - );
394 - return;
395 - }
396 - };
397 - let body_text = template.render_text();
398 -
399 - use crate::services::email_queue_service::EmailQueueService;
400 - if let Err(e) = EmailQueueService::enqueue(
401 - db,
402 - old_email.to_string(),
403 - Some(user.username.clone()),
404 - email_config.smtp_from.clone(),
405 - email_config.smtp_from_name.clone(),
406 - subject,
407 - body_html,
408 - body_text,
409 - "account_modified".to_string(),
410 - )
411 - .await
412 - {
413 - tracing::warn!(
414 - user_id = %user.id,
415 - "Failed to queue email-change alert to previous address: {}",
416 - e
417 - );
418 - }
419 - }
420 -
421 288 /// Change user password
422 289 ///
423 290 /// Validates old password, checks new password strength, and creates a pending
@@ -854,16 +721,17 @@ mod tests {
854 721 .unwrap();
855 722 assert!(loaded.is_some(), "grandfathered user must be loadable");
856 723
857 - // Email-only update must not touch or re-validate the username.
858 - let new_email = format!("updated-{}@example.com", &unique[..8]);
859 - let updated = UserService::update_profile(&db, legacy_id, None, Some(new_email.clone()))
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()))
860 728 .await
861 729 .unwrap();
862 730 assert_eq!(
863 731 updated.username, legacy_username,
864 - "username must be unchanged after email-only update"
732 + "username must be unchanged after an email echo"
865 733 );
866 - assert_eq!(updated.email, new_email);
734 + assert_eq!(updated.email, legacy_email);
867 735
868 736 // Echoing the same out-of-charset username (form POST round-trip) is a no-op.
869 737 let result =
@@ -947,7 +815,7 @@ mod tests {
947 815 );
948 816 }
949 817
950 - // ── RFC 0038: email change revalidation ─────────────────────────────────
818 + // ── RFC 0041: `update_profile` never writes `users.email` ───────────────
951 819
952 820 /// Return every `email_queue` row addressed to `to_email`.
953 821 async fn queue_entries_for(db: &DatabaseConnection, to_email: &str) -> Vec<email_queue::Model> {
@@ -980,87 +848,86 @@ mod tests {
980 848 (verified, email)
981 849 }
982 850
983 - /// Happy path: changing the email clears verification and queues both
984 - /// the validation email (new address) and the alert (old address).
985 - ///
986 - /// SMTP note: this repo's `.env` (loaded by `test_db`) provides the four
987 - /// variables `EmailConfig::from_env()` requires, so the queue asserts run
988 - /// in the branch where the config is available. If the local environment
989 - /// has no SMTP configuration, RFC 0038 mandates that the update still
990 - /// succeeds with the flags reset — the `else` branch asserts exactly
991 - /// that (0 queue entries, flags reset).
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.
992 866 #[tokio::test]
993 - async fn email_change_resets_verification_and_queues_emails() {
867 + async fn email_change_is_refused_and_points_at_email_change_service() {
994 868 let db = test_db().await;
995 869 let unique = Uuid::new_v4().simple().to_string();
996 870 let (user_model, old_email) = create_verified_user(&db, "emailchg").await;
997 871 let new_email = format!("emailchg-new-{}@example.com", &unique[..8]);
998 872
999 - let updated =
1000 - UserService::update_profile(&db, user_model.id, None, Some(new_email.clone()))
1001 - .await
1002 - .unwrap();
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);
1003 876
1004 - assert_eq!(updated.email, new_email);
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());
1005 887 assert!(
1006 - !updated.email_verified,
1007 - "changing email must clear email_verified"
888 + queue_entries_for(&db, &new_email).await.is_empty(),
889 + "no email must be queued by update_profile"
1008 890 );
1009 891 assert!(
1010 - updated.email_verified_at.is_none(),
1011 - "changing email must clear email_verified_at"
892 + queue_entries_for(&db, &old_email).await.is_empty(),
893 + "no alert must be queued by update_profile"
1012 894 );
1013 895
1014 - let validation_entries = queue_entries_for(&db, &new_email).await;
1015 - let alert_entries = queue_entries_for(&db, &old_email).await;
1016 - if crate::config::email::EmailConfig::from_env().is_ok() {
1017 - assert_eq!(
1018 - validation_entries.len(),
1019 - 1,
1020 - "exactly one validation email must target the new address"
1021 - );
1022 - let validation = &validation_entries[0];
1023 - assert_eq!(validation.email_type, "email_validation");
1024 - assert_eq!(validation.status, "pending");
1025 - assert_eq!(
1026 - validation.to_name.as_deref(),
1027 - Some(user_model.username.as_str())
1028 - );
1029 -
1030 - assert_eq!(
1031 - alert_entries.len(),
1032 - 1,
1033 - "exactly one alert must target the old address"
1034 - );
1035 - let alert = &alert_entries[0];
1036 - assert_eq!(alert.email_type, "account_modified");
1037 - assert_eq!(alert.status, "pending");
1038 - // The alert must never carry the validation link.
1039 - assert!(
1040 - !alert.body_html.contains("/verify-email/"),
1041 - "alert HTML body must not contain the validation link"
1042 - );
1043 - assert!(
1044 - !alert.body_text.contains("/verify-email/"),
1045 - "alert text body must not contain the validation link"
1046 - );
1047 - } else {
1048 - // Best-effort contract: no SMTP config → no queue entries, but
1049 - // the DB update above already succeeded with the flags reset.
1050 - eprintln!("SMTP absent: asserts de queue sautés (EmailConfig::from_env() en échec)");
1051 - assert!(validation_entries.is_empty());
1052 - assert!(alert_entries.is_empty());
1053 - }
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;
1054 906
1055 - // Clean up only rows created by this test.
1056 - for entry in validation_entries.into_iter().chain(alert_entries) {
1057 - entry.delete(&db).await.unwrap();
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();
1058 915 }
1059 - // Cascade removes this user's email_validation_token rows.
1060 - updated.delete(&db).await.unwrap();
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();
1061 928 }
1062 929
1063 - /// Invalid format is rejected before any write or enqueue.
930 + /// An invalid address goes through the same branch: refused, no write.
1064 931 #[tokio::test]
1065 932 async fn email_change_invalid_format_rejected_without_side_effects() {
1066 933 let db = test_db().await;
@@ -1069,10 +936,7 @@ mod tests {
1069 936 let result =
1070 937 UserService::update_profile(&db, user_model.id, None, Some("not-an-email".into()))
1071 938 .await;
1072 - assert!(
1073 - matches!(result, Err(AppError::Validation(_))),
1074 - "invalid email format must be rejected"
1075 - );
939 + assert_refused_towards_email_change_service(result);
1076 940
1077 941 let reloaded = UserService::get_user_by_id(&db, user_model.id)
1078 942 .await
@@ -1085,16 +949,13 @@ mod tests {
1085 949 queue_entries_for(&db, "not-an-email").await.is_empty(),
1086 950 "no email must be queued on a rejected update"
1087 951 );
1088 - assert!(
1089 - queue_entries_for(&db, &email).await.is_empty(),
1090 - "no alert must be queued on a rejected update"
1091 - );
1092 952
1093 953 reloaded.delete(&db).await.unwrap();
1094 954 }
1095 955
1096 - /// Changing to an address already owned by another user is rejected
1097 - /// without un-verifying the caller.
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.
1098 959 #[tokio::test]
1099 960 async fn email_change_to_taken_address_rejected_without_unverify() {
1100 961 let db = test_db().await;
@@ -1103,10 +964,7 @@ mod tests {
1103 964
1104 965 let result =
1105 966 UserService::update_profile(&db, caller.id, None, Some(taken_email.clone())).await;
1106 - assert!(
1107 - matches!(result, Err(AppError::Validation(_))),
1108 - "taken email must be rejected"
1109 - );
967 + assert_refused_towards_email_change_service(result);
1110 968
1111 969 let reloaded = UserService::get_user_by_id(&db, caller.id)
1112 970 .await
@@ -1130,8 +988,9 @@ mod tests {
1130 988 owner.delete(&db).await.unwrap();
1131 989 }
1132 990
1133 - /// Echoing the current address (exact match after trim) is a no-op:
1134 - /// flags stay verified and nothing is queued.
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.
1135 994 #[tokio::test]
1136 995 async fn email_echo_same_address_is_noop() {
1137 996 let db = test_db().await;
@@ -1171,14 +1030,48 @@ mod tests {
1171 1030 padded.delete(&db).await.unwrap();
1172 1031 }
1173 1032
1174 - // ── mask_email ───────────────────────────────────────────────────────────
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);
1175 1074
1176 - #[test]
1177 - fn mask_email_never_leaks_full_address() {
1178 - assert_eq!(mask_email("john.doe@example.com"), "jo***@ex***");
1179 - assert_eq!(mask_email("a@b.c"), "a***@b.***");
1180 - assert_eq!(mask_email("no-at-sign"), "***");
1181 - // Multi-byte local part must not panic.
1182 - assert_eq!(mask_email("émile@exemple.fr"), "ém***@ex***");
1075 + final_user.delete(&db).await.unwrap();
1183 1076 }
1184 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 +}
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/src/handlers/settings/profile.rs
+10 -29
@@ -38,40 +38,21 @@ 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 - // RFC 0038: same trim rule as the core comparison, so a padded echo of
44 - // the current address does not invalidate pending password changes.
45 - let email_was_changed = new_email
46 - .as_ref()
47 - .map(|e| e.trim() != old_email)
48 - .unwrap_or(false);
49 -
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.
50 48 let updated_user =
51 49 UserService::update_profile(&db, claims.sub, request.username, request.email).await?;
52 50
53 51 tracing::info!("Profile updated for user {}", updated_user.username);
54 52
55 - if email_was_changed {
56 - use rustwarden_core::services::password_change_service::PasswordChangeService;
57 - let password_change_service = PasswordChangeService::new();
58 - let invalidated = password_change_service
59 - .invalidate_on_email_change(&db, claims.sub)
60 - .await?;
61 - if invalidated > 0 {
62 - tracing::warn!(
63 - "Invalidated {} pending password change request(s) for user {} due to email change",
64 - invalidated,
65 - updated_user.username
66 - );
67 - }
68 - }
69 -
70 - // RFC 0038: when the email changes, `UserService::update_profile` (core)
71 - // already queues the validation email to the new address and the
72 - // `account_modified` alert to the old one. The UI layer only notifies an
73 - // actual username rename here: the profile form always submits every
74 - // field, so an echoed (unchanged) username must not trigger an alert.
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.
75 56 if updated_user.username != old_user.username {
76 57 if let Ok(email_config) = EmailConfig::from_env() {
77 58 let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
M docs/MIGRATION_GUIDE.md
+161 -76
@@ -9,17 +9,22 @@ Les entrees sont classees par date, de la plus recente a la plus ancienne.
9 9
10 10 ---
11 11
12 -## 2026-09-18 — RFC 0036-0040 (1.1.0)
12 +## 2026-09-18 — RFC 0036-0041 (1.1.0)
13 13
14 -Minor de securite. Les cinq RFC 0036 a 0040 sont **Accepted (2026-09-18)** et
15 -**implementees**. Fenetre d'adoption **14 jours**
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**
16 19 ([RUSTWARDEN_EVOLUTION_PROCESS.md](RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
17 20 Details : CHANGELOG `[1.1.0]`, [SERVICES_REFERENCE.md](SERVICES_REFERENCE.md)
18 21 bandeau 1.1.0 + §3, §4, §9, §10, §19.
19 22
20 -**Deux API ajoutees** (`types::Username`, `config::security_headers::SecurityHeadersConfig`),
23 +**Trois API ajoutees** (`types::Username`,
24 +`config::security_headers::SecurityHeadersConfig`,
25 +`services::email_change_service::EmailChangeService`),
21 26 **quatre changements de comportement** sur des chemins d'auth existants,
22 -**une migration SeaORM a jouer**.
27 +**deux migrations SeaORM a jouer**, **cinq routes ajoutees**.
23 28
24 29 > ⚠️ **Ordre imperatif** : jouer la migration
25 30 > `m20260918_000010_totp_last_used_step` **avant** de demarrer le binaire 1.1.0.
@@ -44,14 +49,18 @@ cargo run -p rustwarden-cli -- update-project
44 49 # validator_derive = "0.20.1"
45 50 cargo update
46 51
47 -# 4. Migration AVANT le deploiement du binaire
52 +# 4. Migrations AVANT le deploiement du binaire
53 +# m20260918_000010_totp_last_used_step + m20260919_000011_pending_email_changes
48 54 cargo run --bin migrate
49 55 # (ou auto_migrate(true) si deploiement mono-instance)
50 56
51 -# 5. Lint
57 +# 5. Rediriger les appels qui changeaient l'email (RFC 0041)
58 +grep -rn "update_profile" src/
59 +
60 +# 6. Lint
52 61 cargo clippy --workspace -- -D warnings
53 62
54 -# 6. Smoke tests auth : login, register, changement d'email, 2FA
63 +# 7. Smoke tests auth : login, register, changement d'email, 2FA
55 64 ```
56 65
57 66 `cargo clippy --workspace --all-features` est a eviter : la feature
@@ -175,78 +184,150 @@ grep -rn "last_used_step\|replay_guard\|totp_replay" src/
175 184
176 185 ---
177 186
178 -### RFC 0038 — revalidation email a `update_profile`
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)
179 190
180 -[docs/rfcs/0038-email-change-revalidation.md](rfcs/0038-email-change-revalidation.md)
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.
181 195
182 196 | Sujet | Avant (1.0.1) | Apres (1.1.0) |
183 197 |-------|---------------|---------------|
184 -| Changement d'email | `email` ecrit, `email_verified` **conserve a `true`** | `email_verified = false` + `email_verified_at = NULL` dans le **meme** update |
185 -| Comparaison | — | Exacte apres `trim` ; echo de l'adresse courante = no-op complet (aucun flag, aucun mail) |
186 -| Mail vers la nouvelle adresse | Aucun | `EmailValidationService::send_validation_email` (invalide au passage les tokens de validation precedents) |
187 -| Mail vers l'ancienne adresse | Aucun (ou dupplique cote UI) | Alerte `account_modified`, **sans lien de validation** |
188 -| Chemin admin | Non couvert | `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile` → meme comportement |
189 -| Echec SMTP | — | **Best-effort** : `EmailConfig::from_env()` en erreur → envois sautes avec warning, **l'update en base reussit** |
190 -| Logs | Adresse en clair possible | Adresses masquees (`jo***@ex***`) |
191 -| Rename username seul | — | Ne declenche **aucune** revalidation |
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.
192 231
193 232 **Actions consommateur**
194 233
195 234 ```bash
196 -# Patch local re-verifiant l'email apres un changement d'adresse
235 +# 1. Appels qui passaient une nouvelle adresse
236 +grep -rn "update_profile" src/
237 +
238 +# 2. Code applicatif devenu redondant
197 239 grep -rn "email_verified" src/
198 240 grep -rn "account_modified" src/
241 +grep -rn "invalidate_on_email_change" src/
199 242 ```
200 243
201 -- **Retirer** tout code applicatif qui, apres `update_profile`, remettait
202 - `email_verified = false` a la main ou enqueuait lui-meme un mail de
203 - validation / une alerte `account_modified` pour l'email : le core le fait
204 - desormais (sinon l'utilisateur recoit deux mails).
205 -- Cote UI custom : exposer un bouton « renvoyer l'email de validation »
206 - (`POST /api/v1/auth/resend-verification`) — c'est le filet de secours quand
207 - SMTP etait indisponible au moment du changement.
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.
208 301
209 302 **Pieges**
210 303
211 -- **Seul le PROCHAIN login est bloque — la session en cours survit.**
212 - `update_profile` ne revoque **ni** les refresh tokens **ni** les JWT d'acces
213 - deja emis, et `refresh_token` (`auth/handlers.rs`) ne relit pas
214 - `email_verified`. Concretement, avec `validation_email_required` actif
215 - (defaut, cf. RFC 0040) : l'utilisateur qui change d'adresse **reste connecte**
216 - et peut continuer a rafraichir ses tokens jusqu'a leur expiration naturelle ;
217 - c'est seulement une **nouvelle authentification** (`authenticate_user`) qui est
218 - refusee tant que la nouvelle adresse n'est pas validee. `rustwarden-ui`
219 - redirige vers `/login` cote client apres un changement d'email, mais c'est un
220 - choix d'UI, **pas une revocation serveur**. Prevenir explicitement
221 - l'utilisateur dans l'ecran de profil.
222 -- Meme chose pour un **admin** qui corrige l'email d'un utilisateur depuis
223 - `PUT /admin/users/:id` : il ne deconnecte pas le compte, il en bloque la
224 - **prochaine** connexion jusqu'a validation.
225 -- **Limite connue / follow-up** : revoquer les refresh tokens au changement
226 - d'e-mail (ou appliquer la politique `email_verified` dans `refresh_token`)
227 - fera l'objet d'une **RFC ulterieure**. Tant qu'elle n'est pas livree, ne
228 - comptez pas sur `update_profile` comme mecanisme de deconnexion : si vous avez
229 - besoin d'une revocation immediate, appelez vous-meme
230 - `RefreshTokenService::revoke_all_tokens_for_user` apres le changement.
231 -- **Faute de frappe sur la nouvelle adresse = lock-out definitif sans recours
232 - self-service.** La chaine est : `update_profile` ecrit la **nouvelle** adresse
233 - et passe `email_verified = false` ; le mail de validation part vers cette
234 - nouvelle adresse ; l'ancienne ne recoit qu'une alerte `account_modified`
235 - **sans lien d'action**. Les deux voies de recuperation
236 - (`POST /api/v1/auth/resend-verification` et
237 - `POST /api/v1/auth/resend-verification/by-username`) renvoient vers l'adresse
238 - **stockee** — donc vers la faute de frappe. Un utilisateur qui saisit
239 - `bob@gmial.com` au lieu de `bob@gmail.com` n'a **aucun** moyen de se debloquer
240 - seul : seul un **admin** peut corriger l'adresse via `PUT /admin/users/{id}`.
241 - - *Recommandation aux consommateurs* : ajouter un **champ de confirmation
242 - d'adresse** (double saisie) dans votre ecran de profil, et afficher la
243 - nouvelle adresse en clair avant soumission.
244 - - *Suppression du risque* : la **v2 `pending_email_changes`** evoquee en
245 - follow-up de la RFC 0038 (ecrire l'adresse candidate dans une table d'attente
246 - et ne basculer `user.email` qu'apres clic sur le lien) supprimerait
247 - entierement ce mode de panne — l'adresse courante resterait valide et
248 - verifiee tant que la nouvelle n'est pas confirmee.
249 -- La locale des mails est figee a `fr` (TODO amont, non resolu par cette RFC).
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.
250 331
251 332 ---
252 333
@@ -347,12 +428,12 @@ grep -rn "login_blocked\|on_audit_event" src/
347 428 - **Le hook `on_audit_event` / `auth.login_blocked` n'est plus emis** sur ce
348 429 chemin. Une alerte SIEM branchee sur ce hook devient silencieuse : basculer la
349 430 detection sur la table `audit_logs` (`action = "login_blocked"`).
350 -- Combine a la RFC 0038 : tout changement d'email bloque le **prochain login**
351 - jusqu'a la validation. La session en cours (JWT d'acces + refresh token)
352 - **reste valide** — `update_profile` ne revoque rien et `refresh_token` ne
353 - relit pas `email_verified`. C'est le comportement voulu en v1, mais il change
354 - l'experience utilisateur de facon visible ; voir la § RFC 0038 ci-dessus pour
355 - la limite connue et le risque de lock-out par faute de frappe.
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.
356 437 - Le reglage reste **global** (`app_settings`), pas par utilisateur.
357 438
358 439 ---
@@ -364,8 +445,11 @@ grep -rn "login_blocked\|on_audit_event" src/
364 445 | Newtype `Username` / `validate_username()` local (gitrust) | `rustwarden_core::types::Username` (prelude) |
365 446 | `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` (gitrust) | `builder().security_headers(SecurityHeadersConfig { .. })` |
366 447 | Headers COOP/CORP poses a la main | `SecurityHeadersConfig::with_same_origin_isolation()` |
367 -| Reset manuel de `email_verified` apres `update_profile` | Fait par le core (RFC 0038) |
368 -| Enqueue applicatif du mail de validation / `account_modified` sur changement d'email | Fait par le core (RFC 0038) |
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) |
369 453 | Re-verification de `email_verified` apres `authenticate_user` | Fait par le service (RFC 0040) |
370 454 | Patch local de l'anti-rejeu TOTP | `user_totp.last_used_step` (RFC 0037) |
371 455
@@ -377,11 +461,12 @@ Patch de securite **sans nouvelle API `pub`**. Fenetre d'adoption **14 jours** a
377 461 diffusion de `[1.0.1]`. Details techniques : CHANGELOG `[1.0.1]`,
378 462 [SERVICES_REFERENCE.md](SERVICES_REFERENCE.md) bandeau 1.0.1 + §4, §6, §12, §15, §18, §19.
379 463
380 -> **Mise a jour 2026-09-18** : les RFC 0036–0040 (charset username,
464 +> **Mise a jour 2026-09-19** : les RFC 0036–0040 (charset username,
381 465 > `last_used_step` TOTP, re-validation email, headers de securite configurables,
382 466 > `email_verified` dans `authenticate_user`) sont depuis passees **Accepted** et
383 -> **livrees** dans `[1.1.0]`. Voir l'entree
384 -> [2026-09-18 — RFC 0036-0040 (1.1.0)](#2026-09-18--rfc-0036-0040-110) ci-dessus.
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.
385 470
386 471 ### Breaking operational (pas d'API)
387 472
M docs/SERVICES_REFERENCE.md
+105 -31
@@ -12,9 +12,9 @@
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-0040) — propagé par `update-project`.** Minor de sécurité : **2 API ajoutées** ([`Username`](#username-value-object) §3, [`SecurityHeadersConfig`](#en-tetes-de-securite-securityheadersconfig) §18), **4 changements de comportement** (§3 `create_user` / `update_profile`, §4 `authenticate_user`, §19 TOTP), **1 migration SeaORM à jouer** (`m20260918_000010_totp_last_used_step`). Fenêtre d'adoption **14 jours**.
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 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) un changement d'e-mail remet `email_verified = false` — le **prochain** login est bloqué jusqu'à validation si `validation_email_required` (défaut `true`), mais la **session en cours n'est pas révoquée** (§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. Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § *2026-09-18 — RFC 0036-0040 (1.1.0)* et CHANGELOG `[1.1.0]`.
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 18
19 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 20
@@ -139,7 +139,7 @@ Gestion CRUD des utilisateurs et de leurs roles.
139 139 | `get_user_roles` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne les noms des roles d'un utilisateur (ex: `["admin", "user"]`). |
140 140 | `assign_role_to_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model>` | Assigne un role a un utilisateur. |
141 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. |
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 et de l'email. **1.1.0** : `Username::parse` sur un **vrai rename** uniquement (RFC 0036) ; un changement d'adresse remet `email_verified = false` et declenche les mails de revalidation (RFC 0038) — voir ci-dessous. |
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. |
143 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)`. |
144 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). |
145 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). |
@@ -174,38 +174,112 @@ hors charset continuent de **se connecter** ; seules les **ecritures** sont
174 174 controlees. Un client qui renvoie le username courant (formulaire de profil
175 175 postant tous les champs) est traite en **no-op** — le grandfathering est preserve.
176 176
177 -#### Revalidation e-mail (RFC 0038, depuis 1.1.0)
177 +#### Changement d'adresse e-mail (RFC 0041, depuis 1.1.0)
178 178
179 -`update_profile` traite un changement d'adresse comme une perte de preuve de possession :
179 +`update_profile` **n'ecrit plus jamais** `users.email` :
180 180
181 181 | Cas | Comportement |
182 182 |-----|--------------|
183 -| Nouvelle adresse (comparaison exacte apres `trim`) | `email_verified = false` + `email_verified_at = NULL` dans le **meme** `UPDATE` |
184 -| Nouvelle adresse | Mail de validation envoye a la **nouvelle** adresse (`EmailValidationService::send_validation_email`, qui invalide les tokens precedents) |
185 -| Nouvelle adresse | Alerte `account_modified` a l'**ancienne** adresse, **sans lien de validation** |
186 -| Echo de l'adresse courante | No-op complet : aucun flag modifie, aucun mail |
187 -| Rename username seul | Aucune revalidation |
188 -| `EmailConfig::from_env()` indisponible | **Best-effort** : envois sautes avec warning, l'`UPDATE` reussit quand meme. Filet : `POST /api/v1/auth/resend-verification` |
189 -| Logs | Adresses masquees (`jo***@ex***`) |
190 -
191 -Vaut aussi pour le chemin **admin** : `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile`.
192 -
193 -> ⚠️ Combine a [§4 `authenticate_user`](#4-authentification) : avec
194 -> `validation_email_required = true` (defaut), **le prochain login est bloque**
195 -> tant que l'utilisateur n'a pas clique sur le nouveau lien. En revanche la
196 -> **session en cours survit** : `update_profile` ne revoque ni les refresh
197 -> tokens ni les JWT d'acces deja emis, et `refresh_token` ne relit pas
198 -> `email_verified` — l'utilisateur reste donc connecte et peut rafraichir ses
199 -> tokens jusqu'a leur expiration naturelle. La redirection vers `/login` de
200 -> `rustwarden-ui` est un choix d'UI cote client, **pas une revocation serveur**.
201 -> Prevenez l'utilisateur dans votre ecran de profil, et exposez un bouton
202 -> « renvoyer l'email de validation ».
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.
203 277 >
204 -> **Limite connue / follow-up** : revoquer les refresh tokens au changement
205 -> d'e-mail (ou appliquer la politique `email_verified` dans `refresh_token`)
206 -> fera l'objet d'une RFC ulterieure. En attendant, si vous avez besoin d'une
207 -> revocation immediate, appelez `RefreshTokenService::revoke_all_tokens_for_user`
208 -> apres le changement.
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.
209 283
210 284 ### `Username` (value object)
211 285
@@ -458,7 +532,7 @@ Rate limiting delegue a [`RateLimitService`](#15-rate-limiting) via `check_slidi
458 532 | `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). |
459 533 | `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. |
460 534 | `cancel_pending_request` | `(&self, db, user_id: Uuid) → Result<()>` | Annule la demande pendante. |
461 -| `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). |
462 536 | `cleanup_expired_requests` | `(&self, db) → Result<u64>` | Nettoie les demandes expirees ou confirmees. |
463 537 | `cleanup_failed_requests` | `(&self, db) → Result<u64>` | Supprime les demandes avec 3+ tentatives echouees. |
464 538
M docs/developer-guide/07-security-model.md
+40 -21
@@ -21,7 +21,8 @@ Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden :
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 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 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 | Revalidation forcée + alerte à l'ancienne adresse (RFC 0038) | [`services/user_service.rs`](../../crates/rustwarden-core/src/services/user_service.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) |
25 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) |
26 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) |
27 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) |
@@ -389,7 +390,7 @@ Le flag `users.email_verified` (et son horodatage `email_verified_at`) condition
389 390 stateDiagram-v2
390 391 [*] --> NonVerifie : register (email_verified = false)
391 392 NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token
392 - Verifie --> NonVerifie : changement d'adresse<br/>UserService::update_profile (RFC 0038)
393 + Verifie --> Verifie : changement d'adresse confirmé<br/>EmailChangeService::confirm_change (RFC 0041)<br/>nouvelle adresse écrite DÉJÀ vérifiée
393 394 NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents)
394 395
395 396 note right of NonVerifie
@@ -398,44 +399,62 @@ stateDiagram-v2
398 399 (défaut, fail-closed).
399 400 Session en cours NON révoquée.
400 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
401 411 ```
402 412
403 413 ### 1. Inscription
404 414
405 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.
406 416
407 -### 2. Changement d'adresse (RFC 0038)
417 +### 2. Changement d'adresse (RFC 0041)
408 418
409 -Depuis la [RFC 0038](../rfcs/0038-email-change-revalidation.md), `UserService::update_profile` traite un changement d'adresse comme une perte de preuve de possession :
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**.
410 420
411 421 | Étape | Comportement |
412 422 |---|---|
413 -| Détection | Comparaison **exacte après `trim`** avec l'adresse stockée. Echo de l'adresse courante = no-op complet |
414 -| Écriture | `email`, `email_verified = false` et `email_verified_at = NULL` dans **le même** `UPDATE` |
415 -| Nouvelle adresse | Mail de validation via `EmailValidationService::send_validation_email`, qui invalide au passage les tokens de validation antérieurs |
416 -| Ancienne adresse | Alerte `account_modified`, **sans aucun lien de validation** — elle informe, elle ne permet pas d'agir |
417 -| Échec SMTP | **Best-effort** : si `EmailConfig::from_env()` échoue, les envois sont sautés avec un warning et l'`UPDATE` reste committé. L'utilisateur peut demander un lien via `POST /api/v1/auth/resend-verification` |
418 -| Logs | Adresses masquées (`mask_email` → `jo***@ex***`) — aucune adresse complète en clair dans le trail SIEM |
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.
419 431
420 -Le chemin **admin** est couvert par construction : `PUT /admin/users/:id` de `rustwarden-ui` appelle `UserService::update_profile`, donc un administrateur qui corrige une adresse déclenche la même revalidation.
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.
421 433
422 -L'alerte vers l'ancienne adresse est le signal d'alarme du scénario « session volée, l'attaquant change l'e-mail pour verrouiller le compte » : le propriétaire légitime est prévenu sur une adresse que l'attaquant ne contrôle pas.
434 +#### Pourquoi le signalement n'annule pas le changement
423 435
424 -#### Portée exacte : le prochain login, pas la session en cours
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.
425 437
426 -`update_profile` **ne révoque rien** : ni les refresh tokens (`refresh_tokens`), ni les JWT d'accès déjà émis. Et `refresh_token` (`auth/handlers.rs`) ne relit pas `email_verified` avant de réémettre une paire de tokens. Le passage à `email_verified = false` ne ferme donc **que la porte d'entrée** (`authenticate_user`) : la session ouverte au moment du changement reste pleinement utilisable jusqu'à l'expiration naturelle du refresh token.
438 +#### Pourquoi un `GET` ne confirme jamais rien
427 439
428 -Conséquence côté modèle de menace : dans le scénario « session volée », remettre `email_verified = false` ne déloge pas l'attaquant. C'est l'alerte `account_modified` vers l'ancienne adresse qui reste le mécanisme de détection, et la révocation doit être déclenchée explicitement (`RefreshTokenService::revoke_all_tokens_for_user`). Une **RFC ultérieure** couvrira la révocation automatique au changement d'e-mail.
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 |
429 450
430 -#### Risque résiduel : faute de frappe = lock-out définitif
451 +#### Risque résiduel : recours perdu et faute de frappe à l'inscription
431 452
432 453 | | |
433 454 |---|---|
434 -| **Cause** | `update_profile` écrit immédiatement la **nouvelle** adresse dans `users.email` et passe `email_verified = false`. Aucune étape de confirmation : l'adresse saisie devient l'adresse du compte, correcte ou non. |
435 -| **Conséquence** | Le mail de validation part vers l'adresse fautive (`bob@gmial.com`). L'ancienne adresse ne reçoit qu'une alerte `account_modified`, **sans lien d'action**. Les deux voies de récupération — `POST /api/v1/auth/resend-verification` et `POST /api/v1/auth/resend-verification/by-username` — renvoient elles aussi vers l'adresse **stockée**, donc vers la faute de frappe. Avec `validation_email_required = true` (défaut), le compte devient inaccessible au prochain login. |
436 -| **Recours** | **Aucun recours self-service.** Seul un administrateur peut débloquer le compte en corrigeant l'adresse via `PUT /admin/users/{id}` (qui repasse par `update_profile` et renvoie un lien de validation à la bonne adresse). |
437 -| **Recommandation consommateur** | Ajouter un **champ de confirmation d'adresse** (double saisie) dans l'écran de profil et dans l'écran admin, et réafficher la nouvelle adresse en clair avant soumission. C'est la seule barrière disponible aujourd'hui, et elle vit côté application. |
438 -| **Suppression du risque** | La **v2 `pending_email_changes`** listée en follow-up de la [RFC 0038](../rfcs/0038-email-change-revalidation.md) — écrire l'adresse candidate dans une table d'attente et ne basculer `users.email` qu'après le clic sur le lien — supprimerait entièrement ce mode de panne : l'adresse courante resterait valide et vérifiée tant que la nouvelle n'est pas confirmée. |
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`. |
439 458
440 459 ### 3. Où vit la politique de login (RFC 0040)
441 460
M docs/rfcs/0038-email-change-revalidation.md
+4 -1
@@ -2,7 +2,10 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Accepted (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 +
6 9 - **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/:id`) ; tout consommateur qui appelle `UserService::update_profile`
7 10 - **Crate cible** : `rustwarden-core`
8 11 - **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 15)
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
+4 -1
@@ -94,12 +94,15 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
94 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 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 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` | **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 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 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 |
100 101
101 102 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
102 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 +
103 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.
104 107
105 108 ## RFCs liées au plan fediverse
M tools/rustwarden-cli/src/templates.rs
+11 -0
@@ -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.