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 | ||
| 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("<script>")); | |
| 129 | + assert!(html.contains("a & b")); | |
| 130 | + assert!(html.contains(""quoted"")); | |
| 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 [¬ice.body_text, ¬ice.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('\'', "'") |
| 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 [¬ice_html, ¬ice_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 (`<`), not named ones. | |
| 1674 | + assert!(html.contains("<script>")); | |
| 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. |
GitRust