secure from gitrust

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

9ac094b25a94675d6d878e38eee8dc2b9f54758a

1 parent(s)

50 fichiers modifiés +3514 -277
M CHANGELOG.md
+187 -0
@@ -5,6 +5,193 @@ 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 +
61 +### Changed
62 +
63 +- `RegisterRequest.username` (`POST /api/v1/auth/register`) passe de
64 + `#[validate(length(min = 3, max = 64))]` à `max = 39`, et le handler appelle
65 + `Username::parse` après `validate()` (défense en profondeur — la porte de
66 + validation unique reste `UserService::create_user`).
67 +- `UserService::create_user` valide désormais son argument `username` via
68 + `Username::parse`. Un chemin bootstrap `pub(crate) create_user_allow_reserved`
69 + (charset + longueur, mais noms réservés autorisés) est réservé à
70 + `ensure_default_admin`, pour que `ADMIN_USERNAME=admin` continue de booter.
71 + `AdminConfig::validate` délègue au même parse permissif.
72 +- `OAuthService::generate_unique_username` produit des candidats conformes au
73 + nouveau charset : minuscules, `[a-z0-9]` uniquement, base tronquée à 30
74 + caractères (marge pour les suffixes `-NNN` / `-<hex8>`), suffixe `-user` si la
75 + base tombe sur un nom réservé. Les séparateurs sont des `-` (plus de `_`).
76 +- `rustwarden-ui` — `handlers::user_handler::CreateUserRequest.username` passe à
77 + `#[validate(length(min = 3, max = 39))]`.
78 +- `rustwarden-ui` — **les formulaires livrés appliquent enfin la RFC 0036
79 + côté client** : `templates/profile.html` passe de
80 + `maxlength="50" pattern="[a-zA-Z0-9_-]+"` à `maxlength="39" pattern="[a-z0-9-]+"`,
81 + le champ username du formulaire d'inscription de `templates/login.html`
82 + (`maxlength="50"`, aucun pattern) reçoit les mêmes contraintes, et
83 + `templates/admin/user_form.html` (mode `create`), qui n'avait **ni** pattern
84 + **ni** `maxlength`, les reçoit également. Les six locales
85 + (`fr`, `en`, `es`, `it`, `de`, `pt`) sont corrigées : `profile.username_pattern`
86 + et `profile.username_help` décrivaient une règle devenue fausse (« lettres,
87 + chiffres, tirets et underscores », « 3-50 caractères ») et annoncent désormais
88 + « minuscules, chiffres et tirets uniquement, 3 à 39 caractères » ; deux clés
89 + sont ajoutées (`login.username_pattern_register`,
90 + `admin.user_form.username_pattern`) et les placeholders d'exemple contenant un
91 + `_` sont passés au `-`. Sans cela l'UI de référence laissait saisir des valeurs
92 + que le serveur refuse en 400.
93 +- `rustwarden-cli` — le wizard `start-project` valide désormais le **username
94 + admin** saisi (`ask_admin_config`) contre les règles RFC 0036 : minuscules,
95 + chiffres et tirets uniquement, 3 à 39 caractères, pas de `--`, pas de tiret en
96 + tête ni en queue. Les noms réservés (`admin`) restent acceptés, le bootstrap
97 + passant par `parse_allow_reserved`. Auparavant une saisie comme `Admin`,
98 + `admin_user` ou `jean.dupont` générait un projet dont le premier `build()`
99 + échouait sur `username_format` sans message actionnable.
100 +- `rustwarden-ui` — `PUT /settings/profile` n'enqueue plus l'alerte
101 + `account_modified` pour un changement d'e-mail : le core s'en charge (RFC 0038).
102 + L'alerte « username modifié » n'est envoyée que sur un **vrai** rename
103 + (le formulaire de profil renvoyant tous les champs, un echo ne déclenche plus rien).
104 +
105 +### Security
106 +
107 +- **Anti-rejeu TOTP persistant**
108 + ([`docs/rfcs/0037-totp-last-used-step.md`](docs/rfcs/0037-totp-last-used-step.md)) —
109 + nouvelle migration `m20260918_000010_totp_last_used_step` ajoutant
110 + `user_totp.last_used_step BIGINT NULL`. `TotpService::verify_code` et
111 + `verify_and_enable` consomment le **pas qui a réellement matché**
112 + (`matching_totp_step`, skew ±1) via un compare-and-set SeaORM
113 + (`UPDATE user_totp SET last_used_step = $step WHERE user_id = $id AND
114 + (last_used_step IS NULL OR last_used_step < $step)`, succès ssi
115 + `rows_affected == 1`). Un rejeu renvoie `Ok(false)` — sémantique identique à un
116 + code invalide, donc pas d'oracle de rejeu. Ferme le rejeu **inter-réplicas** que
117 + la garde in-process de 1.0.1 ne couvrait pas (une map par processus). La garde
118 + in-process est conservée en défense en profondeur, après le CAS. Les erreurs DB
119 + remontent en `AppError` : le garde ne fail jamais open.
120 + `NULL` signifie « aucun pas consommé depuis la migration » : le premier code
121 + présenté après migration est toujours accepté (grandfathering des lignes existantes).
122 +- **Revalidation e-mail sur changement d'adresse**
123 + ([`docs/rfcs/0038-email-change-revalidation.md`](docs/rfcs/0038-email-change-revalidation.md)) —
124 + `UserService::update_profile` bascule `email_verified = false` et
125 + `email_verified_at = NULL` dès que l'adresse change (comparaison exacte après
126 + `trim`). Un mail de validation part vers la **nouvelle** adresse (via
127 + `EmailValidationService::send_validation_email`, qui invalide au passage les
128 + tokens de validation précédents) et une alerte `account_modified` — **sans lien
129 + de validation** — part vers l'**ancienne**. Echo de l'adresse courante = no-op
130 + complet (aucun flag touché, aucun mail). Vaut aussi pour le chemin **admin**
131 + (`PUT /admin/users/:id` de `rustwarden-ui` passe par `update_profile`).
132 + Notifications **best-effort** : si `EmailConfig::from_env()` échoue, l'update en
133 + base réussit quand même et les envois sont sautés avec un warning — l'utilisateur
134 + peut demander un nouveau lien via `POST /api/v1/auth/resend-verification`.
135 + Les adresses sont masquées dans les logs (`jo***@ex***`).
136 + **Portée exacte** : seul le **prochain login** est bloqué. `update_profile` ne
137 + révoque ni les refresh tokens ni les JWT d'accès, et `refresh_token` ne relit
138 + pas `email_verified` — la session en cours reste valide jusqu'à son expiration
139 + naturelle. **Limites connues (follow-up RFC)** : (a) révocation des refresh
140 + tokens au changement d'e-mail ; (b) une faute de frappe sur la nouvelle adresse
141 + enferme le compte sans recours self-service (les deux endpoints
142 + `resend-verification` renvoient vers l'adresse **stockée**) — seul un admin
143 + débloque via `PUT /admin/users/{id}`. La v2 `pending_email_changes` supprimerait
144 + ce risque. Détail : [`docs/developer-guide/07-security-model.md`](docs/developer-guide/07-security-model.md)
145 + § *Cycle de vie de l'e-mail vérifié*.
146 +- **Charset `username` appliqué aux frontières d'écriture**
147 + ([`docs/rfcs/0036-username-charset-validation.md`](docs/rfcs/0036-username-charset-validation.md)) —
148 + `UserService::create_user`, `UserService::update_profile` et
149 + `POST /api/v1/auth/register` refusent les identifiants hors charset (backticks,
150 + `_`, `.`, majuscules, noms réservés). Réduit la surface d'injection dans les
151 + chemins d'URL, les templates et les logs.
152 + **Aucun `CHECK` SQL, aucune migration, aucun rewrite** : les comptes existants
153 + hors charset restent utilisables et **continuent de se connecter**.
154 + `update_profile` ne valide que sur un **vrai rename** — un client qui renvoie le
155 + username courant (y compris un handle grandfathered) est traité en no-op.
156 +- **`AuthService::authenticate_user` honore `validation_email_required`**
157 + ([`docs/rfcs/0040-authenticate-user-email-verified.md`](docs/rfcs/0040-authenticate-user-email-verified.md)) —
158 + le contrôle « e-mail vérifié » a été **déplacé** du handler HTTP `login` vers le
159 + service. Mêmes clés de réglage (`validation_email_required`, alias legacy
160 + `validation_email_requise`), même défaut `true`, même comportement fail-closed
161 + (une erreur de lookup vaut « vérification requise »), **même message d'erreur
162 + français** et même `AppError::Validation` (HTTP 400). Le contrôle intervient
163 + **après** la vérification du mot de passe, donc pas d'oracle « le compte existe
164 + mais n'est pas vérifié ». Conséquence : une app consommatrice qui appelait le
165 + **service** (SSR maison, handler custom) ne peut plus contourner la politique.
166 + `AuthService::verify_credentials` reste la primitive « mot de passe seul »
167 + (utilisée par le re-prompt TOTP) et n'applique **pas** ce contrôle.
168 + **Effet de bord documenté (décision v1)** : le blocage n'émet plus le hook
169 + `on_audit_event` / `auth.login_blocked` — la ligne `audit_logs` est toujours
170 + écrite par `AuthLoggingService::log_blocked_login`, mais le paramètre `hooks`
171 + vaut `None` depuis le service.
172 +
173 +### Migration
174 +
175 +1. **Sauvegarder** : `pg_dump` avant toute opération.
176 +2. `cargo run -p rustwarden-cli -- update-project` (ou vendor manuel) pour
177 + récupérer `rustwarden-core` / `rustwarden-ui` et les docs framework-owned
178 + (`docs/SERVICES_REFERENCE.md`, `docs/MIGRATION_GUIDE.md`).
179 +3. Aligner les pins de dépendances de `crates/rustwarden-core/Cargo.toml`
180 + (`sea-orm` / `sea-orm-migration` 2.0.0, `sea-bae = "0.2.2"`,
181 + `validator_derive = "0.20.1"`), puis `cargo update`.
182 +4. **Jouer la migration avant de déployer le binaire** :
183 + `cargo run --bin migrate` (ou `auto_migrate(true)` sur un déploiement
184 + mono-instance).
185 +5. `cargo clippy --workspace -- -D warnings` puis les smoke tests auth
186 + (login, register, changement d'e-mail, 2FA).
187 +6. Retirer les workarounds locaux devenus redondants : newtype `Username` maison,
188 + overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`,
189 + tout patch re-vérifiant `email_verified` après `authenticate_user`.
190 +
191 +Table **Avant / Après** par RFC, greps de repérage et pièges connus :
192 +[`docs/MIGRATION_GUIDE.md`](docs/MIGRATION_GUIDE.md)
193 +§ *2026-09-18 — RFC 0036-0040 (1.1.0)*.
194 +
8 195 ## [1.0.1] - 2026-09-18 — Security
9 196
10 197 Patch de sécurité (pas de nouvelle API publique). Fenêtre d'adoption **14 jours**
M Cargo.lock
+60 -16
@@ -1264,6 +1264,16 @@ dependencies = [
1264 1264 ]
1265 1265
1266 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]]
1267 1277 name = "darling_core"
1268 1278 version = "0.20.11"
1269 1279 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1292,6 +1302,19 @@ dependencies = [
1292 1302 ]
1293 1303
1294 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]]
1295 1318 name = "darling_macro"
1296 1319 version = "0.20.11"
1297 1320 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1314,6 +1337,17 @@ dependencies = [
1314 1337 ]
1315 1338
1316 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]]
1317 1351 name = "dashmap"
1318 1352 version = "6.1.0"
1319 1353 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -3579,25 +3613,25 @@ dependencies = [
3579 3613 ]
3580 3614
3581 3615 [[package]]
3582 -name = "proc-macro-error-attr2"
3583 -version = "2.0.0"
3616 +name = "proc-macro-error-attr3"
3617 +version = "3.1.1"
3584 3618 source = "registry+https://github.com/rust-lang/crates.io-index"
3585 -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
3619 +checksum = "9e564d14133360e1ae169ffde5da25881b5fa47261665b8e5713c212c27799da"
3586 3620 dependencies = [
3587 3621 "proc-macro2",
3588 3622 "quote",
3589 3623 ]
3590 3624
3591 3625 [[package]]
3592 -name = "proc-macro-error2"
3593 -version = "2.0.1"
3626 +name = "proc-macro-error3"
3627 +version = "3.1.1"
3594 3628 source = "registry+https://github.com/rust-lang/crates.io-index"
3595 -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
3629 +checksum = "8f0d4471b3436c22106b21913b1dda531558918ae9b7ec55d58aa84b43552233"
3596 3630 dependencies = [
3597 - "proc-macro-error-attr2",
3631 + "proc-macro-error-attr3",
3598 3632 "proc-macro2",
3599 3633 "quote",
3600 - "syn 2.0.117",
3634 + "syn 3.0.6",
3601 3635 ]
3602 3636
3603 3637 [[package]]
@@ -4238,6 +4272,7 @@ dependencies = [
4238 4272 "openidconnect",
4239 4273 "rand 0.9.2",
4240 4274 "reqwest 0.12.28",
4275 + "sea-bae",
4241 4276 "sea-orm",
4242 4277 "sea-orm-migration",
4243 4278 "serde",
@@ -4259,6 +4294,7 @@ dependencies = [
4259 4294 "urlencoding",
4260 4295 "uuid",
4261 4296 "validator",
4297 + "validator_derive",
4262 4298 "wiremock",
4263 4299 "yaml-rust2",
4264 4300 ]
@@ -4433,12 +4469,11 @@ checksum = "490dcfcbfef26be6800d11870ff2df8774fa6e86d047e3e8c8a76b25655e41ca"
4433 4469
4434 4470 [[package]]
4435 4471 name = "sea-bae"
4436 -version = "0.2.1"
4472 +version = "0.2.2"
4437 4473 source = "registry+https://github.com/rust-lang/crates.io-index"
4438 -checksum = "f694a6ab48f14bc063cfadff30ab551d3c7e46d8f81836c51989d548f44a2a25"
4474 +checksum = "260bbc7148a8d6818ac5032a1b9970da1a68bdd723d45b12d59f7c1bf3565e24"
4439 4475 dependencies = [
4440 4476 "heck 0.4.1",
4441 - "proc-macro-error2",
4442 4477 "proc-macro2",
4443 4478 "quote",
4444 4479 "syn 2.0.117",
@@ -5244,6 +5279,16 @@ dependencies = [
5244 5279 ]
5245 5280
5246 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]]
5247 5292 name = "sync_wrapper"
5248 5293 version = "1.0.2"
5249 5294 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -5914,13 +5959,12 @@ dependencies = [
5914 5959
5915 5960 [[package]]
5916 5961 name = "validator_derive"
5917 -version = "0.20.0"
5962 +version = "0.20.1"
5918 5963 source = "registry+https://github.com/rust-lang/crates.io-index"
5919 -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
5964 +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335"
5920 5965 dependencies = [
5921 - "darling 0.20.11",
5922 - "once_cell",
5923 - "proc-macro-error2",
5966 + "darling 0.23.0",
5967 + "proc-macro-error3",
5924 5968 "proc-macro2",
5925 5969 "quote",
5926 5970 "syn 2.0.117",
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/src/app.rs
+275 -9
@@ -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};
@@ -179,6 +180,10 @@ pub struct RustwardenBuilder {
179 180 /// Set by [`RustwardenBuilder::headless`]; flips the default auth-failure
180 181 /// mode to `Json401` when no explicit mode was requested.
181 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,
182 187 }
183 188
184 189 impl Default for RustwardenBuilder {
@@ -206,6 +211,7 @@ impl Default for RustwardenBuilder {
206 211 login_path: None,
207 212 auth_failure_mode: None,
208 213 headless: false,
214 + security_headers: SecurityHeadersConfig::default(),
209 215 }
210 216 }
211 217 }
@@ -244,6 +250,19 @@ impl RustwardenBuilder {
244 250 self
245 251 }
246 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 +
247 266 /// Merge additional custom routes into the **browser** family.
248 267 ///
249 268 /// Browser routes are session-cookie driven and therefore receive the
@@ -695,11 +714,23 @@ impl RustwardenBuilder {
695 714 let prefixes_for_auth = std::sync::Arc::clone(&protected_prefixes);
696 715 let revocation_checker = self.revocation_checker.clone();
697 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());
698 723
699 724 let app = router.with_state(db).layer(
700 725 ServiceBuilder::new()
701 726 .layer(cors_layer)
702 - .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 + }))
703 734 .layer_fn(move |service| AdminService::new(service, db_for_admin.clone()))
704 735 .layer_fn(move |service| {
705 736 AuthService::new(service, db_for_auth.clone(), prefixes_for_auth.clone())
@@ -850,7 +881,17 @@ impl RustwardenBuilder {
850 881 }
851 882
852 883 /// SEC-H1: Middleware that adds standard security headers to every response.
853 -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 {
854 895 let mut response = next.run(request).await;
855 896 let headers = response.headers_mut();
856 897 headers.insert(
@@ -874,13 +915,46 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
874 915 HeaderName::from_static("permissions-policy"),
875 916 HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
876 917 );
877 - // SEC-FIX: CSP header to mitigate XSS attacks
878 - headers.insert(
879 - HeaderName::from_static("content-security-policy"),
880 - HeaderValue::from_static(
881 - "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'"
882 - ),
883 - );
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 + }
884 958 // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE
885 959 if !std::env::var("APP_DEBUG")
886 960 .unwrap_or_default()
@@ -968,4 +1042,196 @@ mod builder_tests {
968 1042 let builder = RustwardenBuilder::default().register_protected_prefix("/custom");
969 1043 assert!(builder.protected_prefixes.iter().any(|p| p == "/custom"));
970 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 + }
971 1237 }
M crates/rustwarden-core/src/auth/handlers.rs
+19 -43
@@ -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,7 +280,7 @@ 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>,
@@ -307,38 +307,10 @@ pub async fn login(
307 307 payload.username
308 308 );
309 309
310 - // 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).
311 312 let user = AuthService::authenticate_user(&db, &payload.username, &payload.password).await?;
312 313
313 - let email_validation_required =
314 - crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
315 - &db,
316 - &["validation_email_required", "validation_email_requise"],
317 - true,
318 - )
319 - .await
320 - .unwrap_or(true);
321 -
322 - if email_validation_required && !user.email_verified {
323 - let reason = "email_not_verified";
324 - tracing::warn!(
325 - "Login attempt blocked (unverified email): {} ({})",
326 - user.username,
327 - user.email
328 - );
329 - // Journaliser sans stocker de secrets
330 - AuthLoggingService::log_blocked_login(
331 - &db,
332 - &user.email,
333 - reason,
334 - hooks.as_ref().map(|Extension(h)| h),
335 - )
336 - .await?;
337 - return Err(AppError::Validation(
338 - "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
339 - ));
340 - }
341 -
342 314 // Check if 2FA is forced globally
343 315 let totp_force_enabled =
344 316 crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
@@ -672,7 +644,7 @@ pub async fn verify_2fa(
672 644 /// ```json
673 645 /// {
674 646 /// "id": "550e8400-e29b-41d4-a716-446655440000",
675 -/// "username": "john_doe",
647 +/// "username": "john-doe",
676 648 /// "email": "john@example.com",
677 649 /// "roles": ["user"],
678 650 /// "created_at": "2024-01-01T00:00:00Z",
@@ -1371,7 +1343,7 @@ pub async fn resend_verification_by_username(
1371 1343 ///
1372 1344 /// ```json
1373 1345 /// {
1374 -/// "username": "john_doe",
1346 +/// "username": "john-doe",
1375 1347 /// "email": "john@example.com",
1376 1348 /// "password": "SecurePassword123!"
1377 1349 /// }
@@ -1383,7 +1355,7 @@ pub async fn resend_verification_by_username(
1383 1355 /// {
1384 1356 /// "message": "Account created successfully. Please check your email to verify your account.",
1385 1357 /// "user_id": "550e8400-e29b-41d4-a716-446655440000",
1386 -/// "username": "john_doe",
1358 +/// "username": "john-doe",
1387 1359 /// "email": "john@example.com"
1388 1360 /// }
1389 1361 /// ```
@@ -1419,6 +1391,10 @@ pub async fn register(
1419 1391 AppError::Validation(format!("Invalid registration request: {}", e))
1420 1392 })?;
1421 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 +
1422 1398 // SEC-H1: Validate password complexity from DB rules
1423 1399 crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password)
1424 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/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/todo.rs
+1 -1
@@ -184,7 +184,7 @@ mod tests {
184 184 existing.route = "/custom/2fa".into(); // route éditée par le dev
185 185 // "desired" = catalogue (route suggérée par défaut), même clé (func+screen)
186 186 let desired = row();
187 - let merged = merge(&[existing.clone()], &[desired.clone()]);
187 + let merged = merge(&[existing.clone()], std::slice::from_ref(&desired));
188 188 assert_eq!(merged.len(), 1);
189 189 assert!(merged[0].done, "statut conservé");
190 190 assert_eq!(merged[0].route, "/custom/2fa", "route éditée conservée");
M crates/rustwarden-core/src/config/admin.rs
+4 -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 }
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
+2 -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;
@@ -64,4 +65,5 @@ pub mod prelude {
64 65 pub use crate::services::user_service::UserService;
65 66 pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor;
66 67 pub use crate::types::TeamSlug;
68 + pub use crate::types::Username;
67 69 }
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 +}
M crates/rustwarden-core/src/migrations/mod.rs
+3 -0
@@ -49,6 +49,8 @@ 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;
52 54
53 55 use sea_orm_migration::prelude::*;
54 56
@@ -64,6 +66,7 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
64 66 Box::new(m20260422_000007_create_personal_access_tokens::Migration),
65 67 Box::new(m20260423_000008_create_teams::Migration),
66 68 Box::new(m20260510_000009_fediverse_interop::Migration),
69 + Box::new(m20260918_000010_totp_last_used_step::Migration),
67 70 ]
68 71 }
69 72
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
+4 -1
@@ -67,7 +67,10 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
67 67 validate_new_admin_credentials(&config, password)?;
68 68
69 69 let password_hash = PasswordService::hash_password(password).await?;
70 - 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(
71 74 db,
72 75 config.username.clone(),
73 76 config.email.clone(),
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
+5 -1
@@ -499,7 +499,11 @@ mod tests {
499 499 for txn in db.into_transaction_log() {
500 500 for stmt in txn.statements() {
501 501 assert!(
502 - !stmt.sql.trim_start().to_ascii_uppercase().starts_with("UPDATE"),
502 + !stmt
503 + .sql
504 + .trim_start()
505 + .to_ascii_uppercase()
506 + .starts_with("UPDATE"),
503 507 "rejected password must not consume the token (used_at stays NULL): {}",
504 508 stmt.sql
505 509 );
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/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
+366 -4
@@ -8,7 +8,11 @@ use std::sync::{Mutex, OnceLock};
8 8 use std::time::Instant;
9 9
10 10 use chrono::{Duration, Utc};
11 -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 +};
12 16 use totp_rs::{Algorithm, Secret, TOTP};
13 17 use uuid::Uuid;
14 18
@@ -193,6 +197,7 @@ impl TotpService {
193 197 backup_codes_encrypted: Set(None),
194 198 backup_codes_remaining: Set(0),
195 199 enabled_at: Set(None),
200 + last_used_step: NotSet,
196 201 created_at: Set(now),
197 202 updated_at: Set(now),
198 203 };
@@ -208,6 +213,11 @@ impl TotpService {
208 213 /// Verify a TOTP code during setup and enable 2FA.
209 214 ///
210 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).
211 221 pub async fn verify_and_enable(
212 222 db: &DatabaseConnection,
213 223 user_id: Uuid,
@@ -223,9 +233,16 @@ impl TotpService {
223 233 return Err(AppError::TotpAlreadyEnabled);
224 234 }
225 235
226 - // 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`).
227 238 let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?;
228 - 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? {
229 246 return Err(AppError::TotpFailed);
230 247 }
231 248
@@ -246,6 +263,13 @@ impl TotpService {
246 263 }
247 264
248 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.
249 273 pub async fn verify_code(
250 274 db: &DatabaseConnection,
251 275 user_id: Uuid,
@@ -262,6 +286,14 @@ impl TotpService {
262 286 let Some(step) = matching_totp_step(&secret_base32, code) else {
263 287 return Ok(false);
264 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.
265 297 if !totp_replay_guard_accept(user_id, step) {
266 298 return Ok(false);
267 299 }
@@ -492,7 +524,46 @@ impl TotpService {
492 524
493 525 // --- Internal helpers ---
494 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 +
495 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)]
496 567 fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> {
497 568 Ok(matching_totp_step(secret_base32, code).is_some())
498 569 }
@@ -619,7 +690,7 @@ mod tests {
619 690 #[test]
620 691 fn same_step_replay_is_rejected() {
621 692 // Dedicated id so parallel tests cannot collide on Uuid::nil().
622 - let uid = Uuid::from_u128(0x_70_70_51_E0_1A_7_u128);
693 + let uid = Uuid::from_u128(0x0707_051E_01A7_u128);
623 694 assert!(totp_replay_guard_accept(uid, 42));
624 695 assert!(!totp_replay_guard_accept(uid, 42));
625 696 assert!(totp_replay_guard_accept(uid, 43));
@@ -700,4 +771,295 @@ mod tests {
700 771 "mismatched backup code should not verify"
701 772 );
702 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 + }
703 1065 }
M crates/rustwarden-core/src/services/user_service.rs
+728 -25
@@ -9,6 +9,21 @@ use sea_orm::{
9 9 };
10 10 use uuid::Uuid;
11 11
12 +/// Mask an email address for log output (e.g. `jo***@ex***`).
13 +///
14 +/// Keeps at most the first two characters of the local part and of the
15 +/// domain so log lines never contain a full address (SIEM-friendly).
16 +fn mask_email(email: &str) -> String {
17 + match email.split_once('@') {
18 + Some((local, domain)) => {
19 + let local_visible: String = local.chars().take(2).collect();
20 + let domain_visible: String = domain.chars().take(2).collect();
21 + format!("{local_visible}***@{domain_visible}***")
22 + }
23 + None => "***".to_string(),
24 + }
25 +}
26 +
12 27 /// Service for managing users
13 28 ///
14 29 /// This service provides business logic for user operations,
@@ -16,20 +31,60 @@ use uuid::Uuid;
16 31 pub struct UserService;
17 32
18 33 impl UserService {
19 - /// Create a new user with the given details
34 + /// Create a new user with the given details.
35 + ///
36 + /// Enforces [`crate::types::Username::parse`] (strict): charset
37 + /// `[a-z0-9-]`, length 3–39, no consecutive `--`, no leading/trailing `-`,
38 + /// and reserved names rejected. This is the single validation gate for
39 + /// every caller.
40 + ///
41 + /// For the bootstrap path where `"admin"` (a reserved name) must be
42 + /// accepted, use the crate-private `create_user_allow_reserved`.
20 43 ///
21 44 /// # Arguments
22 45 ///
23 46 /// * `db` - Database connection
24 - /// * `username` - Username (must be unique)
47 + /// * `username` - Desired username; validated by [`crate::types::Username::parse`]
25 48 /// * `email` - Email address (must be unique)
26 49 /// * `password_hash` - Pre-hashed password (use bcrypt)
27 50 /// * `must_change_password` - Whether user must change password on next login
51 + pub async fn create_user(
52 + db: &DatabaseConnection,
53 + username: String,
54 + email: String,
55 + password_hash: String,
56 + must_change_password: bool,
57 + ) -> Result<user::Model, AppError> {
58 + let username = crate::types::Username::parse(&username)?.into_inner();
59 + Self::do_insert_user(db, username, email, password_hash, must_change_password).await
60 + }
61 +
62 + /// Bootstrap-only path: create a user whose username may be reserved.
28 63 ///
29 - /// # Returns
64 + /// Identical to [`UserService::create_user`] except it calls
65 + /// [`Username::parse_allow_reserved`], so names such as `"admin"` are
66 + /// accepted. All charset and length rules still apply.
30 67 ///
31 - /// The created user model
32 - pub async fn create_user(
68 + /// **Only `ensure_default_admin` should call this function.** Every
69 + /// other caller must use [`UserService::create_user`] which enforces
70 + /// the full reserved-name policy.
71 + pub(crate) async fn create_user_allow_reserved(
72 + db: &DatabaseConnection,
73 + username: String,
74 + email: String,
75 + password_hash: String,
76 + must_change_password: bool,
77 + ) -> Result<user::Model, AppError> {
78 + let username = crate::types::Username::parse_allow_reserved(&username)?.into_inner();
79 + Self::do_insert_user(db, username, email, password_hash, must_change_password).await
80 + }
81 +
82 + /// Internal DB insert shared by [`create_user`] and
83 + /// [`create_user_allow_reserved`].
84 + ///
85 + /// The caller is responsible for pre-validating `username` via the
86 + /// appropriate `Username::parse*` variant before calling this.
87 + async fn do_insert_user(
33 88 db: &DatabaseConnection,
34 89 username: String,
35 90 email: String,
@@ -154,6 +209,34 @@ impl UserService {
154 209 /// Update user profile
155 210 ///
156 211 /// Allows updating username and/or email with validation for uniqueness.
212 + ///
213 + /// # Email revalidation (RFC 0038)
214 + ///
215 + /// Changing the email address clears verification: when the new address
216 + /// (exact comparison after trim) differs from the stored one,
217 + /// `email_verified` is set to `false` and `email_verified_at` to `NULL`
218 + /// in the same update. A validation email is then queued for the **new**
219 + /// address (previous validation tokens are invalidated) and an
220 + /// `account_modified` alert — without any validation link — is queued for
221 + /// the **old** address. Both notifications are best-effort: if the email
222 + /// configuration is unavailable
223 + /// ([`EmailConfig::from_env`](crate::config::email::EmailConfig::from_env)
224 + /// fails), they are skipped with a warning and the database update still
225 + /// succeeds; the user can request a new link via
226 + /// `POST /api/v1/auth/resend-verification`.
227 + ///
228 + /// Echoing the current email is a no-op for the verification flags, and a
229 + /// username-only rename never triggers revalidation.
230 + ///
231 + /// ```rust,ignore
232 + /// use rustwarden_core::services::user_service::UserService;
233 + ///
234 + /// // Changing email clears verification until the new address is confirmed.
235 + /// let user =
236 + /// UserService::update_profile(&db, user_id, None, Some("new@example.com".into())).await?;
237 + /// assert!(!user.email_verified);
238 + /// assert!(user.email_verified_at.is_none());
239 + /// ```
157 240 pub async fn update_profile(
158 241 db: &DatabaseConnection,
159 242 user_id: Uuid,
@@ -166,43 +249,55 @@ impl UserService {
166 249
167 250 let mut updated_user: user::ActiveModel = user.clone().into();
168 251 let mut changes = false;
252 + // RFC 0038: previous address, `Some` only when the email actually changes.
253 + let mut previous_email: Option<String> = None;
169 254
170 255 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 - )));
256 + let trimmed = new_username.trim();
257 + // Only enforce charset + reserved on an actual rename.
258 + // If the client echoes back the current username (including
259 + // grandfathered out-of-charset handles), treat it as a no-op.
260 + if trimmed != user.username.as_str() {
261 + let parsed = crate::types::Username::parse(trimmed)?;
262 + if let Some(existing_user) = Self::get_user_by_username(db, parsed.as_str()).await?
263 + {
264 + if existing_user.id != user_id {
265 + return Err(AppError::Validation(format!(
266 + "Username '{}' already exists",
267 + parsed.as_str()
268 + )));
269 + }
183 270 }
271 + updated_user.username = Set(parsed.into_inner());
272 + changes = true;
184 273 }
185 -
186 - updated_user.username = Set(new_username.clone());
187 - changes = true;
274 + // Same username: no-op — grandfathering preserved.
188 275 }
189 276
190 277 if let Some(new_email) = &email {
191 - if !new_email.contains('@') || !new_email.contains('.') {
278 + let trimmed = new_email.trim();
279 + if !trimmed.contains('@') || !trimmed.contains('.') {
192 280 return Err(AppError::Validation("Invalid email format".to_string()));
193 281 }
194 282
195 - if let Some(existing_user) = Self::get_user_by_email(db, new_email).await? {
283 + if let Some(existing_user) = Self::get_user_by_email(db, trimmed).await? {
196 284 if existing_user.id != user_id {
197 285 return Err(AppError::Validation(format!(
198 286 "Email '{}' already exists",
199 - new_email
287 + trimmed
200 288 )));
201 289 }
202 290 }
203 291
204 - updated_user.email = Set(new_email.clone());
205 - changes = true;
292 + // RFC 0038: exact comparison after trim. Echoing the current
293 + // address is a no-op (verification flags untouched, no emails).
294 + if trimmed != user.email.as_str() {
295 + updated_user.email = Set(trimmed.to_string());
296 + updated_user.email_verified = Set(false);
297 + updated_user.email_verified_at = Set(None);
298 + previous_email = Some(user.email.clone());
299 + changes = true;
300 + }
206 301 }
207 302
208 303 if !changes {
@@ -212,10 +307,117 @@ impl UserService {
212 307 updated_user.updated_at = Set(Utc::now());
213 308 let user = updated_user.update(db).await?;
214 309
310 + if let Some(old_email) = previous_email {
311 + Self::notify_email_change(db, &user, &old_email).await;
312 + }
313 +
215 314 tracing::info!("Profile updated for user {}", user_id);
216 315 Ok(user)
217 316 }
218 317
318 + /// Best-effort notifications after an email change (RFC 0038).
319 + ///
320 + /// Queues a validation email for the new address (the validation service
321 + /// invalidates previous tokens itself) and an `account_modified` alert —
322 + /// without any validation link — for the old address. This never fails:
323 + /// the profile update already succeeded, so every error here is only
324 + /// logged. When the email configuration is unavailable both notifications
325 + /// are skipped and the user can rely on
326 + /// `POST /api/v1/auth/resend-verification`.
327 + async fn notify_email_change(db: &DatabaseConnection, user: &user::Model, old_email: &str) {
328 + // SIEM trail: never log full addresses.
329 + tracing::warn!(
330 + user_id = %user.id,
331 + old_email = %mask_email(old_email),
332 + new_email = %mask_email(&user.email),
333 + "Email address changed; verification reset until the new address is validated"
334 + );
335 +
336 + let email_config = match crate::config::email::EmailConfig::from_env() {
337 + Ok(config) => config,
338 + Err(e) => {
339 + tracing::warn!(
340 + user_id = %user.id,
341 + "Email configuration unavailable ({}); validation and alert emails skipped after email change",
342 + e
343 + );
344 + return;
345 + }
346 + };
347 +
348 + // Validation email to the NEW address (same helper as `register`).
349 + use crate::services::email_validation_service::EmailValidationService;
350 + let validation_service = EmailValidationService::new(email_config.clone());
351 + if let Err(e) = validation_service.send_validation_email(db, user.id).await {
352 + tracing::warn!(
353 + user_id = %user.id,
354 + "Failed to queue validation email after email change: {}",
355 + e
356 + );
357 + }
358 +
359 + // Alert to the OLD address (reuses the account_modified template,
360 + // which carries no validation link).
361 + use crate::templates::email_templates::{AccountChange, AccountModifiedTemplate};
362 + let app_name = email_config
363 + .smtp_from_name
364 + .clone()
365 + .unwrap_or_else(crate::config::app::default_app_name);
366 + // Same locale pattern as `EmailValidationService::send_validation_email`.
367 + let locale = Some("fr".to_string()); // TODO: Get from user preferences or Accept-Language header
368 + let template = AccountModifiedTemplate::new(
369 + locale,
370 + user.username.clone(),
371 + Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(),
372 + vec![AccountChange {
373 + field: "Email".to_string(),
374 + old_value: Some(old_email.to_string()),
375 + new_value: user.email.clone(),
376 + }],
377 + Some(app_name),
378 + );
379 + let subject = match template.locale.as_str() {
380 + "fr" => "Compte modifié",
381 + "de" => "Konto geändert",
382 + "es" => "Cuenta modificada",
383 + _ => "Account Modified",
384 + }
385 + .to_string();
386 + let body_html = match template.render_html() {
387 + Ok(html) => html,
388 + Err(e) => {
389 + tracing::warn!(
390 + user_id = %user.id,
391 + "Failed to render account-modified template after email change: {}",
392 + e
393 + );
394 + return;
395 + }
396 + };
397 + let body_text = template.render_text();
398 +
399 + use crate::services::email_queue_service::EmailQueueService;
400 + if let Err(e) = EmailQueueService::enqueue(
401 + db,
402 + old_email.to_string(),
403 + Some(user.username.clone()),
404 + email_config.smtp_from.clone(),
405 + email_config.smtp_from_name.clone(),
406 + subject,
407 + body_html,
408 + body_text,
409 + "account_modified".to_string(),
410 + )
411 + .await
412 + {
413 + tracing::warn!(
414 + user_id = %user.id,
415 + "Failed to queue email-change alert to previous address: {}",
416 + e
417 + );
418 + }
419 + }
420 +
219 421 /// Change user password
220 422 ///
221 423 /// Validates old password, checks new password strength, and creates a pending
@@ -479,3 +681,504 @@ impl UserService {
479 681 Ok(())
480 682 }
481 683 }
684 +
685 +#[cfg(test)]
686 +mod tests {
687 + use super::*; // UserService, AppError, Uuid, Utc, user, Set/NotSet, ModelTrait, …
688 + use crate::models::email_queue;
689 +
690 + async fn test_db() -> DatabaseConnection {
691 + dotenvy::dotenv().ok();
692 + let url =
693 + std::env::var("DATABASE_URL").expect("DATABASE_URL required for user_service tests");
694 + sea_orm::Database::connect(&url).await.unwrap()
695 + }
696 +
697 + async fn dummy_hash() -> String {
698 + PasswordService::hash_password("TestPassword1!")
699 + .await
700 + .unwrap()
701 + }
702 +
703 + // ── create_user: invalid charset ────────────────────────────────────────
704 +
705 + /// `create_user` must reject the username before any INSERT.
706 + #[tokio::test]
707 + async fn create_user_invalid_charset_returns_validation_error() {
708 + let db = test_db().await;
709 + let result = UserService::create_user(
710 + &db,
711 + "x`id`y".to_string(),
712 + "backtick-test@example.com".to_string(),
713 + "irrelevant-hash".to_string(),
714 + false,
715 + )
716 + .await;
717 + assert!(
718 + matches!(result, Err(AppError::Validation(_))),
719 + "backtick username must be rejected before any DB insert"
720 + );
721 + let loaded = UserService::get_user_by_username(&db, "x`id`y")
722 + .await
723 + .unwrap();
724 + assert!(
725 + loaded.is_none(),
726 + "no row should exist after a rejected create_user"
727 + );
728 + }
729 +
730 + // ── create_user: happy path ──────────────────────────────────────────────
731 +
732 + #[tokio::test]
733 + async fn create_user_valid_username_succeeds() {
734 + let db = test_db().await;
735 + let unique = Uuid::new_v4().simple().to_string();
736 + let username = format!("alice-{}", &unique[..8]);
737 + let result = UserService::create_user(
738 + &db,
739 + username.clone(),
740 + format!("alice-{}@example.com", &unique[..8]),
741 + dummy_hash().await,
742 + false,
743 + )
744 + .await;
745 + assert!(
746 + result.is_ok(),
747 + "valid username must succeed: {:?}",
748 + result.err()
749 + );
750 + let created = result.unwrap();
751 + assert_eq!(created.username, username);
752 + // Non-regression (RFC 0038): new accounts always start unverified.
753 + assert!(
754 + !created.email_verified,
755 + "create_user must set email_verified=false"
756 + );
757 + assert!(
758 + created.email_verified_at.is_none(),
759 + "create_user must leave email_verified_at unset"
760 + );
761 + }
762 +
763 + // ── create_user: reserved name rejected by strict gate ──────────────────
764 +
765 + #[tokio::test]
766 + async fn create_user_reserved_name_returns_validation_error() {
767 + let db = test_db().await;
768 + let unique = Uuid::new_v4().simple().to_string();
769 + let result = UserService::create_user(
770 + &db,
771 + "admin".to_string(),
772 + format!("admin-strict-{}@example.com", &unique[..8]),
773 + "irrelevant-hash".to_string(),
774 + false,
775 + )
776 + .await;
777 + assert!(
778 + matches!(result, Err(AppError::Validation(_))),
779 + "'admin' must be rejected by strict create_user"
780 + );
781 + }
782 +
783 + // ── create_user_allow_reserved: bootstrap accepts reserved name ──────────
784 +
785 + #[tokio::test]
786 + async fn create_user_allow_reserved_accepts_admin() {
787 + let db = test_db().await;
788 + let unique = Uuid::new_v4().simple().to_string();
789 + // If an "admin" user already exists (real deploy data or a previous
790 + // test run), skip rather than deleting it — tests must never destroy
791 + // data they did not create (FK cascade would wipe teams, PAT, etc.).
792 + if UserService::get_user_by_username(&db, "admin")
793 + .await
794 + .unwrap()
795 + .is_some()
796 + {
797 + return;
798 + }
799 + let result = UserService::create_user_allow_reserved(
800 + &db,
801 + "admin".to_string(),
802 + format!("admin-bootstrap-{}@example.com", &unique[..8]),
803 + dummy_hash().await,
804 + false,
805 + )
806 + .await;
807 + assert!(
808 + result.is_ok(),
809 + "bootstrap path must accept reserved name 'admin': {:?}",
810 + result.err()
811 + );
812 + let created = result.unwrap();
813 + assert_eq!(created.username, "admin");
814 + // Clean up only the row this test created (by the id we hold).
815 + created.delete(&db).await.unwrap();
816 + }
817 +
818 + // ── grandfathering: pre-RFC accounts must remain usable ─────────────────
819 +
820 + #[tokio::test]
821 + async fn grandfather_load_and_profile_update_work() {
822 + let db = test_db().await;
823 + let unique = Uuid::new_v4().simple().to_string();
824 + // Use an underscore username to simulate a pre-RFC (grandfathered) account.
825 + let legacy_username = format!("john_doe_{}", &unique[..8]);
826 + let legacy_id = Uuid::new_v4();
827 + let now = Utc::now();
828 +
829 + // Insert directly, bypassing Username::parse (simulates a pre-RFC row).
830 + user::ActiveModel {
831 + id: Set(legacy_id),
832 + username: Set(legacy_username.clone()),
833 + email: Set(format!("johndoe-{}@legacy.example.com", &unique[..8])),
834 + password_hash: Set("hashed".to_string()),
835 + must_change_password: Set(false),
836 + last_login_at: NotSet,
837 + last_login_ip: NotSet,
838 + password_changed_at: NotSet,
839 + email_verified: Set(false),
840 + email_verified_at: NotSet,
841 + created_at: Set(now),
842 + updated_at: Set(now),
843 + ap_public_key_pem: NotSet,
844 + ap_private_key_pem: NotSet,
845 + ap_discoverable: NotSet,
846 + }
847 + .insert(&db)
848 + .await
849 + .unwrap();
850 +
851 + // Grandfathered account must still be loadable.
852 + let loaded = UserService::get_user_by_username(&db, &legacy_username)
853 + .await
854 + .unwrap();
855 + assert!(loaded.is_some(), "grandfathered user must be loadable");
856 +
857 + // Email-only update must not touch or re-validate the username.
858 + let new_email = format!("updated-{}@example.com", &unique[..8]);
859 + let updated = UserService::update_profile(&db, legacy_id, None, Some(new_email.clone()))
860 + .await
861 + .unwrap();
862 + assert_eq!(
863 + updated.username, legacy_username,
864 + "username must be unchanged after email-only update"
865 + );
866 + assert_eq!(updated.email, new_email);
867 +
868 + // Echoing the same out-of-charset username (form POST round-trip) is a no-op.
869 + let result =
870 + UserService::update_profile(&db, legacy_id, Some(legacy_username.clone()), None).await;
871 + assert!(
872 + result.is_ok(),
873 + "echoing same out-of-charset username must succeed (grandfathering no-op)"
874 + );
875 + assert_eq!(
876 + result.unwrap().username,
877 + legacy_username,
878 + "username must still be unchanged after echo no-op"
879 + );
880 + }
881 +
882 + // ── rename validation ────────────────────────────────────────────────────
883 +
884 + #[tokio::test]
885 + async fn rename_invalid_or_reserved_is_rejected() {
886 + let db = test_db().await;
887 + let unique = Uuid::new_v4().simple().to_string();
888 + let username = format!("renametest-{}", &unique[..8]);
889 + let email = format!("renametest-{}@example.com", &unique[..8]);
890 + let user_model = UserService::create_user(
891 + &db,
892 + username.clone(),
893 + email.clone(),
894 + dummy_hash().await,
895 + false,
896 + )
897 + .await
898 + .unwrap();
899 +
900 + // RFC 0038: mark verified so the username-only rename below can prove
901 + // it never touches the verification flags.
902 + let mut active: user::ActiveModel = user_model.clone().into();
903 + active.email_verified = Set(true);
904 + active.email_verified_at = Set(Some(Utc::now()));
905 + active.update(&db).await.unwrap();
906 +
907 + // Uppercase → Validation error.
908 + assert!(
909 + matches!(
910 + UserService::update_profile(&db, user_model.id, Some("Not-Valid".into()), None)
911 + .await,
912 + Err(AppError::Validation(_))
913 + ),
914 + "uppercase username must be rejected on rename"
915 + );
916 +
917 + // Reserved name → Validation error.
918 + assert!(
919 + matches!(
920 + UserService::update_profile(&db, user_model.id, Some("api".into()), None).await,
921 + Err(AppError::Validation(_))
922 + ),
923 + "reserved name 'api' must be rejected on rename"
924 + );
925 +
926 + // Valid rename → accepted.
927 + let new_name = format!("renamed-{}", &unique[..8]);
928 + let renamed = UserService::update_profile(&db, user_model.id, Some(new_name.clone()), None)
929 + .await
930 + .unwrap();
931 + assert_eq!(renamed.username, new_name, "valid rename must succeed");
932 +
933 + // RFC 0038: a username-only rename must leave the email and its
934 + // verification flags intact, and must not queue any email.
935 + assert_eq!(renamed.email, email, "email must be unchanged after rename");
936 + assert!(
937 + renamed.email_verified,
938 + "username-only rename must not clear email_verified"
939 + );
940 + assert!(
941 + renamed.email_verified_at.is_some(),
942 + "username-only rename must not clear email_verified_at"
943 + );
944 + assert!(
945 + queue_entries_for(&db, &email).await.is_empty(),
946 + "username-only rename must not enqueue any email from the service"
947 + );
948 + }
949 +
950 + // ── RFC 0038: email change revalidation ─────────────────────────────────
951 +
952 + /// Return every `email_queue` row addressed to `to_email`.
953 + async fn queue_entries_for(db: &DatabaseConnection, to_email: &str) -> Vec<email_queue::Model> {
954 + email_queue::Entity::find()
955 + .filter(email_queue::Column::ToEmail.eq(to_email))
956 + .all(db)
957 + .await
958 + .unwrap()
959 + }
960 +
961 + /// Create a user with a unique name/address and mark it email-verified.
962 + async fn create_verified_user(db: &DatabaseConnection, prefix: &str) -> (user::Model, String) {
963 + let unique = Uuid::new_v4().simple().to_string();
964 + let email = format!("{}-{}@example.com", prefix, &unique[..8]);
965 + let created = UserService::create_user(
966 + db,
967 + format!("{}-{}", prefix, &unique[..8]),
968 + email.clone(),
969 + dummy_hash().await,
970 + false,
971 + )
972 + .await
973 + .unwrap();
974 +
975 + // Simulate a clicked validation link.
976 + let mut active: user::ActiveModel = created.clone().into();
977 + active.email_verified = Set(true);
978 + active.email_verified_at = Set(Some(Utc::now()));
979 + let verified = active.update(db).await.unwrap();
980 + (verified, email)
981 + }
982 +
983 + /// Happy path: changing the email clears verification and queues both
984 + /// the validation email (new address) and the alert (old address).
985 + ///
986 + /// SMTP note: this repo's `.env` (loaded by `test_db`) provides the four
987 + /// variables `EmailConfig::from_env()` requires, so the queue asserts run
988 + /// in the branch where the config is available. If the local environment
989 + /// has no SMTP configuration, RFC 0038 mandates that the update still
990 + /// succeeds with the flags reset — the `else` branch asserts exactly
991 + /// that (0 queue entries, flags reset).
992 + #[tokio::test]
993 + async fn email_change_resets_verification_and_queues_emails() {
994 + let db = test_db().await;
995 + let unique = Uuid::new_v4().simple().to_string();
996 + let (user_model, old_email) = create_verified_user(&db, "emailchg").await;
997 + let new_email = format!("emailchg-new-{}@example.com", &unique[..8]);
998 +
999 + let updated =
1000 + UserService::update_profile(&db, user_model.id, None, Some(new_email.clone()))
1001 + .await
1002 + .unwrap();
1003 +
1004 + assert_eq!(updated.email, new_email);
1005 + assert!(
1006 + !updated.email_verified,
1007 + "changing email must clear email_verified"
1008 + );
1009 + assert!(
1010 + updated.email_verified_at.is_none(),
1011 + "changing email must clear email_verified_at"
1012 + );
1013 +
1014 + let validation_entries = queue_entries_for(&db, &new_email).await;
1015 + let alert_entries = queue_entries_for(&db, &old_email).await;
1016 + if crate::config::email::EmailConfig::from_env().is_ok() {
1017 + assert_eq!(
1018 + validation_entries.len(),
1019 + 1,
1020 + "exactly one validation email must target the new address"
1021 + );
1022 + let validation = &validation_entries[0];
1023 + assert_eq!(validation.email_type, "email_validation");
1024 + assert_eq!(validation.status, "pending");
1025 + assert_eq!(
1026 + validation.to_name.as_deref(),
1027 + Some(user_model.username.as_str())
1028 + );
1029 +
1030 + assert_eq!(
1031 + alert_entries.len(),
1032 + 1,
1033 + "exactly one alert must target the old address"
1034 + );
1035 + let alert = &alert_entries[0];
1036 + assert_eq!(alert.email_type, "account_modified");
1037 + assert_eq!(alert.status, "pending");
1038 + // The alert must never carry the validation link.
1039 + assert!(
1040 + !alert.body_html.contains("/verify-email/"),
1041 + "alert HTML body must not contain the validation link"
1042 + );
1043 + assert!(
1044 + !alert.body_text.contains("/verify-email/"),
1045 + "alert text body must not contain the validation link"
1046 + );
1047 + } else {
1048 + // Best-effort contract: no SMTP config → no queue entries, but
1049 + // the DB update above already succeeded with the flags reset.
1050 + eprintln!("SMTP absent: asserts de queue sautés (EmailConfig::from_env() en échec)");
1051 + assert!(validation_entries.is_empty());
1052 + assert!(alert_entries.is_empty());
1053 + }
1054 +
1055 + // Clean up only rows created by this test.
1056 + for entry in validation_entries.into_iter().chain(alert_entries) {
1057 + entry.delete(&db).await.unwrap();
1058 + }
1059 + // Cascade removes this user's email_validation_token rows.
1060 + updated.delete(&db).await.unwrap();
1061 + }
1062 +
1063 + /// Invalid format is rejected before any write or enqueue.
1064 + #[tokio::test]
1065 + async fn email_change_invalid_format_rejected_without_side_effects() {
1066 + let db = test_db().await;
1067 + let (user_model, email) = create_verified_user(&db, "emailfmt").await;
1068 +
1069 + let result =
1070 + UserService::update_profile(&db, user_model.id, None, Some("not-an-email".into()))
1071 + .await;
1072 + assert!(
1073 + matches!(result, Err(AppError::Validation(_))),
1074 + "invalid email format must be rejected"
1075 + );
1076 +
1077 + let reloaded = UserService::get_user_by_id(&db, user_model.id)
1078 + .await
1079 + .unwrap()
1080 + .unwrap();
1081 + assert_eq!(reloaded.email, email, "email must be unchanged");
1082 + assert!(reloaded.email_verified, "flags must be untouched");
1083 + assert!(reloaded.email_verified_at.is_some());
1084 + assert!(
1085 + queue_entries_for(&db, "not-an-email").await.is_empty(),
1086 + "no email must be queued on a rejected update"
1087 + );
1088 + assert!(
1089 + queue_entries_for(&db, &email).await.is_empty(),
1090 + "no alert must be queued on a rejected update"
1091 + );
1092 +
1093 + reloaded.delete(&db).await.unwrap();
1094 + }
1095 +
1096 + /// Changing to an address already owned by another user is rejected
1097 + /// without un-verifying the caller.
1098 + #[tokio::test]
1099 + async fn email_change_to_taken_address_rejected_without_unverify() {
1100 + let db = test_db().await;
1101 + let (owner, taken_email) = create_verified_user(&db, "emailowner").await;
1102 + let (caller, caller_email) = create_verified_user(&db, "emailcaller").await;
1103 +
1104 + let result =
1105 + UserService::update_profile(&db, caller.id, None, Some(taken_email.clone())).await;
1106 + assert!(
1107 + matches!(result, Err(AppError::Validation(_))),
1108 + "taken email must be rejected"
1109 + );
1110 +
1111 + let reloaded = UserService::get_user_by_id(&db, caller.id)
1112 + .await
1113 + .unwrap()
1114 + .unwrap();
1115 + assert_eq!(
1116 + reloaded.email, caller_email,
1117 + "caller email must be unchanged"
1118 + );
1119 + assert!(
1120 + reloaded.email_verified,
1121 + "caller must not be un-verified by a rejected change"
1122 + );
1123 + assert!(reloaded.email_verified_at.is_some());
1124 + assert!(
1125 + queue_entries_for(&db, &taken_email).await.is_empty(),
1126 + "no email must be queued on a rejected update"
1127 + );
1128 +
1129 + reloaded.delete(&db).await.unwrap();
1130 + owner.delete(&db).await.unwrap();
1131 + }
1132 +
1133 + /// Echoing the current address (exact match after trim) is a no-op:
1134 + /// flags stay verified and nothing is queued.
1135 + #[tokio::test]
1136 + async fn email_echo_same_address_is_noop() {
1137 + let db = test_db().await;
1138 + let (user_model, email) = create_verified_user(&db, "emailnoop").await;
1139 +
1140 + // Exact echo.
1141 + let echoed = UserService::update_profile(&db, user_model.id, None, Some(email.clone()))
1142 + .await
1143 + .unwrap();
1144 + assert_eq!(echoed.email, email);
1145 + assert!(
1146 + echoed.email_verified,
1147 + "echoing the same email must keep email_verified=true"
1148 + );
1149 + assert!(echoed.email_verified_at.is_some());
1150 +
1151 + // Whitespace-padded echo: trimmed comparison → still a no-op.
1152 + let padded =
1153 + UserService::update_profile(&db, user_model.id, None, Some(format!(" {} ", email)))
1154 + .await
1155 + .unwrap();
1156 + assert_eq!(
1157 + padded.email, email,
1158 + "padded echo must not alter the stored email"
1159 + );
1160 + assert!(
1161 + padded.email_verified,
1162 + "padded echo must keep email_verified=true"
1163 + );
1164 + assert!(padded.email_verified_at.is_some());
1165 +
1166 + assert!(
1167 + queue_entries_for(&db, &email).await.is_empty(),
1168 + "no email must be queued on a no-op echo"
1169 + );
1170 +
1171 + padded.delete(&db).await.unwrap();
1172 + }
1173 +
1174 + // ── mask_email ───────────────────────────────────────────────────────────
1175 +
1176 + #[test]
1177 + fn mask_email_never_leaks_full_address() {
1178 + assert_eq!(mask_email("john.doe@example.com"), "jo***@ex***");
1179 + assert_eq!(mask_email("a@b.c"), "a***@b.***");
1180 + assert_eq!(mask_email("no-at-sign"), "***");
1181 + // Multi-byte local part must not panic.
1182 + assert_eq!(mask_email("émile@exemple.fr"), "ém***@ex***");
1183 + }
1184 +}
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 +}
M crates/rustwarden-ui/locales/de.yml
+4 -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"
M crates/rustwarden-ui/locales/en.yml
+4 -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"
M crates/rustwarden-ui/locales/es.yml
+5 -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"
M crates/rustwarden-ui/locales/fr.yml
+5 -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"
M crates/rustwarden-ui/locales/it.yml
+5 -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"
M crates/rustwarden-ui/locales/pt.yml
+5 -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"
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,
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/settings/profile.rs
+21 -61
@@ -40,8 +40,12 @@ pub async fn update_profile(
40 40
41 41 let old_email = old_user.email.clone();
42 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);
43 + // RFC 0038: same trim rule as the core comparison, so a padded echo of
44 + // the current address does not invalidate pending password changes.
45 + let email_was_changed = new_email
46 + .as_ref()
47 + .map(|e| e.trim() != old_email)
48 + .unwrap_or(false);
45 49
46 50 let updated_user =
47 51 UserService::update_profile(&db, claims.sub, request.username, request.email).await?;
@@ -63,32 +67,25 @@ pub async fn update_profile(
63 67 }
64 68 }
65 69
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);
70 + // RFC 0038: when the email changes, `UserService::update_profile` (core)
71 + // already queues the validation email to the new address and the
72 + // `account_modified` alert to the old one. The UI layer only notifies an
73 + // actual username rename here: the profile form always submits every
74 + // field, so an echoed (unchanged) username must not trigger an alert.
75 + if updated_user.username != old_user.username {
76 + if let Ok(email_config) = EmailConfig::from_env() {
77 + let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
78 + let app_name = email_config
79 + .smtp_from_name
80 + .clone()
81 + .unwrap_or_else(rustwarden_core::config::app::default_app_name);
72 82
73 - let mut changes = Vec::new();
74 - if new_username.is_some() {
75 - changes.push(rustwarden_core::templates::email_templates::AccountChange {
83 + let changes = vec![rustwarden_core::templates::email_templates::AccountChange {
76 84 field: "Username".to_string(),
77 85 old_value: Some(old_user.username.clone()),
78 86 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 - }
87 + }];
90 88
91 - if !changes.is_empty() {
92 89 let template =
93 90 rustwarden_core::templates::email_templates::AccountModifiedTemplate::new(
94 91 Some("fr".to_string()),
@@ -102,44 +99,7 @@ pub async fn update_profile(
102 99 let subject = "Compte modifié".to_string();
103 100 use rustwarden_core::services::email_queue_service::EmailQueueService;
104 101
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(
102 + if let Err(e) = EmailQueueService::enqueue(
143 103 &db,
144 104 updated_user.email.clone(),
145 105 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/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 %}
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 docs/MIGRATION_GUIDE.md
+411 -0
@@ -4,6 +4,417 @@ 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-0040 (1.1.0)
13 +
14 +Minor de securite. Les cinq RFC 0036 a 0040 sont **Accepted (2026-09-18)** et
15 +**implementees**. Fenetre d'adoption **14 jours**
16 +([RUSTWARDEN_EVOLUTION_PROCESS.md](RUSTWARDEN_EVOLUTION_PROCESS.md) §9).
17 +Details : CHANGELOG `[1.1.0]`, [SERVICES_REFERENCE.md](SERVICES_REFERENCE.md)
18 +bandeau 1.1.0 + §3, §4, §9, §10, §19.
19 +
20 +**Deux API ajoutees** (`types::Username`, `config::security_headers::SecurityHeadersConfig`),
21 +**quatre changements de comportement** sur des chemins d'auth existants,
22 +**une migration SeaORM a jouer**.
23 +
24 +> ⚠️ **Ordre imperatif** : jouer la migration
25 +> `m20260918_000010_totp_last_used_step` **avant** de demarrer le binaire 1.1.0.
26 +> Le modele SeaORM `user_totp` declare `last_used_step`, donc SeaORM emet la
27 +> liste de colonnes explicite : sur un schema pre-migration, **toute** requete
28 +> sur `user_totp` echoue — y compris `TotpService::is_totp_enabled`, appelee a
29 +> **chaque** login. Un replica 1.1.0 sur un schema pre-migration fait donc
30 +> echouer **tous les logins en HTTP 500** (pas seulement ceux des comptes 2FA)
31 +> tant que la colonne `user_totp.last_used_step` est absente.
32 +
33 +### Ordre d'operations
34 +
35 +```bash
36 +# 1. Sauvegarde avant tout
37 +pg_dump "$DATABASE_URL" > backup-avant-1.1.0.sql
38 +
39 +# 2. Recuperer le framework (vendor + docs framework-owned)
40 +cargo run -p rustwarden-cli -- update-project
41 +
42 +# 3. Aligner les pins de dependances de crates/rustwarden-core/Cargo.toml
43 +# sea-orm / sea-orm-migration = "2.0.0", sea-bae = "0.2.2",
44 +# validator_derive = "0.20.1"
45 +cargo update
46 +
47 +# 4. Migration AVANT le deploiement du binaire
48 +cargo run --bin migrate
49 +# (ou auto_migrate(true) si deploiement mono-instance)
50 +
51 +# 5. Lint
52 +cargo clippy --workspace -- -D warnings
53 +
54 +# 6. Smoke tests auth : login, register, changement d'email, 2FA
55 +```
56 +
57 +`cargo clippy --workspace --all-features` est a eviter : la feature
58 +`redis-session` de `rustwarden-core` ne compile pas.
59 +
60 +---
61 +
62 +### RFC 0036 — newtype `Username`
63 +
64 +[docs/rfcs/0036-username-charset-validation.md](rfcs/0036-username-charset-validation.md)
65 +
66 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
67 +|-------|---------------|---------------|
68 +| Charset username | Aucune contrainte : `John_Doe`, `alice.bob`, backticks et `$` acceptes | `[a-z0-9-]` uniquement. Majuscule **rejetee** (pas de fold : `Alice` → erreur, pas `alice`) |
69 +| Longueur | `RegisterRequest` : 3–64 | 3–39 (apres `trim`), `RegisterRequest` : `max = 39` |
70 +| Tirets | Libres | Ni en tete ni en queue, pas de `--` consecutifs |
71 +| Noms reserves | Aucun | `types::team_slug::RESERVED` + `me` + `explore` |
72 +| Porte de validation | Aucune | `UserService::create_user` (unique gate) + `POST /api/v1/auth/register` (defense en profondeur) |
73 +| Rename (`update_profile`) | Unicite seulement | `Username::parse` **seulement sur un vrai rename** ; echo du username courant = no-op |
74 +| Bootstrap admin | `create_user` | `create_user_allow_reserved` (`pub(crate)`, reserve a `ensure_default_admin`) → `ADMIN_USERNAME=admin` continue de booter |
75 +| OAuth auto-username | `_` possible, longueur libre | `OAuthService::generate_unique_username` : minuscules `[a-z0-9]`, base a 30 chars, suffixes `-NNN` / `-user` / `-<hex8>` |
76 +| Schema DB | — | **Aucun `CHECK` SQL, aucune migration, aucun rewrite** |
77 +| Login des comptes existants | — | **Inchange** — les handles hors charset se connectent toujours |
78 +
79 +**Actions consommateur**
80 +
81 +```bash
82 +# Usernames generes par votre app : passent-ils Username::parse ?
83 +grep -rn "create_user(" src/
84 +grep -rn "update_profile(" src/
85 +# Newtype Username local a retirer (ex. gitrust)
86 +grep -rni "struct Username\|fn parse_username\|validate_username" src/
87 +```
88 +
89 +- Si votre app genere des usernames (import LDAP, seed, provisioning SSO maison),
90 + verifier qu'ils sont en minuscules, `[a-z0-9-]`, 3–39 caracteres, sans `--`.
91 + Sinon `create_user` renvoie desormais `AppError::Validation` (HTTP 400).
92 +- **Retirer** un newtype `Username` local : utiliser
93 + `rustwarden_core::types::Username` (re-exporte au prelude). Pour valider un
94 + identifiant avant de le stocker ailleurs :
95 +
96 + ```rust
97 + use rustwarden_core::prelude::*;
98 +
99 + let username = Username::parse(&raw_input)?; // AppError::Validation si invalide
100 + UserService::create_user(&db, username.into_inner(), email, hash, false).await?;
101 + ```
102 +
103 +- Formulaires cote client : aligner le `maxlength` / le pattern HTML sur 39 et
104 + `[a-z0-9-]` pour ne pas renvoyer une erreur serveur a l'utilisateur.
105 + `rustwarden-ui` le fait desormais pour ses propres ecrans (`login.html`
106 + inscription, `profile.html`, `admin/user_form.html` en mode `create`) et ses
107 + six locales decrivent la bonne regle — si vous avez **forke** ces templates ou
108 + ces fichiers `locales/*.yml`, reportez le changement a la main :
109 + `maxlength="39"`, `pattern="[a-z0-9-]+"`, et le texte d'aide
110 + « minuscules, chiffres et tirets uniquement, 3 a 39 caracteres ».
111 +
112 +**Pieges**
113 +
114 +- **Comptes existants hors charset** : ils se connectent toujours, mais toute
115 + tentative de **rename** vers une autre valeur passe par `Username::parse`.
116 + Un utilisateur `John_Doe` peut renvoyer `John_Doe` (no-op), il ne peut pas
117 + renommer en `John_Doe2`.
118 +- **`ADMIN_USERNAME` avec une majuscule ou un `_`** : le chemin de creation de
119 + l'admin par defaut (`ensure_default_admin` → `validate_new_admin_credentials`
120 + → `AdminConfig::validate`) delegue a `Username::parse_allow_reserved`, qui
121 + refuse toujours le mauvais charset. `build()` propage l'erreur : **le boot
122 + echoue**. A noter : ce controle ne s'applique que sur le **chemin de creation**
123 + (aucun admin en base + `ADMIN_PASSWORD` defini). Un deploiement dont l'admin
124 + existe deja n'est pas impacte. Le wizard
125 + `cargo run -p rustwarden-cli -- start-project` valide maintenant cette saisie
126 + au moment de la poser, pour que l'erreur remonte dans le wizard et non au
127 + premier `build()`.
128 +- Le nom reserve `admin` reste accepte pour le bootstrap uniquement ; un
129 + `POST /api/v1/auth/register` avec `admin` est refuse.
130 +
131 +---
132 +
133 +### RFC 0037 — anti-rejeu TOTP persistant (`last_used_step`)
134 +
135 +[docs/rfcs/0037-totp-last-used-step.md](rfcs/0037-totp-last-used-step.md)
136 +
137 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
138 +|-------|---------------|---------------|
139 +| Anti-rejeu | Garde **in-process** (`HashMap` `OnceLock`, TTL 90 s) | Garde **persistee en base** : `user_totp.last_used_step BIGINT NULL` |
140 +| Multi-replicas | Un code valide rejouable sur un **autre** replica | Ferme : compare-and-set SQL partage |
141 +| 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` |
142 +| Pas consomme | — | Le pas **qui a matche** (`matching_totp_step`, skew ±1), jamais `now / 30` |
143 +| Rejeu detecte | — | `Ok(false)` — meme reponse qu'un code invalide, pas d'oracle |
144 +| Couverture | `verify_code` | `verify_code` **et** `verify_and_enable` (le code de setup ne peut plus servir de login immediat) |
145 +| Erreur DB | — | Propagee en `AppError` — jamais fail-open |
146 +| Plafonds 1.0.1 | 15 tentatives / 15 min (somme), 5 par challenge | **Inchanges**, la garde in-process est conservee en defense en profondeur |
147 +
148 +**Actions consommateur**
149 +
150 +```bash
151 +# Patch local du rejeu TOTP a retirer
152 +grep -rn "last_used_step\|replay_guard\|totp_replay" src/
153 +```
154 +
155 +- Jouer la migration (`cargo run --bin migrate` ou `auto_migrate(true)`).
156 +- Verifier la colonne : `\d user_totp` doit lister `last_used_step | bigint | nullable`.
157 +- Retirer tout patch local dupliquant l'anti-rejeu TOTP.
158 +
159 +**Pieges**
160 +
161 +- **Ordre de deploiement : migration d'abord, binaire ensuite — le rayon de
162 + souffle depasse le 2FA.** Le modele SeaORM `user_totp` declare desormais
163 + `last_used_step` ; SeaORM genere une liste de colonnes explicite, donc sur un
164 + schema pre-migration **toute** requete sur `user_totp` echoue (`column
165 + user_totp.last_used_step does not exist`), pas seulement le compare-and-set.
166 + Or `TotpService::is_totp_enabled` est appelee a **chaque** login par
167 + `auth/handlers.rs` : **tous les logins renvoient 500**, y compris ceux des
168 + comptes sans 2FA. Ce n'est donc pas une degradation partielle du 2FA, c'est
169 + une **panne totale de l'authentification** jusqu'a ce que la colonne existe.
170 +- `last_used_step IS NULL` = « aucun pas consomme depuis la migration ». Le
171 + premier code presente apres migration est donc toujours accepte
172 + (grandfathering des lignes existantes) — c'est voulu.
173 +- La colonne est **ajoutee sans defaut** : l'`ALTER TABLE ... ADD COLUMN ... NULL`
174 + ne reecrit pas la table.
175 +
176 +---
177 +
178 +### RFC 0038 — revalidation email a `update_profile`
179 +
180 +[docs/rfcs/0038-email-change-revalidation.md](rfcs/0038-email-change-revalidation.md)
181 +
182 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
183 +|-------|---------------|---------------|
184 +| Changement d'email | `email` ecrit, `email_verified` **conserve a `true`** | `email_verified = false` + `email_verified_at = NULL` dans le **meme** update |
185 +| Comparaison | — | Exacte apres `trim` ; echo de l'adresse courante = no-op complet (aucun flag, aucun mail) |
186 +| Mail vers la nouvelle adresse | Aucun | `EmailValidationService::send_validation_email` (invalide au passage les tokens de validation precedents) |
187 +| Mail vers l'ancienne adresse | Aucun (ou dupplique cote UI) | Alerte `account_modified`, **sans lien de validation** |
188 +| Chemin admin | Non couvert | `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile` → meme comportement |
189 +| Echec SMTP | — | **Best-effort** : `EmailConfig::from_env()` en erreur → envois sautes avec warning, **l'update en base reussit** |
190 +| Logs | Adresse en clair possible | Adresses masquees (`jo***@ex***`) |
191 +| Rename username seul | — | Ne declenche **aucune** revalidation |
192 +
193 +**Actions consommateur**
194 +
195 +```bash
196 +# Patch local re-verifiant l'email apres un changement d'adresse
197 +grep -rn "email_verified" src/
198 +grep -rn "account_modified" src/
199 +```
200 +
201 +- **Retirer** tout code applicatif qui, apres `update_profile`, remettait
202 + `email_verified = false` a la main ou enqueuait lui-meme un mail de
203 + validation / une alerte `account_modified` pour l'email : le core le fait
204 + desormais (sinon l'utilisateur recoit deux mails).
205 +- Cote UI custom : exposer un bouton « renvoyer l'email de validation »
206 + (`POST /api/v1/auth/resend-verification`) — c'est le filet de secours quand
207 + SMTP etait indisponible au moment du changement.
208 +
209 +**Pieges**
210 +
211 +- **Seul le PROCHAIN login est bloque — la session en cours survit.**
212 + `update_profile` ne revoque **ni** les refresh tokens **ni** les JWT d'acces
213 + deja emis, et `refresh_token` (`auth/handlers.rs`) ne relit pas
214 + `email_verified`. Concretement, avec `validation_email_required` actif
215 + (defaut, cf. RFC 0040) : l'utilisateur qui change d'adresse **reste connecte**
216 + et peut continuer a rafraichir ses tokens jusqu'a leur expiration naturelle ;
217 + c'est seulement une **nouvelle authentification** (`authenticate_user`) qui est
218 + refusee tant que la nouvelle adresse n'est pas validee. `rustwarden-ui`
219 + redirige vers `/login` cote client apres un changement d'email, mais c'est un
220 + choix d'UI, **pas une revocation serveur**. Prevenir explicitement
221 + l'utilisateur dans l'ecran de profil.
222 +- Meme chose pour un **admin** qui corrige l'email d'un utilisateur depuis
223 + `PUT /admin/users/:id` : il ne deconnecte pas le compte, il en bloque la
224 + **prochaine** connexion jusqu'a validation.
225 +- **Limite connue / follow-up** : revoquer les refresh tokens au changement
226 + d'e-mail (ou appliquer la politique `email_verified` dans `refresh_token`)
227 + fera l'objet d'une **RFC ulterieure**. Tant qu'elle n'est pas livree, ne
228 + comptez pas sur `update_profile` comme mecanisme de deconnexion : si vous avez
229 + besoin d'une revocation immediate, appelez vous-meme
230 + `RefreshTokenService::revoke_all_tokens_for_user` apres le changement.
231 +- **Faute de frappe sur la nouvelle adresse = lock-out definitif sans recours
232 + self-service.** La chaine est : `update_profile` ecrit la **nouvelle** adresse
233 + et passe `email_verified = false` ; le mail de validation part vers cette
234 + nouvelle adresse ; l'ancienne ne recoit qu'une alerte `account_modified`
235 + **sans lien d'action**. Les deux voies de recuperation
236 + (`POST /api/v1/auth/resend-verification` et
237 + `POST /api/v1/auth/resend-verification/by-username`) renvoient vers l'adresse
238 + **stockee** — donc vers la faute de frappe. Un utilisateur qui saisit
239 + `bob@gmial.com` au lieu de `bob@gmail.com` n'a **aucun** moyen de se debloquer
240 + seul : seul un **admin** peut corriger l'adresse via `PUT /admin/users/{id}`.
241 + - *Recommandation aux consommateurs* : ajouter un **champ de confirmation
242 + d'adresse** (double saisie) dans votre ecran de profil, et afficher la
243 + nouvelle adresse en clair avant soumission.
244 + - *Suppression du risque* : la **v2 `pending_email_changes`** evoquee en
245 + follow-up de la RFC 0038 (ecrire l'adresse candidate dans une table d'attente
246 + et ne basculer `user.email` qu'apres clic sur le lien) supprimerait
247 + entierement ce mode de panne — l'adresse courante resterait valide et
248 + verifiee tant que la nouvelle n'est pas confirmee.
249 +- La locale des mails est figee a `fr` (TODO amont, non resolu par cette RFC).
250 +
251 +---
252 +
253 +### RFC 0039 — `SecurityHeadersConfig`
254 +
255 +[docs/rfcs/0039-security-headers-config.md](rfcs/0039-security-headers-config.md)
256 +
257 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
258 +|-------|---------------|---------------|
259 +| CSP | Litteral hardcode dans `security_headers_middleware` | `SecurityHeadersConfig.content_security_policy`, configurable |
260 +| COOP / CORP | Jamais emis | **Opt-in** : `Option<Cow<'static, str>>`, `None` par defaut = header non emis |
261 +| 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(...)`) |
262 +| Valeur par defaut | — | **Bit-identique a 1.0.1** — `'unsafe-inline'` et `'unsafe-eval'` conserves (Alpine.js) |
263 +| 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) |
264 +| Valeur invalide | — | CSP invalide → fallback sur le defaut + warning ; COOP/CORP invalide → header saute + warning. Jamais de panique |
265 +
266 +**Ce que la RFC ne fait PAS** : elle n'ameliore aucune politique par defaut.
267 +Un consommateur qui n'appelle jamais `.security_headers(..)` obtient exactement
268 +les memes en-tetes qu'en 1.0.1. La constante `CSP_WITHOUT_UNSAFE_EVAL` est
269 +fournie mais **non utilisee** par `Default` — le durcissement du defaut est une
270 +**Phase 2** ulterieure, apres migration Alpine CSP
271 +([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)).
272 +
273 +**Actions consommateur**
274 +
275 +```bash
276 +# Overlay CSP local a retirer (ex. gitrust)
277 +grep -rn "SetResponseHeaderLayer" src/
278 +grep -rn "CONTENT_SECURITY_POLICY\|Cross-Origin-Opener-Policy\|content-security-policy" src/
279 +```
280 +
281 +```rust
282 +use rustwarden_core::prelude::*; // SecurityHeadersConfig
283 +
284 +// AVANT — overlay tower-http par-dessus le middleware framework
285 +let router = router.layer(SetResponseHeaderLayer::overriding(
286 + header::CONTENT_SECURITY_POLICY,
287 + HeaderValue::from_static("default-src 'self'; script-src 'self'"),
288 +));
289 +
290 +// APRES — configuration native
291 +let app = RustwardenApp::builder()
292 + .from_env()
293 + .security_headers(
294 + SecurityHeadersConfig {
295 + content_security_policy: "default-src 'self'; script-src 'self'".into(),
296 + ..SecurityHeadersConfig::default()
297 + }
298 + .with_same_origin_isolation(), // COOP + CORP = same-origin
299 + )
300 + .build()
301 + .await?;
302 +```
303 +
304 +**Pieges**
305 +
306 +- Une CSP custom trop stricte casse l'UI `rustwarden-ui` (HTMX + Alpine.js ont
307 + besoin de `'unsafe-inline'` et, pour la build Alpine standard, `'unsafe-eval'`).
308 + A reserver aux apps headless ou a UI maison.
309 +- COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN
310 + d'assets, widgets tiers). Tester avant d'activer.
311 +
312 +---
313 +
314 +### RFC 0040 — `authenticate_user` honore `validation_email_required`
315 +
316 +[docs/rfcs/0040-authenticate-user-email-verified.md](rfcs/0040-authenticate-user-email-verified.md)
317 +
318 +| Sujet | Avant (1.0.1) | Apres (1.1.0) |
319 +|-------|---------------|---------------|
320 +| Ou vit le controle | Handler HTTP `POST /api/v1/auth/login` | `AuthService::authenticate_user` (service) |
321 +| Appel direct au service | **Contourne** la politique | Applique la politique |
322 +| Reglage | `validation_email_required` / alias `validation_email_requise` | **Identique** |
323 +| Defaut | `true` | `true` (fail-closed : erreur de lookup = verification requise) |
324 +| Message d'erreur | Francais | **Identique**, meme `AppError::Validation` (HTTP 400) |
325 +| Ordre des controles | — | Mot de passe **d'abord**, politique email ensuite → pas d'oracle « compte existant non verifie » |
326 +| `verify_credentials` | Mot de passe seul | **Inchange** — primitive mot de passe seul, pas de controle email (utilisee par le re-prompt TOTP) |
327 +| Hook `auth.login_blocked` | Emis par le handler | **Plus emis** (decision v1) — la ligne `audit_logs` est toujours ecrite |
328 +
329 +**Actions consommateur**
330 +
331 +```bash
332 +grep -rn "authenticate_user\|verify_credentials" src/
333 +grep -rn "login_blocked\|on_audit_event" src/
334 +```
335 +
336 +- Retirer tout patch local qui dupliquait le controle `email_verified` apres
337 + `authenticate_user` (double verification = double message d'erreur).
338 +- Si votre SSR appelait `authenticate_user` : il applique desormais la politique.
339 + **Verifier que votre ecran de login affiche le message d'erreur** et propose
340 + « renvoyer l'email de validation ».
341 +- Si vous avez volontairement besoin d'un login sans controle email (outil
342 + interne, migration), utilisez `verify_credentials` **en connaissance de cause**
343 + — c'est un contournement explicite de la politique.
344 +
345 +**Pieges**
346 +
347 +- **Le hook `on_audit_event` / `auth.login_blocked` n'est plus emis** sur ce
348 + chemin. Une alerte SIEM branchee sur ce hook devient silencieuse : basculer la
349 + detection sur la table `audit_logs` (`action = "login_blocked"`).
350 +- Combine a la RFC 0038 : tout changement d'email bloque le **prochain login**
351 + jusqu'a la validation. La session en cours (JWT d'acces + refresh token)
352 + **reste valide** — `update_profile` ne revoque rien et `refresh_token` ne
353 + relit pas `email_verified`. C'est le comportement voulu en v1, mais il change
354 + l'experience utilisateur de facon visible ; voir la § RFC 0038 ci-dessus pour
355 + la limite connue et le risque de lock-out par faute de frappe.
356 +- Le reglage reste **global** (`app_settings`), pas par utilisateur.
357 +
358 +---
359 +
360 +### Recapitulatif des workarounds consommateur a retirer
361 +
362 +| Workaround | Remplace par |
363 +|------------|--------------|
364 +| Newtype `Username` / `validate_username()` local (gitrust) | `rustwarden_core::types::Username` (prelude) |
365 +| `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` (gitrust) | `builder().security_headers(SecurityHeadersConfig { .. })` |
366 +| Headers COOP/CORP poses a la main | `SecurityHeadersConfig::with_same_origin_isolation()` |
367 +| Reset manuel de `email_verified` apres `update_profile` | Fait par le core (RFC 0038) |
368 +| Enqueue applicatif du mail de validation / `account_modified` sur changement d'email | Fait par le core (RFC 0038) |
369 +| Re-verification de `email_verified` apres `authenticate_user` | Fait par le service (RFC 0040) |
370 +| Patch local de l'anti-rejeu TOTP | `user_totp.last_used_step` (RFC 0037) |
371 +
372 +---
373 +
374 +## 2026-09-18 — Security 1.0.1 (patch interne)
375 +
376 +Patch de securite **sans nouvelle API `pub`**. Fenetre d'adoption **14 jours** apres
377 +diffusion de `[1.0.1]`. Details techniques : CHANGELOG `[1.0.1]`,
378 +[SERVICES_REFERENCE.md](SERVICES_REFERENCE.md) bandeau 1.0.1 + §4, §6, §12, §15, §18, §19.
379 +
380 +> **Mise a jour 2026-09-18** : les RFC 0036–0040 (charset username,
381 +> `last_used_step` TOTP, re-validation email, headers de securite configurables,
382 +> `email_verified` dans `authenticate_user`) sont depuis passees **Accepted** et
383 +> **livrees** dans `[1.1.0]`. Voir l'entree
384 +> [2026-09-18 — RFC 0036-0040 (1.1.0)](#2026-09-18--rfc-0036-0040-110) ci-dessus.
385 +
386 +### Breaking operational (pas d'API)
387 +
388 +| Sujet | Avant | Apres 1.0.1 |
389 +|-------|--------|-------------|
390 +| 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. |
391 +| `JWT_SECRET` | Placeholders anglais uniquement | Egalite exacte aussi avec `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE` → **refus de boot**. |
392 +| `ADMIN_PASSWORD` | Placeholder / trop faible pouvait casser un redeploiement | `AdminConfig::validate()` seulement si creation d'admin. Sans variable, pas de bootstrap. |
393 +| 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`. |
394 +| Reset MDP faible | Token consomme puis 400 | Complexite verifiee **avant** hash / `used_at`. |
395 +| Rate limiter plein | 429 sur une **nouvelle** cle | Eviction LRU a 100 000 entrees. |
396 +| Auth middleware | Fail-open lookup user / blacklist / TOTP force | Fail-closed (503 sur erreur TOTP force). |
397 +| TOTP login | Compteur de challenges, pas de rejeu in-process | 15 tentatives / 5 min ; rejeu in-process seulement. |
398 +| `.env.example` CLI | `JWT_SECRET` / `ADMIN_PASSWORD` placeholder | Secrets a definir ; ne plus copier un placeholder. |
399 +
400 +### Workarounds consommateur a retirer
401 +
402 +Apres vendor / `update-project` + rebuild :
403 +
404 +- garde JWT maison dupliquant `AuthConfig` (placeholders FR) ;
405 +- 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 ;
406 +- `RateLimitService::new()` dans chaque handler de reset / verify email (les services framework partagent un `OnceLock`) ;
407 +- patch local du plafond TOTP ou du rejeu in-process.
408 +
409 +`check_auth_rate_limit(&HeaderMap)` **n'a pas change de signature**. Il ignore toujours ConnectInfo.
410 +
411 +### Dependances
412 +
413 +`rustwarden-core` pin `sea-bae = "0.2.2"` et `validator_derive = "0.20.1"` (drop
414 +`proc-macro-error2` / RUSTSEC-2026-0173). Apres sync des crates, `cargo update`
415 +puis verifier l'absence de `proc-macro-error2` dans `cargo tree -i proc-macro-error2`.
416 +
417 +`validator` reste `0.20` (pas de `0.20.1` sur crates.io pour le crate principal).
7 418
8 419 ---
9 420
M docs/SERVICES_REFERENCE.md
+300 -13
@@ -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-0040) — propagé par `update-project`.** Minor de sécurité : **2 API ajoutées** ([`Username`](#username-value-object) §3, [`SecurityHeadersConfig`](#en-tetes-de-securite-securityheadersconfig) §18), **4 changements de comportement** (§3 `create_user` / `update_profile`, §4 `authenticate_user`, §19 TOTP), **1 migration SeaORM à jouer** (`m20260918_000010_totp_last_used_step`). Fenêtre d'adoption **14 jours**.
16 +>
17 +> Actions consommateur : (1) **jouer la migration avant de déployer le binaire** (`cargo run --bin migrate`) — le modèle SeaORM `user_totp` déclare `last_used_step`, donc sur un schéma pré-migration **toute** requête sur `user_totp` échoue, dont `TotpService::is_totp_enabled` appelée à **chaque** login : un réplica 1.1.0 sur un schéma pré-migration fait échouer **tous les logins en 500**, pas seulement ceux des comptes 2FA ; (2) vérifier que les usernames générés par votre app passent `Username::parse` (`[a-z0-9-]`, 3–39, pas de majuscule) ; (3) un changement d'e-mail remet `email_verified = false` — le **prochain** login est bloqué jusqu'à validation si `validation_email_required` (défaut `true`), mais la **session en cours n'est pas révoquée** (§3) ; (4) le hook `auth.login_blocked` **n'est plus émis** par le blocage e-mail (la ligne `audit_logs` l'est toujours) ; (5) retirer les workarounds : newtype `Username` local, overlay `SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)`, patch local re-vérifiant l'e-mail. Détail : [MIGRATION_GUIDE.md](MIGRATION_GUIDE.md) § *2026-09-18 — RFC 0036-0040 (1.1.0)* et CHANGELOG `[1.1.0]`.
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 et de l'email. **1.1.0** : `Username::parse` sur un **vrai rename** uniquement (RFC 0036) ; un changement d'adresse remet `email_verified = false` et declenche les mails de revalidation (RFC 0038) — voir ci-dessous. |
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,102 @@ 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 +#### Revalidation e-mail (RFC 0038, depuis 1.1.0)
178 +
179 +`update_profile` traite un changement d'adresse comme une perte de preuve de possession :
180 +
181 +| Cas | Comportement |
182 +|-----|--------------|
183 +| Nouvelle adresse (comparaison exacte apres `trim`) | `email_verified = false` + `email_verified_at = NULL` dans le **meme** `UPDATE` |
184 +| Nouvelle adresse | Mail de validation envoye a la **nouvelle** adresse (`EmailValidationService::send_validation_email`, qui invalide les tokens precedents) |
185 +| Nouvelle adresse | Alerte `account_modified` a l'**ancienne** adresse, **sans lien de validation** |
186 +| Echo de l'adresse courante | No-op complet : aucun flag modifie, aucun mail |
187 +| Rename username seul | Aucune revalidation |
188 +| `EmailConfig::from_env()` indisponible | **Best-effort** : envois sautes avec warning, l'`UPDATE` reussit quand meme. Filet : `POST /api/v1/auth/resend-verification` |
189 +| Logs | Adresses masquees (`jo***@ex***`) |
190 +
191 +Vaut aussi pour le chemin **admin** : `PUT /admin/users/:id` (rustwarden-ui) passe par `update_profile`.
192 +
193 +> ⚠️ Combine a [§4 `authenticate_user`](#4-authentification) : avec
194 +> `validation_email_required = true` (defaut), **le prochain login est bloque**
195 +> tant que l'utilisateur n'a pas clique sur le nouveau lien. En revanche la
196 +> **session en cours survit** : `update_profile` ne revoque ni les refresh
197 +> tokens ni les JWT d'acces deja emis, et `refresh_token` ne relit pas
198 +> `email_verified` — l'utilisateur reste donc connecte et peut rafraichir ses
199 +> tokens jusqu'a leur expiration naturelle. La redirection vers `/login` de
200 +> `rustwarden-ui` est un choix d'UI cote client, **pas une revocation serveur**.
201 +> Prevenez l'utilisateur dans votre ecran de profil, et exposez un bouton
202 +> « renvoyer l'email de validation ».
203 +>
204 +> **Limite connue / follow-up** : revoquer les refresh tokens au changement
205 +> d'e-mail (ou appliquer la politique `email_verified` dans `refresh_token`)
206 +> fera l'objet d'une RFC ulterieure. En attendant, si vous avez besoin d'une
207 +> revocation immediate, appelez `RefreshTokenService::revoke_all_tokens_for_user`
208 +> apres le changement.
209 +
210 +### `Username` (value object)
211 +
212 +**Module** : `rustwarden_core::types::username`
213 +**Export** : prelude
214 +**RFC** : [0036](rfcs/0036-username-charset-validation.md) — depuis **1.1.0**
215 +
216 +Newtype valide pour un identifiant de connexion local, calque sur [`TeamSlug`](#teamslug-value-object).
217 +
218 +| Methode | Signature | Description |
219 +|---------|-----------|-------------|
220 +| `parse` | `(raw: &str) → Result<Username, AppError>` | Valide charset, longueur, tirets **et** noms reserves. `AppError::Validation` (400) sinon. |
221 +| `as_str` | `(&self) → &str` | Vue empruntee. |
222 +| `into_inner` | `(self) → String` | Consomme le newtype (a passer a `create_user`). |
223 +
224 +Derive `Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize` (`#[serde(transparent)]`) et implemente `Display`.
225 +
226 +**Regles appliquees par `parse`** :
227 +
228 +| Regle | Detail |
229 +|-------|--------|
230 +| Longueur | 3 a 39 caracteres, mesures **apres `trim()`** |
231 +| Charset | `[a-z0-9-]` uniquement |
232 +| Majuscules | **Rejetees**, jamais repliees — `Alice` echoue, il ne devient pas `alice` |
233 +| Tirets | Ni en tete ni en queue, pas de `--` consecutifs |
234 +| Noms reserves | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) |
235 +
236 +```rust
237 +use rustwarden_core::prelude::*;
238 +
239 +let username = Username::parse("squad-42")?; // Ok
240 +assert!(Username::parse("Alice").is_err()); // majuscule → refus, pas de fold
241 +assert!(Username::parse("john_doe").is_err()); // underscore hors charset
242 +assert!(Username::parse("me").is_err()); // nom reserve
243 +
244 +UserService::create_user(&db, username.into_inner(), email, password_hash, true).await?;
245 +```
246 +
247 +**Chemin bootstrap** : `Username::parse_allow_reserved` et
248 +`UserService::create_user_allow_reserved` sont `pub(crate)` et reserves a
249 +`ensure_default_admin` (pour que `ADMIN_USERNAME=admin` continue de booter).
250 +Ils appliquent toujours charset et longueur — un `ADMIN_USERNAME` avec une
251 +majuscule ou un `_` fait **echouer le boot** sur le chemin de creation de l'admin.
252 +
253 +**Usernames OAuth** : `OAuthService::generate_unique_username` produit deja des
254 +candidats conformes (minuscules `[a-z0-9]`, base tronquee a 30 caracteres,
255 +suffixes `-NNN` / `-user` / `-<hex8>`).
256 +
257 +**A retirer cote consommateur** : tout newtype `Username` / `validate_username()`
258 +local — `grep -rni "struct Username\|validate_username" src/`.
259 +
158 260 ---
159 261
160 262 ## 4. Authentification
@@ -167,13 +269,51 @@ Verification des credentials avec protection contre le timing attack (SEC-C1).
167 269
168 270 | Methode | Signature | Description |
169 271 |---------|-----------|-------------|
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`. |
272 +Le parametre `identifier` accepte indifferemment un **username ou un email** (lookup par email s'il contient `@`).
273 +
274 +| Methode | Signature | Description |
275 +|---------|-----------|-------------|
276 +| `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). |
277 +| `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 278
173 279 **Securite** :
174 280 - `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 281 - `mask_username()` : masque les usernames dans les logs (affiche uniquement le premier tiers des caracteres).
176 282
283 +#### Politique « e-mail verifie » (RFC 0040, depuis 1.1.0)
284 +
285 +Le controle vivait dans le **handler** `POST /api/v1/auth/login` ; il a ete
286 +**deplace dans `authenticate_user`**, pour qu'une app qui appelle le service
287 +directement (SSR maison, handler custom) ne puisse plus le contourner.
288 +
289 +| Aspect | Valeur |
290 +|--------|--------|
291 +| Reglage | `validation_email_required` (alias legacy `validation_email_requise`), lu via `AppSettingsService::get_bool_with_fallback` |
292 +| Defaut | `true` — **fail-closed** : une erreur de lookup vaut « verification requise » |
293 +| Erreur | `AppError::Validation` → **HTTP 400**, message francais destine a l'utilisateur final (inchange depuis 1.0.1) |
294 +| Ordre | Mot de passe verifie **d'abord** → pas d'oracle « compte existant mais non verifie » |
295 +| Audit | Ligne `audit_logs` ecrite (`action = "login_blocked"`) via `AuthLoggingService::log_blocked_login` |
296 +| 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`. |
297 +
298 +```rust
299 +use rustwarden_core::services::auth_service::AuthService;
300 +use rustwarden_core::error::AppError;
301 +
302 +match AuthService::authenticate_user(&db, &form.username, &form.password).await {
303 + Ok(user) => issue_session(user).await,
304 + // Message deja redige pour l'utilisateur : proposer « renvoyer l'email ».
305 + Err(AppError::Validation(msg)) => render_login_error(&msg, /* show_resend = */ true),
306 + Err(AppError::InvalidCredentials) => render_login_error("Identifiants invalides.", false),
307 + Err(e) => Err(e),
308 +}
309 +```
310 +
311 +**A retirer cote consommateur** : tout patch local re-verifiant `email_verified`
312 +apres `authenticate_user` (double controle = double message d'erreur).
313 +`grep -rn "authenticate_user\|email_verified" src/`.
314 +
315 +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.
316 +
177 317 ### `AuthUser` (extracteur)
178 318
179 319 **Module** : `rustwarden_core::auth::extractors`
@@ -332,7 +472,9 @@ Flux "Mot de passe oublie" (reset par email).
332 472 |---------|-----------|-------------|
333 473 | `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 474 | `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). |
475 +| `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. |
476 +
477 +`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 478
337 479 ---
338 480
@@ -707,7 +849,10 @@ Verification d'email par token.
707 849
708 850 | Methode | Signature | Description |
709 851 |---------|-----------|-------------|
710 -| `new` | `(config: EmailConfig) → Self` | Constructeur. |
852 +| `new` | `(config: EmailConfig) → Self` | Constructeur. Depuis 1.0.1 le rate limiter interne est un **`OnceLock` partage** (signature inchangee). |
853 +| `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
854 +| `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
855 +| `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 856 | `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
712 857 | `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
713 858 | `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 +957,27 @@ Rate limiter generique in-process, backing de toutes les limites du framework (a
812 957 | `check` | `(&self, key: &str, max_per_minute: u32) → Result<(), AppError>` | Fixed window 60s. Renvoie `AppError::TooManyRequests { retry_after_secs }` au depassement. |
813 958 | `check_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | Idem avec fenetre personnalisable. |
814 959 | `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). |
960 +| `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 961 | `len` / `sliding_len` / `is_empty` | | Compteurs exposes pour metrics/tests. |
817 962
818 963 **Cle convention** : `"<scope>:<identifier>"` — ex. `"auth:1.2.3.4"`, `"share:1.2.3.4"`, `"pwd_reset:user@example.com"`, `"import:123e4567-..."`.
819 964
820 965 **AppError::TooManyRequests** : mappe vers HTTP **429** + header `Retry-After: N` + body JSON `{"error","status":429,"retry_after_secs":N}`.
821 966
967 +`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.
968 +
822 969 Exemple d'usage pour proteger un endpoint d'import cote app consommateur :
823 970 ```rust
824 971 use std::sync::Arc;
825 972 use rustwarden_core::services::rate_limit_service::RateLimitService;
826 973
827 -// Au demarrage de l'app
974 +// Au demarrage de l'app — une seule instance, pas dans le handler
828 975 let limiter = Arc::new(RateLimitService::new());
829 976 // Dans un handler
830 977 limiter.check(&format!("import:{ip}"), 120)?; // 120/min
831 978 ```
832 979
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)`.
980 +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 981
835 982 > **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 983
@@ -1072,12 +1219,16 @@ Wrapper pratiques par-dessus `RateLimitService` :
1072 1219
1073 1220 | Helper | Signature | Description |
1074 1221 |--------|-----------|-------------|
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. |
1222 +| `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). |
1223 +| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. Lit ConnectInfo. |
1077 1224 | `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`). |
1225 +| `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 1226 | `AdminRateLimitConfig::*` | | Layers Tower pre-construits (`admin_limit`, `user_management_limit`, `reset_password_limit`). |
1080 1227
1228 +`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`).
1229 +
1230 +`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).
1231 +
1081 1232 ### Auth / Admin
1082 1233
1083 1234 **Module** : `rustwarden_core::middleware`
@@ -1112,6 +1263,88 @@ Mode d'échec sur préfixe protégé atteint sans credentials valides — `AuthC
1112 1263
1113 1264 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 1265
1266 +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`).
1267 +
1268 +### En-tetes de securite (`SecurityHeadersConfig`)
1269 +
1270 +**Module** : `rustwarden_core::config::security_headers`
1271 +**Export** : prelude
1272 +**RFC** : [0039](rfcs/0039-security-headers-config.md) — depuis **1.1.0**
1273 +
1274 +Le middleware `security_headers_middleware` pose les en-tetes de securite sur
1275 +**toutes** les reponses. Depuis 1.1.0, la CSP est configurable et COOP/CORP sont
1276 +disponibles en opt-in.
1277 +
1278 +```rust
1279 +#[derive(Clone, Debug)]
1280 +pub struct SecurityHeadersConfig {
1281 + pub content_security_policy: Cow<'static, str>,
1282 + pub cross_origin_opener_policy: Option<Cow<'static, str>>,
1283 + pub cross_origin_resource_policy: Option<Cow<'static, str>>,
1284 +}
1285 +```
1286 +
1287 +| Element | Signature / valeur | Description |
1288 +|---------|--------------------|-------------|
1289 +| `SecurityHeadersConfig::default()` | — | CSP = `DEFAULT_CONTENT_SECURITY_POLICY`, COOP/CORP = `None` |
1290 +| `with_same_origin_isolation` | `(self) → Self` | Positionne COOP **et** CORP a `same-origin`. La CSP n'est pas touchee. |
1291 +| `DEFAULT_CONTENT_SECURITY_POLICY` | `&'static str` | La CSP historique 1.0.1 (avec `'unsafe-inline'` et `'unsafe-eval'`) |
1292 +| `CSP_WITHOUT_UNSAFE_EVAL` | `&'static str` | Candidat **Phase 2**, identique au defaut moins `'unsafe-eval'`. **Non utilise** par `Default`. |
1293 +| `RustwardenBuilder::security_headers` | `(cfg: SecurityHeadersConfig) → Self` | Override au builder |
1294 +
1295 +> ✅ **Le defaut est bit-identique a 1.0.1.** Un consommateur qui n'appelle
1296 +> jamais `.security_headers(..)` ne voit **aucun** changement d'en-tete — ni
1297 +> amelioration automatique, ni regression. Le durcissement du defaut (retrait
1298 +> de `'unsafe-eval'`) est une **Phase 2** ulterieure, conditionnee a la migration
1299 +> vers la build Alpine CSP ([plans/PLAN_CSP_HTMX.md](plans/PLAN_CSP_HTMX.md)).
1300 +
1301 +| En-tete | Configurable ? | Defaut |
1302 +|---------|----------------|--------|
1303 +| `Content-Security-Policy` | **Oui** | `DEFAULT_CONTENT_SECURITY_POLICY` |
1304 +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non emis (`None`) |
1305 +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non emis (`None`) |
1306 +| `X-Content-Type-Options` | Non | `nosniff` |
1307 +| `X-Frame-Options` | Non | `DENY` |
1308 +| `X-XSS-Protection` | Non | `0` |
1309 +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` |
1310 +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` |
1311 +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` |
1312 +
1313 +```rust
1314 +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig
1315 +
1316 +let app = RustwardenApp::builder()
1317 + .from_env()
1318 + .security_headers(
1319 + SecurityHeadersConfig {
1320 + content_security_policy:
1321 + "default-src 'self'; script-src 'self'; style-src 'self'; \
1322 + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \
1323 + form-action 'self'"
1324 + .into(),
1325 + ..SecurityHeadersConfig::default()
1326 + }
1327 + .with_same_origin_isolation(),
1328 + )
1329 + .build()
1330 + .await?;
1331 +```
1332 +
1333 +**Details d'implementation** :
1334 +
1335 +- 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 ;
1336 +- une CSP invalide (caracteres de controle) **ne panique pas** : fallback sur le defaut + `warn!` ;
1337 +- une valeur COOP/CORP invalide fait sauter l'en-tete + `warn!`, sans casser la reponse.
1338 +
1339 +**Pieges** : une CSP sans `'unsafe-inline'` / `'unsafe-eval'` casse `rustwarden-ui`
1340 +(HTMX + build Alpine standard) — a reserver aux apps headless ou a UI maison.
1341 +COOP/CORP `same-origin` cassent les integrations cross-origin (iframes, CDN
1342 +d'assets, widgets tiers).
1343 +
1344 +**A retirer cote consommateur** : overlay
1345 +`SetResponseHeaderLayer::overriding(CONTENT_SECURITY_POLICY, …)` et tout header
1346 +COOP/CORP pose a la main — `grep -rn "SetResponseHeaderLayer" src/`.
1347 +
1115 1348 ---
1116 1349
1117 1350 ## 19. Utilitaires
@@ -1141,6 +1374,60 @@ Les projets générés par le CLI en mode headless câblent déjà ce router (+
1141 1374 |----------|-----------|-------------|
1142 1375 | `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 1376
1377 +`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.
1378 +
1379 +`AuthConfig` refuse au demarrage un `JWT_SECRET` **egal** (pas un sous-chaine) a un placeholder connu, y compris `REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE`.
1380 +
1381 +`sanitize_redirect` (middleware auth, query `redirect`) normalise `\` en `/` avant le test `//` — un open redirect `/\evil.com` est bloque (1.0.1).
1382 +
1383 +### TOTP (2FA)
1384 +
1385 +**Module** : `rustwarden_core::services::totp_service`
1386 +
1387 +Depuis 1.0.1, sur le chemin login :
1388 +
1389 +- `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` ;
1390 +- 5 tentatives maximum par challenge, TTL 5 min ;
1391 +- `verify_code` : garde anti-rejeu **in-process** (fenetre ±1, TTL 90 s) ;
1392 +- codes backup verifies en `spawn_blocking` (bcrypt).
1393 +
1394 +#### Anti-rejeu persistant `last_used_step` (RFC 0037, depuis 1.1.0)
1395 +
1396 +> ⚠️ **Migration a jouer** : `m20260918_000010_totp_last_used_step` ajoute
1397 +> `user_totp.last_used_step BIGINT NULL`. **Jouer la migration avant de deployer
1398 +> le binaire 1.1.0** — un replica sur un schema pre-migration echoue a la
1399 +> verification TOTP (fail-closed : l'erreur DB remonte, l'utilisateur ne peut
1400 +> plus valider son 2FA).
1401 +
1402 +`verify_code` **et** `verify_and_enable` consomment desormais le **pas qui a
1403 +reellement matche** (`matching_totp_step`, skew ±1 — jamais `now / 30`) via un
1404 +compare-and-set SeaORM :
1405 +
1406 +```sql
1407 +UPDATE user_totp
1408 + SET last_used_step = $step, updated_at = now()
1409 + WHERE user_id = $id
1410 + AND (last_used_step IS NULL OR last_used_step < $step)
1411 +```
1412 +
1413 +Succes ssi `rows_affected == 1`.
1414 +
1415 +| Aspect | Comportement |
1416 +|--------|--------------|
1417 +| Rejeu detecte | `Ok(false)` — **identique a un code invalide**, pas d'oracle de rejeu |
1418 +| Multi-replicas | Ferme : le CAS SQL est partage, la garde in-process de 1.0.1 ne couvrait qu'un processus |
1419 +| `verify_and_enable` | Consomme le pas **avant** de basculer `enabled = true` : le code de setup ne peut pas servir de login immediat |
1420 +| Erreur DB | Propagee en `AppError` — **jamais fail-open** |
1421 +| Garde in-process | Conservee **apres** le CAS, en defense en profondeur |
1422 +| Lignes existantes | `last_used_step IS NULL` = aucun pas consomme depuis la migration → le premier code presente apres migration est toujours accepte |
1423 +
1424 +**Ce que ca ne couvre pas** : un MITM temps reel (proxy de phishing type
1425 +Evilginx) rejoue le code **avant** l'utilisateur legitime et gagne le CAS. Le
1426 +TOTP n'est pas phishing-resistant par construction. Voir
1427 +[developer-guide/07-security-model.md](developer-guide/07-security-model.md#totp--2fa).
1428 +
1429 +**A retirer cote consommateur** : tout patch local dupliquant l'anti-rejeu TOTP.
1430 +
1144 1431 ---
1145 1432
1146 1433 ## 20. Patterns communs
@@ -1169,7 +1456,7 @@ Toutes les limites passent par [`RateLimitService`](#15-rate-limiting) avec des
1169 1456
1170 1457 | Cle | Limite | Window type | Expose via |
1171 1458 |-----|--------|-------------|------------|
1172 -| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | `check_auth_rate_limit(headers)` |
1459 +| `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 1460 | `share:{ip}` | 30/min | Fixed | `check_public_share_rate_limit*` |
1174 1461 | `pwd_change:{user_id}` | 3/hour (config) | Sliding strict | `PasswordChangeService::create_pending_request` |
1175 1462 | `pwd_reset:{email}` | 3/hour | Sliding strict | `PasswordResetService::request_reset` |
M docs/developer-guide/07-security-model.md
+277 -5
@@ -19,6 +19,10 @@ 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 | Revalidation forcée + alerte à l'ancienne adresse (RFC 0038) | [`services/user_service.rs`](../../crates/rustwarden-core/src/services/user_service.rs) |
25 +| 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 26 | É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 27 | 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 28
@@ -197,6 +201,86 @@ Les administrateurs peuvent forcer un changement de mot de passe via le flag `mu
197 201
198 202 ---
199 203
204 +## Politique de nommage des comptes (`Username`)
205 +
206 +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.
207 +
208 +### Règles appliquées par `Username::parse`
209 +
210 +| Règle | Détail |
211 +|---|---|
212 +| Longueur | 3 à 39 caractères, mesurés **après `trim()`** |
213 +| Charset | `[a-z0-9-]` uniquement (minuscules ASCII, chiffres, tiret) |
214 +| Majuscules | **Rejetées**, jamais repliées : `Alice` renvoie une erreur, il ne devient pas `alice` |
215 +| Tirets | Ni en tête ni en queue, pas de `--` consécutifs |
216 +| Noms réservés | `types::team_slug::RESERVED` + `EXTRA_RESERVED` (`me`, `explore`) |
217 +
218 +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.
219 +
220 +### Où la règle est appliquée
221 +
222 +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`).
223 +
224 +```mermaid
225 +flowchart TD
226 + REG["POST /api/v1/auth/register"] --> PARSE1["Username::parse()"]
227 + ADMIN["Handler admin / provisioning"] --> CREATE
228 + PARSE1 --> CREATE["UserService::create_user()"]
229 + CREATE --> PARSE2["Username::parse() — porte unique"]
230 + PARSE2 --> INSERT[("INSERT users")]
231 +
232 + BOOT["ensure_default_admin()"] --> ALLOW["create_user_allow_reserved()\nUsername::parse_allow_reserved()"]
233 + ALLOW --> INSERT
234 +
235 + PROFILE["update_profile(username = Some(x))"] --> SAME{"x == username courant ?"}
236 + SAME -->|Oui| NOOP["No-op — grandfathering préservé"]
237 + SAME -->|Non| PARSE3["Username::parse() + contrôle d'unicité"]
238 + PARSE3 --> UPDATE[("UPDATE users")]
239 +```
240 +
241 +`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.
242 +
243 +### Grandfathering
244 +
245 +Aucun `CHECK` SQL n'a été ajouté, aucune migration ne réécrit les lignes existantes. Conséquences :
246 +
247 +- un compte historique hors charset (`John_Doe`, `alice.bob`) **se connecte toujours** — les chemins de lecture n'appellent jamais `parse` ;
248 +- `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 ;
249 +- ce même compte ne peut pas être renommé vers une autre valeur hors charset.
250 +
251 +### Utiliser `Username` dans votre propre code
252 +
253 +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) :
254 +
255 +```rust
256 +use rustwarden_core::prelude::*; // Username, UserService, AppError
257 +
258 +async fn provision(
259 + db: &sea_orm::DatabaseConnection,
260 + raw_login: &str,
261 + email: String,
262 + password_hash: String,
263 +) -> Result<(), AppError> {
264 + // Échoue tôt, avec un message exploitable, avant tout accès base.
265 + let username = Username::parse(raw_login)?;
266 +
267 + UserService::create_user(db, username.into_inner(), email, password_hash, true).await?;
268 + Ok(())
269 +}
270 +```
271 +
272 +Pour n'afficher qu'un message de validation côté formulaire :
273 +
274 +```rust
275 +if let Err(e) = Username::parse(&form.username) {
276 + return Ok(render_form_with_error(&e.to_string()));
277 +}
278 +```
279 +
280 +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é.
281 +
282 +---
283 +
200 284 ## TOTP / 2FA
201 285
202 286 ```mermaid
@@ -244,11 +328,166 @@ L'algorithme TOTP utilisé est SHA1, 6 chiffres, période 30 secondes — confor
244 328
245 329 Référence : [`services/totp_service.rs`](../../crates/rustwarden-core/src/services/totp_service.rs)
246 330
331 +### Plafonds de tentatives (1.0.1)
332 +
333 +| Contrôle | Valeur | Constante |
334 +|---|---|---|
335 +| Tentatives par challenge | 5 | `MAX_CHALLENGE_ATTEMPTS` |
336 +| Somme des tentatives par utilisateur / 15 min | 15 | `MAX_ATTEMPTS_PER_WINDOW` |
337 +| TTL d'un challenge | 5 min | `CHALLENGE_EXPIRY_MINUTES` |
338 +| TTL de la garde anti-rejeu in-process | 90 s | `REPLAY_GUARD_TTL_SECS` |
339 +
340 +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`.
341 +
342 +### Anti-rejeu persistant — `last_used_step` (RFC 0037)
343 +
344 +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.
345 +
346 +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** :
347 +
348 +```mermaid
349 +sequenceDiagram
350 + participant R1 as Réplica 1
351 + participant R2 as Réplica 2
352 + participant DB as user_totp
353 +
354 + Note over R1,DB: Code "123456" valide pour le pas t
355 + R1->>R1: matching_totp_step(secret, code) → Some(t)
356 + R1->>DB: UPDATE ... SET last_used_step = t<br/>WHERE user_id = $id AND (last_used_step IS NULL OR last_used_step < t)
357 + DB-->>R1: rows_affected = 1 → Ok(true)
358 +
359 + Note over R2,DB: Rejeu du même code sur un autre réplica
360 + R2->>R2: matching_totp_step(secret, code) → Some(t)
361 + R2->>DB: même UPDATE conditionnel
362 + DB-->>R2: rows_affected = 0 → Ok(false)
363 +```
364 +
365 +Points importants :
366 +
367 +- 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é ;
368 +- 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 ;
369 +- 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 » ;
370 +- `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 ;
371 +- une erreur base remonte en `AppError` — le garde ne **fail jamais open** ;
372 +- la garde in-process de 1.0.1 est conservée **après** le CAS, en défense en profondeur ;
373 +- `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).
374 +
375 +### Ce que l'anti-rejeu ne garantit PAS
376 +
377 +- **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.
378 +- **Il ne protège pas contre un secret TOTP compromis.** Qui détient le secret génère des codes pour les pas suivants.
379 +- **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.
380 +- **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).
381 +
382 +---
383 +
384 +## Cycle de vie de l'e-mail vérifié
385 +
386 +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.
387 +
388 +```mermaid
389 +stateDiagram-v2
390 + [*] --> NonVerifie : register (email_verified = false)
391 + NonVerifie --> Verifie : clic sur le lien<br/>GET /api/v1/auth/verify-email/:token
392 + Verifie --> NonVerifie : changement d'adresse<br/>UserService::update_profile (RFC 0038)
393 + NonVerifie --> NonVerifie : POST /api/v1/auth/resend-verification<br/>(invalide les tokens précédents)
394 +
395 + note right of NonVerifie
396 + Prochain login refusé si
397 + validation_email_required = true
398 + (défaut, fail-closed).
399 + Session en cours NON révoquée.
400 + end note
401 +```
402 +
403 +### 1. Inscription
404 +
405 +`UserService::create_user` insère toujours `email_verified = false`. L'exception est le bootstrap : `ensure_default_admin` force `email_verified = true` sur l'administrateur par défaut, sinon le premier compte ne pourrait pas se connecter.
406 +
407 +### 2. Changement d'adresse (RFC 0038)
408 +
409 +Depuis la [RFC 0038](../rfcs/0038-email-change-revalidation.md), `UserService::update_profile` traite un changement d'adresse comme une perte de preuve de possession :
410 +
411 +| Étape | Comportement |
412 +|---|---|
413 +| Détection | Comparaison **exacte après `trim`** avec l'adresse stockée. Echo de l'adresse courante = no-op complet |
414 +| Écriture | `email`, `email_verified = false` et `email_verified_at = NULL` dans **le même** `UPDATE` |
415 +| Nouvelle adresse | Mail de validation via `EmailValidationService::send_validation_email`, qui invalide au passage les tokens de validation antérieurs |
416 +| Ancienne adresse | Alerte `account_modified`, **sans aucun lien de validation** — elle informe, elle ne permet pas d'agir |
417 +| Échec SMTP | **Best-effort** : si `EmailConfig::from_env()` échoue, les envois sont sautés avec un warning et l'`UPDATE` reste committé. L'utilisateur peut demander un lien via `POST /api/v1/auth/resend-verification` |
418 +| Logs | Adresses masquées (`mask_email` → `jo***@ex***`) — aucune adresse complète en clair dans le trail SIEM |
419 +
420 +Le chemin **admin** est couvert par construction : `PUT /admin/users/:id` de `rustwarden-ui` appelle `UserService::update_profile`, donc un administrateur qui corrige une adresse déclenche la même revalidation.
421 +
422 +L'alerte vers l'ancienne adresse est le signal d'alarme du scénario « session volée, l'attaquant change l'e-mail pour verrouiller le compte » : le propriétaire légitime est prévenu sur une adresse que l'attaquant ne contrôle pas.
423 +
424 +#### Portée exacte : le prochain login, pas la session en cours
425 +
426 +`update_profile` **ne révoque rien** : ni les refresh tokens (`refresh_tokens`), ni les JWT d'accès déjà émis. Et `refresh_token` (`auth/handlers.rs`) ne relit pas `email_verified` avant de réémettre une paire de tokens. Le passage à `email_verified = false` ne ferme donc **que la porte d'entrée** (`authenticate_user`) : la session ouverte au moment du changement reste pleinement utilisable jusqu'à l'expiration naturelle du refresh token.
427 +
428 +Conséquence côté modèle de menace : dans le scénario « session volée », remettre `email_verified = false` ne déloge pas l'attaquant. C'est l'alerte `account_modified` vers l'ancienne adresse qui reste le mécanisme de détection, et la révocation doit être déclenchée explicitement (`RefreshTokenService::revoke_all_tokens_for_user`). Une **RFC ultérieure** couvrira la révocation automatique au changement d'e-mail.
429 +
430 +#### Risque résiduel : faute de frappe = lock-out définitif
431 +
432 +| | |
433 +|---|---|
434 +| **Cause** | `update_profile` écrit immédiatement la **nouvelle** adresse dans `users.email` et passe `email_verified = false`. Aucune étape de confirmation : l'adresse saisie devient l'adresse du compte, correcte ou non. |
435 +| **Conséquence** | Le mail de validation part vers l'adresse fautive (`bob@gmial.com`). L'ancienne adresse ne reçoit qu'une alerte `account_modified`, **sans lien d'action**. Les deux voies de récupération — `POST /api/v1/auth/resend-verification` et `POST /api/v1/auth/resend-verification/by-username` — renvoient elles aussi vers l'adresse **stockée**, donc vers la faute de frappe. Avec `validation_email_required = true` (défaut), le compte devient inaccessible au prochain login. |
436 +| **Recours** | **Aucun recours self-service.** Seul un administrateur peut débloquer le compte en corrigeant l'adresse via `PUT /admin/users/{id}` (qui repasse par `update_profile` et renvoie un lien de validation à la bonne adresse). |
437 +| **Recommandation consommateur** | Ajouter un **champ de confirmation d'adresse** (double saisie) dans l'écran de profil et dans l'écran admin, et réafficher la nouvelle adresse en clair avant soumission. C'est la seule barrière disponible aujourd'hui, et elle vit côté application. |
438 +| **Suppression du risque** | La **v2 `pending_email_changes`** listée en follow-up de la [RFC 0038](../rfcs/0038-email-change-revalidation.md) — écrire l'adresse candidate dans une table d'attente et ne basculer `users.email` qu'après le clic sur le lien — supprimerait entièrement ce mode de panne : l'adresse courante resterait valide et vérifiée tant que la nouvelle n'est pas confirmée. |
439 +
440 +### 3. Où vit la politique de login (RFC 0040)
441 +
442 +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.
443 +
444 +| Fonction | Contrôle e-mail | Usage attendu |
445 +|---|---|---|
446 +| `AuthService::authenticate_user` | **Oui** | Porte d'authentification complète — à utiliser par défaut |
447 +| `AuthService::verify_credentials` | Non | Primitive « mot de passe seul » : re-prompt avant setup TOTP, ré-authentification sensible |
448 +
449 +Détails du contrôle :
450 +
451 +- réglage `validation_email_required`, alias legacy `validation_email_requise`, lu via `AppSettingsService::get_bool_with_fallback` ;
452 +- défaut `true` et **fail-closed** : une erreur de lookup en base vaut « vérification requise » (`unwrap_or(true)`) ;
453 +- 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 ;
454 +- le rejet est un `AppError::Validation` (HTTP 400) portant un message français destiné à l'utilisateur final.
455 +
456 +```rust
457 +use rustwarden_core::services::auth_service::AuthService;
458 +use rustwarden_core::error::AppError;
459 +
460 +// SSR maison : la politique s'applique, sans code supplémentaire.
461 +match AuthService::authenticate_user(&db, &form.username, &form.password).await {
462 + Ok(user) => issue_session(user).await,
463 + // Message déjà rédigé pour l'utilisateur ; proposer « renvoyer l'email ».
464 + Err(AppError::Validation(msg)) => render_login_with_error(&msg, /* show_resend = */ true),
465 + Err(AppError::InvalidCredentials) => render_login_with_error("Identifiants invalides.", false),
466 + Err(e) => Err(e),
467 +}
468 +```
469 +
470 +**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`.
471 +
247 472 ---
248 473
249 -## CSP (Content Security Policy)
474 +## En-têtes de sécurité (CSP, COOP, CORP)
250 475
251 -La politique CSP est appliquée sur toutes les réponses HTML dans [`app.rs`](../../crates/rustwarden-core/src/app.rs#L737) :
476 +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).
477 +
478 +| En-tête | Configurable | Valeur par défaut |
479 +|---|---|---|
480 +| `Content-Security-Policy` | **Oui** | voir ci-dessous |
481 +| `Cross-Origin-Opener-Policy` | **Oui**, opt-in | non émis (`None`) |
482 +| `Cross-Origin-Resource-Policy` | **Oui**, opt-in | non émis (`None`) |
483 +| `X-Content-Type-Options` | Non | `nosniff` |
484 +| `X-Frame-Options` | Non | `DENY` |
485 +| `X-XSS-Protection` | Non | `0` (auditeur legacy désactivé — il introduisait ses propres failles) |
486 +| `Referrer-Policy` | Non | `strict-origin-when-cross-origin` |
487 +| `Permissions-Policy` | Non | `camera=(), microphone=(), geolocation=()` |
488 +| `Strict-Transport-Security` | Non | `max-age=31536000; includeSubDomains`, sauf si `APP_DEBUG=true` |
489 +
490 +### CSP par défaut
252 491
253 492 ```
254 493 default-src 'self';
@@ -262,11 +501,44 @@ base-uri 'self';
262 501 form-action 'self'
263 502 ```
264 503
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.
504 +**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.
505 +
506 +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)).
507 +
508 +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.
509 +
510 +### Configurer les en-têtes
511 +
512 +```rust
513 +use rustwarden_core::prelude::*; // RustwardenApp, SecurityHeadersConfig
514 +
515 +let app = RustwardenApp::builder()
516 + .from_env()
517 + .security_headers(
518 + SecurityHeadersConfig {
519 + // App headless ou UI maison sans Alpine : on peut durcir.
520 + content_security_policy:
521 + "default-src 'self'; script-src 'self'; style-src 'self'; \
522 + img-src 'self' data:; frame-ancestors 'none'; base-uri 'self'; \
523 + form-action 'self'"
524 + .into(),
525 + ..SecurityHeadersConfig::default()
526 + }
527 + // Équivaut à COOP: same-origin + CORP: same-origin.
528 + .with_same_origin_isolation(),
529 + )
530 + .build()
531 + .await?;
532 +```
533 +
534 +Détails d'implémentation utiles à connaître :
266 535
267 -Le header `frame-ancestors 'none'` empêche le clickjacking en refusant l'intégration dans des iframes depuis n'importe quel domaine.
536 +- 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 ;
537 +- une CSP syntaxiquement invalide (caractères de contrôle) ne panique pas : elle est remplacée par le défaut avec un `warn!` ;
538 +- une valeur COOP/CORP invalide fait **sauter l'en-tête** avec un `warn!`, sans casser la réponse ;
539 +- `frame-ancestors 'none'` + `X-Frame-Options: DENY` couvrent le clickjacking sur les navigateurs modernes comme legacy.
268 540
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.
541 +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 542
271 543 ---
272 544
M docs/rfcs/0036-username-charset-validation.md
+6 -6
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / Rustwarden 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Draft
5 +- **Statut** : Accepted (2026-09-18)
6 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 7 - **Crate cible** : `rustwarden-core`
8 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)
@@ -336,11 +336,11 @@ UserService::create_user(db, username, email, hash, false).await?;
336 336
337 337 Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
338 338
339 -- [ ] **`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.
340 -- [ ] **SemVer** : breaking comportemental sans changement de signature → major, ou minor `Security` documenté ? Le processus §1 penche major.
341 -- [ ] **`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.
342 -- [ ] **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).
343 -- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
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 344
345 345 ## Tracking issue
346 346
M docs/rfcs/0037-totp-last-used-step.md
+5 -5
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Draft
5 +- **Statut** : Accepted (2026-09-18)
6 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 7 - **Crate cible** : `rustwarden-core`
8 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)).
@@ -321,10 +321,10 @@ Aucune signature publique changée.
321 321
322 322 Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
323 323
324 -- [ ] **Garde in-process après CAS** : le conserver comme court-circuit (recommandé) ou le retirer une fois la colonne livrée (moins de chemins) ?
325 -- [ ] **`verify_and_enable`** : confirmer qu’il **doit** `claim_totp_step` (recommandé) pour bloquer le replay setup→login.
326 -- [ ] **SemVer** : minor Security vs major ? Signatures stables, schéma additif nullable.
327 -- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
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 328
329 329 ## Tracking issue
330 330
M docs/rfcs/0038-email-change-revalidation.md
+6 -6
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Draft
5 +- **Statut** : Accepted (2026-09-18)
6 6 - **Apps consommatrices concernées** : Gitrust ; `rustwarden-ui` (`PUT /settings/profile`, `PUT /admin/users/:id`) ; tout consommateur qui appelle `UserService::update_profile`
7 7 - **Crate cible** : `rustwarden-core`
8 8 - **Origine** : plan [`docs/plans/2026-09-17-securisation-upstream-audit-gitrust.md`](../plans/2026-09-17-securisation-upstream-audit-gitrust.md) (Task 15)
@@ -235,11 +235,11 @@ UserService::update_profile(db, user_id, None, Some(new_email)).await?;
235 235
236 236 Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
237 237
238 -- [ ] **Type de queue de l’alerte** : `"email_changed"` dédié vs `"account_modified"` existant (`AccountModifiedTemplate`) ?
239 -- [ ] **Trim / casse** : comparaison exacte (recommandé, aligné unicité PostgreSQL sensible à la casse) vs fold ASCII ?
240 -- [ ] **Admin un-verify** : confirmer le même contrat (recommandé) plutôt qu’un bypass `email_verified=true` réservé admin.
241 -- [ ] **v2** : confirmer follow-up (cette RFC, score v1 ≥ 25) plutôt que fusionner les deux.
242 -- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
238 +- [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.**
239 +- [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).**
240 +- [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.**
241 +- [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.**
242 +- [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.**
243 243
244 244 ## Tracking issue
245 245
M docs/rfcs/0039-security-headers-config.md
+5 -5
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Draft
5 +- **Statut** : Accepted (2026-09-18)
6 6 - **Apps consommatrices concernées** : Gitrust (pourra retirer `SetResponseHeaderLayer::overriding`) ; `rustwarden-ui` / `rustwarden-app` ; tout consommateur de `RustwardenApp::builder()`
7 7 - **Crate cible** : `rustwarden-core`
8 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).
@@ -338,10 +338,10 @@ RustwardenApp::builder()
338 338
339 339 Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
340 340
341 -- [ ] **Re-export prelude** : `SecurityHeadersConfig` dans `prelude` ou seulement `config::security_headers` ?
342 -- [ ] **Env `CSP=`** : hors v1 (recommandé) vs lu par `from_env()` ?
343 -- [ ] **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é.
344 -- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
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 345
346 346 ## Tracking issue
347 347
M docs/rfcs/0040-authenticate-user-email-verified.md
+5 -5
@@ -2,7 +2,7 @@
2 2
3 3 - **Auteur** : EBO (origine audit Gitrust / patch 1.0.1)
4 4 - **Date** : 2026-09-18
5 -- **Statut** : Draft
5 +- **Statut** : Accepted (2026-09-18)
6 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 7 - **Crate cible** : `rustwarden-core`
8 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.
@@ -293,10 +293,10 @@ let user = AuthService::authenticate_user(db, identifier, password).await?;
293 293
294 294 Liste des points en suspens. La RFC ne peut pas être acceptée tant qu’ils ne sont pas résolus :
295 295
296 -- [ ] **`hooks` sur `authenticate_user`** : rester `log_blocked_login(..., None)` (recommandé v1, signature stable) vs `Option<&Arc<dyn RustwardenHooks>>` (breaking ou surcharge) ?
297 -- [ ] **`AppError::EmailNotVerified`** : garder `Validation` (compat UI) vs variante dédiée (meilleur i18n, breaking JSON) ?
298 -- [ ] **SemVer** : minor Security vs major (comportement service) ?
299 -- [ ] **Tracking GitHub** : ouvrir l’issue avant `Under Review`.
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 300
301 301 ## Tracking issue
302 302
M docs/rfcs/README.md
+5 -5
@@ -92,11 +92,11 @@ Score minimum **25/35** pour passer en `Accepted`. Voir grille détaillée dans
92 92 | [0033](0033-fediverse-delete-actor-hooks-unification.md) | Fediverse — unifier `apply_delete_actor*` derrière `DeleteActorHooks` | **Implemented** | EBO | 2026-05-17 |
93 93 | [0034](0034-edition-2024-fmt-cleanup.md) | Workspace — cleanup rustfmt après migration édition 2024 | **Draft** | EBO (gitrust) | 2026-05-18 |
94 94 | [0035](0035-oauth-pkce-code-verifier.md) | OAuth — envoyer `code_verifier` PKCE à l'échange (GitHub & Discord) | **Implemented** (2026-06-18) | PasterMan | 2026-06-18 |
95 -| [0036](0036-username-charset-validation.md) | Auth — newtype `Username` (charset GitHub-like, audit M-1) | **Draft** | 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) | **Draft** | 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` | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-18 |
98 -| [0039](0039-security-headers-config.md) | HTTP — `SecurityHeadersConfig` (CSP / COOP / CORP additifs) | **Draft** | 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` | **Draft** | EBO (origine audit Gitrust / patch 1.0.1) | 2026-09-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` | **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 100
101 101 À mesure que les RFCs sont créées, elles doivent apparaître dans cette table avec leur statut courant.
102 102
M tools/rustwarden-cli/README.md
+7 -1
@@ -190,6 +190,9 @@ cargo run -p rustwarden-cli -- update-project
190 190 | `locales/` | Remplace |
191 191 | `static/` | Remplace (si le projet a une UI) |
192 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 |
193 196
194 197 ### Fichiers jamais touches
195 198
@@ -217,7 +220,10 @@ cargo clippy -- -D warnings
217 220 # 3. Verifier les migrations
218 221 cargo run --bin migrate
219 222
220 -# 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
221 227 cargo test
222 228 ```
223 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()))