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())) |
GitRust