Add teams Force 2FA Rustdoc en anglais
Eric BOUHANA <eric@ebii.fr> committé le 2026-05-03 18:42
97461c8c2acd0df379ef9d589bc25049124236df
1 parent(s)
164 fichiers modifiés
+16223
-3098
M
.gitignore
+5
-0
@@ -3,6 +3,7 @@
| 3 | 3 | **/*.rs.bk |
| 4 | 4 | # Cargo.lock : à commiter pour les binaires/applications (recommandation Rust) |
| 5 | 5 | |
| 6 | +/node_modules | |
| 6 | 7 | # Environment & secrets (constitution: no secrets in repo) |
| 7 | 8 | .env |
| 8 | 9 | .env.* |
@@ -16,6 +17,10 @@
| 16 | 17 | *.swo |
| 17 | 18 | *~ |
| 18 | 19 | .vscode/ |
| 20 | +.claude/ | |
| 21 | +.omc/ | |
| 22 | +CLAUDE.md | |
| 23 | + | |
| 19 | 24 | # Uncomment to share workspace settings: !.vscode/settings.json |
| 20 | 25 | |
| 21 | 26 | # OS |
D
CLAUDE.md
+0
-179
@@ -1,179 +0,0 @@
| 1 | -# CLAUDE.md | |
| 2 | - | |
| 3 | -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. | |
| 4 | - | |
| 5 | -## Project Overview | |
| 6 | - | |
| 7 | -Rustwarden est un **framework réutilisable** qui fournit la couche authentification, gestion utilisateurs, RBAC, OAuth/SSO, email et admin UI à d'autres applications Rust. Il sert de fondation à plusieurs projets (ex: PasterMan). Le CLI wizard (`rustwarden-cli start-project`) génère des projets autonomes qui consomment le framework. | |
| 8 | - | |
| 9 | -**Principe fondamental** : toute modification doit préserver la réutilisabilité du framework. Les changements dans `rustwarden-core` et `rustwarden-ui` impactent tous les projets consommateurs. | |
| 10 | - | |
| 11 | -## Workspace Structure | |
| 12 | - | |
| 13 | -- **`crates/rustwarden-core/`** — Coeur du framework : auth, services, middleware, models (SeaORM), migrations, i18n, background tasks, hooks | |
| 14 | -- **`crates/rustwarden-ui/`** — UI par défaut : templates Askama, handlers, routes, DTOs, static assets | |
| 15 | -- **`app/`** — Application de référence (12 lignes, sert de démo/test du builder) | |
| 16 | -- **`tools/rustwarden-cli/`** — CLI wizard interactif pour scaffolder de nouveaux projets consommateurs | |
| 17 | - | |
| 18 | -### Comment les apps consomment le framework | |
| 19 | - | |
| 20 | -```rust | |
| 21 | -// app/src/main.rs — usage minimal | |
| 22 | -RustwardenApp::builder() | |
| 23 | - .from_env() | |
| 24 | - .with_ui(rustwarden_ui::default_routes) | |
| 25 | - .build().await? | |
| 26 | - .run().await | |
| 27 | - | |
| 28 | -// Usage avancé (projet consommateur type) | |
| 29 | -let db = rustwarden_core::config::database::create_connection().await?; | |
| 30 | -my_app::migrations::run_migrations(&db).await?; // migrations métier d'abord | |
| 31 | -RustwardenApp::builder() | |
| 32 | - .from_env() | |
| 33 | - .database(db) | |
| 34 | - .auto_migrate(false) // DB déjà migrée | |
| 35 | - .with_ui(rustwarden_ui::default_routes) | |
| 36 | - .merge_routes(my_app_routes) // routes métier ajoutées | |
| 37 | - .hooks(MyAppHooks) // hooks lifecycle | |
| 38 | - .build().await? | |
| 39 | - .run().await | |
| 40 | -``` | |
| 41 | - | |
| 42 | -Les apps consommatrices peuvent **surcharger** les routes framework (les routes custom mergées après prennent priorité) et **surcharger** les templates en créant leurs propres templates + handlers. | |
| 43 | - | |
| 44 | -## Build & Development Commands | |
| 45 | - | |
| 46 | -```bash | |
| 47 | -# Build | |
| 48 | -cargo build # Full workspace | |
| 49 | -cargo build -p rustwarden-core # Single crate | |
| 50 | -cargo check # Quick compilation check (no codegen) | |
| 51 | - | |
| 52 | -# Run | |
| 53 | -cargo run -p rustwarden-app # Reference app (port 3000) | |
| 54 | -cargo run -p rustwarden-cli -- start-project # Project scaffolder | |
| 55 | - | |
| 56 | -# Tests | |
| 57 | -cargo test # All tests | |
| 58 | -cargo test -p rustwarden-core # Single crate | |
| 59 | -cargo test test_name # Single test | |
| 60 | - | |
| 61 | -# Quality gates (must all pass) | |
| 62 | -cargo clippy -- -D warnings | |
| 63 | -cargo fmt --check | |
| 64 | -cargo doc # Must generate without warnings | |
| 65 | - | |
| 66 | -# Database binaries (in rustwarden-core) | |
| 67 | -cargo run --bin migrate | |
| 68 | -cargo run --bin seed # Create/update default admin | |
| 69 | -cargo run --bin hash_password | |
| 70 | -cargo run --bin change_password -- admin "NewPassword123!" | |
| 71 | - | |
| 72 | -# Database (Docker) | |
| 73 | -cd database && docker compose up -d # PostgreSQL 18 | |
| 74 | - | |
| 75 | -# CSS (Tailwind + DaisyUI) | |
| 76 | -npm install | |
| 77 | -npm run build:css # One-shot build | |
| 78 | -npm run build:css:watch # Watch mode | |
| 79 | -npm run build:css:min # Minified | |
| 80 | -``` | |
| 81 | - | |
| 82 | -## Tech Stack | |
| 83 | - | |
| 84 | -- **Rust 1.91.1+** (Edition 2024), **Axum 0.8**, **Tokio 1.x**, **SeaORM 2.0.0-rc.20** | |
| 85 | -- **PostgreSQL 18+** (via Docker in `database/`) | |
| 86 | -- **Templates:** Askama 0.15 — **Frontend:** Alpine.js + HTMX (CDN) — **CSS:** Tailwind 4 + DaisyUI 5 | |
| 87 | -- **Auth:** JWT (jsonwebtoken), bcrypt, tower-sessions (memory/redis) | |
| 88 | -- **OAuth** (feature-gated): `openidconnect` — Google, GitHub, Discord, Microsoft | |
| 89 | -- **Email:** lettre (SMTP), imap (bounce detection) | |
| 90 | -- **i18n:** YAML files in `/locales/` (fr, en, de, es). Default locale: fr | |
| 91 | - | |
| 92 | -## Architecture Key Points | |
| 93 | - | |
| 94 | -- **Builder pattern:** `RustwardenApp::builder()` — configure DB, routes, hooks, middleware, i18n, background tasks | |
| 95 | -- **Service layer:** Business logic in `services/`, handlers call services, services call SeaORM | |
| 96 | -- **Middleware stack:** auth validation → CSRF → rate limiting → admin check | |
| 97 | -- **Hooks system:** `RustwardenHooks` trait — lifecycle events que les apps consommatrices implémentent (on_user_registered, on_user_login, on_password_changed, on_oauth_login, on_resource_shared...) | |
| 98 | -- **Feature flags:** `oauth` (OpenID Connect), `redis-session` (Redis session backend) | |
| 99 | -- **Background tasks:** Email queue, JWT cleanup, password change cleanup, IMAP bounce — démarrés automatiquement au build | |
| 100 | -- **19 SeaORM entities** in `models/` with 5 migrations | |
| 101 | -- **Config:** Variables d'environnement (voir `.env.sample`). OAuth secrets chiffrés AES-256-GCM en DB | |
| 102 | -- **Extensibilité:** Les apps consommatrices étendent via `merge_routes()`, `hooks()`, et surcharge de templates | |
| 103 | - | |
| 104 | -## Framework Constraints — Impact sur les consommateurs | |
| 105 | - | |
| 106 | -Avant toute modification, considérer : | |
| 107 | - | |
| 108 | -1. **API publique** : les types exportés via `prelude` (`RustwardenApp`, `AuthUser`, `AppError`, `Claims`, etc.) et les traits (`RustwardenHooks`, `OAuthProvider`) sont l'interface du framework. Tout changement de signature est un breaking change pour les consommateurs. | |
| 109 | - | |
| 110 | -2. **Migrations** : les apps consommatrices appellent `core_migrations()` depuis `src/migrations.rs`, **pas** le `Migrator` de `migrations/src/lib.rs`. Les deux fichiers doivent rester synchronisés (voir "Adding Features"). | |
| 111 | - | |
| 112 | -3. **Templates** : les apps consommatrices peuvent étendre `base.html` et inclure les composants (`navbar.html`, `sidebar.html`). Les variables de `LayoutContext` (`page_title`, `app_name`, `app_version`, `csrf_token`) sont un contrat. | |
| 113 | - | |
| 114 | -4. **CLI wizard** : `tools/rustwarden-cli/src/templates.rs` génère le code des nouveaux projets. Si l'API du builder ou les imports changent, mettre à jour les templates du wizard. | |
| 115 | - | |
| 116 | -## Handler Patterns | |
| 117 | - | |
| 118 | -Each handler file in `rustwarden-ui/src/handlers/` follows this structure: | |
| 119 | - | |
| 120 | -- Template struct with `#[derive(Template)]` and `#[template(path = "...")]` | |
| 121 | -- Required base variables (contrat `base.html`): `page_title`, `app_name`, `app_version`, `csrf_token` | |
| 122 | -- Authenticated templates add: `username`, `email`, `initials`, `avatar_color`, `roles_text`, `nav_items`, `app_domain`, `app_description` | |
| 123 | -- Auth extractors: `Extension(claims): Extension<Claims>` (preferred) or `AuthUser` | |
| 124 | -- Services take `&DatabaseConnection` as first parameter, return `Result<T, AppError>` | |
| 125 | - | |
| 126 | -## Frontend Stack — Tailwind CSS v4 + DaisyUI v5 | |
| 127 | - | |
| 128 | -Couleurs CSS variables au format DaisyUI v5 : | |
| 129 | - | |
| 130 | -- `var(--color-primary)`, `var(--color-base-100)`, `var(--color-base-content)`, etc. | |
| 131 | -- Ne PAS utiliser l'ancienne syntaxe DaisyUI v3/v4 : ~~`oklch(var(--p))`~~, ~~`oklch(var(--bc))`~~ | |
| 132 | -- Pour les opacités/mélanges : `color-mix(in oklab, var(--color-primary) 35%, transparent)` | |
| 133 | - | |
| 134 | -## HTMX — Principes | |
| 135 | - | |
| 136 | -Documentation : https://htmx.fr/ | |
| 137 | - | |
| 138 | -**Philosophie** : privilégier les attributs HTMX au JavaScript pour toute interaction serveur renvoyant du HTML. Réserver le JS (ou Alpine.js) uniquement aux cas où HTMX ne suffit pas : API JSON, Clipboard API, manipulation DOM purement client-side. | |
| 139 | - | |
| 140 | -| Cas | Utiliser | | |
| 141 | -|-----|----------| | |
| 142 | -| Formulaire → réponse HTML | HTMX (`hx-post` + `hx-target`) | | |
| 143 | -| Confirmation avant action | HTMX (`hx-confirm`) | | |
| 144 | -| Suppression avec feedback | HTMX (`hx-delete` + `hx-swap="outerHTML"`) | | |
| 145 | -| Pagination / chargement | HTMX (`hx-get` + `hx-swap`) | | |
| 146 | -| API JSON (login, register) | Alpine.js / JS (`fetch`) | | |
| 147 | -| Clipboard, drag & drop | JS natif | | |
| 148 | -| Toggle CSS / état UI local | Alpine.js (`x-data`, `@click`) | | |
| 149 | - | |
| 150 | -## Adding Features | |
| 151 | - | |
| 152 | -- **New service**: service in `rustwarden-core/src/services/` → declare in `mod.rs` | |
| 153 | -- **New entity**: migration → SeaORM model in `models/` → service → declare in `mod.rs` files | |
| 154 | -- **New page (UI)**: Askama template in `rustwarden-ui/templates/` → Template struct in handler → route in `routes.rs` | |
| 155 | -- **New middleware**: implement in `rustwarden-core/src/middleware/` → wire into app builder | |
| 156 | -- **New migration**: `m{YYYYMMDD}_{NNNNNN}_{description}.rs` — must be declared in **both** `migrations/src/lib.rs` **and** `src/migrations.rs` (`core_migrations()`) to be executed by consumer apps. Missing one breaks either `sea-orm-cli` or production consumer apps. | |
| 157 | -- **New builder option / public type**: update `prelude` re-exports in `lib.rs` + update CLI wizard templates in `tools/rustwarden-cli/src/templates.rs` | |
| 158 | - | |
| 159 | -## Code Conventions (from constitution.md) | |
| 160 | - | |
| 161 | -- **All public functions** must have `///` doc comments | |
| 162 | -- **No `unwrap()`/`expect()`** in production code (only in tests). Use `Result<T, AppError>` with `?` | |
| 163 | -- **No raw SQL** — all DB access via SeaORM. Consult SeaORM docs for patterns | |
| 164 | -- **No `unsafe`** without `// SAFETY:` justification | |
| 165 | -- **No stub handlers** — every route must have a complete, functional implementation | |
| 166 | -- **Zero clippy warnings:** `cargo clippy -- -D warnings` | |
| 167 | -- **`cargo fmt`** before every commit | |
| 168 | -- **Test coverage target:** >80% overall, >90% for auth/security/core services | |
| 169 | -- **ANSSI compliance:** See `docs/ANSSI_COMPLIANCE_REPORT.md` | |
| 170 | - | |
| 171 | -## Documentation | |
| 172 | - | |
| 173 | -- **`DEVELOPER.md`** — Guide de référence complet (70KB+) : architecture, ajout de routes/models/services, personnalisation templates, auth, RBAC, OAuth, hooks, i18n, tâches de fond, tests, production | |
| 174 | -- **`constitution.md`** — Règles du projet, principes de sécurité, contraintes techniques | |
| 175 | -- **`docs/ANSSI_COMPLIANCE_REPORT.md`** — Rapport de conformité sécurité | |
| 176 | - | |
| 177 | -## Language | |
| 178 | - | |
| 179 | -La documentation du projet (constitution, guide développeur, commentaires) est en **français**. Les identifiants de code et les chemins API sont en anglais. |
M
Cargo.lock
+154
-0
@@ -3,6 +3,12 @@
| 3 | 3 | version = 4 |
| 4 | 4 | |
| 5 | 5 | [[package]] |
| 6 | +name = "adler2" | |
| 7 | +version = "2.0.1" | |
| 8 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 9 | +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" | |
| 10 | + | |
| 11 | +[[package]] | |
| 6 | 12 | name = "aead" |
| 7 | 13 | version = "0.5.2" |
| 8 | 14 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -542,6 +548,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 542 | 548 | checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" |
| 543 | 549 | |
| 544 | 550 | [[package]] |
| 551 | +name = "base32" | |
| 552 | +version = "0.5.1" | |
| 553 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 554 | +checksum = "022dfe9eb35f19ebbcb51e0b40a5ab759f46ad60cadf7297e0bd085afb50e076" | |
| 555 | + | |
| 556 | +[[package]] | |
| 545 | 557 | name = "base64" |
| 546 | 558 | version = "0.13.1" |
| 547 | 559 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -705,12 +717,24 @@ dependencies = [
| 705 | 717 | ] |
| 706 | 718 | |
| 707 | 719 | [[package]] |
| 720 | +name = "bytemuck" | |
| 721 | +version = "1.25.0" | |
| 722 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 723 | +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" | |
| 724 | + | |
| 725 | +[[package]] | |
| 708 | 726 | name = "byteorder" |
| 709 | 727 | version = "1.5.0" |
| 710 | 728 | source = "registry+https://github.com/rust-lang/crates.io-index" |
| 711 | 729 | checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" |
| 712 | 730 | |
| 713 | 731 | [[package]] |
| 732 | +name = "byteorder-lite" | |
| 733 | +version = "0.1.0" | |
| 734 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 735 | +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" | |
| 736 | + | |
| 737 | +[[package]] | |
| 714 | 738 | name = "bytes" |
| 715 | 739 | version = "1.11.1" |
| 716 | 740 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -877,6 +901,12 @@ dependencies = [
| 877 | 901 | ] |
| 878 | 902 | |
| 879 | 903 | [[package]] |
| 904 | +name = "constant_time_eq" | |
| 905 | +version = "0.3.1" | |
| 906 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 907 | +checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" | |
| 908 | + | |
| 909 | +[[package]] | |
| 880 | 910 | name = "cookie" |
| 881 | 911 | version = "0.18.1" |
| 882 | 912 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -950,6 +980,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 950 | 980 | checksum = "338089f42c427b86394a5ee60ff321da23a5c89c9d89514c829687b26359fcff" |
| 951 | 981 | |
| 952 | 982 | [[package]] |
| 983 | +name = "crc32fast" | |
| 984 | +version = "1.5.0" | |
| 985 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 986 | +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" | |
| 987 | +dependencies = [ | |
| 988 | + "cfg-if", | |
| 989 | +] | |
| 990 | + | |
| 991 | +[[package]] | |
| 953 | 992 | name = "crossbeam-queue" |
| 954 | 993 | version = "0.3.12" |
| 955 | 994 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1335,6 +1374,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 1335 | 1374 | checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" |
| 1336 | 1375 | |
| 1337 | 1376 | [[package]] |
| 1377 | +name = "fdeflate" | |
| 1378 | +version = "0.3.7" | |
| 1379 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1380 | +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" | |
| 1381 | +dependencies = [ | |
| 1382 | + "simd-adler32", | |
| 1383 | +] | |
| 1384 | + | |
| 1385 | +[[package]] | |
| 1338 | 1386 | name = "ff" |
| 1339 | 1387 | version = "0.13.1" |
| 1340 | 1388 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -1357,6 +1405,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 1357 | 1405 | checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" |
| 1358 | 1406 | |
| 1359 | 1407 | [[package]] |
| 1408 | +name = "flate2" | |
| 1409 | +version = "1.1.9" | |
| 1410 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1411 | +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" | |
| 1412 | +dependencies = [ | |
| 1413 | + "crc32fast", | |
| 1414 | + "miniz_oxide", | |
| 1415 | +] | |
| 1416 | + | |
| 1417 | +[[package]] | |
| 1360 | 1418 | name = "float-cmp" |
| 1361 | 1419 | version = "0.9.0" |
| 1362 | 1420 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -2064,6 +2122,19 @@ dependencies = [
| 2064 | 2122 | ] |
| 2065 | 2123 | |
| 2066 | 2124 | [[package]] |
| 2125 | +name = "image" | |
| 2126 | +version = "0.25.10" | |
| 2127 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2128 | +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" | |
| 2129 | +dependencies = [ | |
| 2130 | + "bytemuck", | |
| 2131 | + "byteorder-lite", | |
| 2132 | + "moxcms", | |
| 2133 | + "num-traits", | |
| 2134 | + "png", | |
| 2135 | +] | |
| 2136 | + | |
| 2137 | +[[package]] | |
| 2067 | 2138 | name = "imap" |
| 2068 | 2139 | version = "2.4.1" |
| 2069 | 2140 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -2474,6 +2545,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
| 2474 | 2545 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" |
| 2475 | 2546 | |
| 2476 | 2547 | [[package]] |
| 2548 | +name = "miniz_oxide" | |
| 2549 | +version = "0.8.9" | |
| 2550 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2551 | +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" | |
| 2552 | +dependencies = [ | |
| 2553 | + "adler2", | |
| 2554 | + "simd-adler32", | |
| 2555 | +] | |
| 2556 | + | |
| 2557 | +[[package]] | |
| 2477 | 2558 | name = "mio" |
| 2478 | 2559 | version = "1.1.1" |
| 2479 | 2560 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -2485,6 +2566,16 @@ dependencies = [
| 2485 | 2566 | ] |
| 2486 | 2567 | |
| 2487 | 2568 | [[package]] |
| 2569 | +name = "moxcms" | |
| 2570 | +version = "0.8.1" | |
| 2571 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2572 | +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" | |
| 2573 | +dependencies = [ | |
| 2574 | + "num-traits", | |
| 2575 | + "pxfm", | |
| 2576 | +] | |
| 2577 | + | |
| 2578 | +[[package]] | |
| 2488 | 2579 | name = "native-tls" |
| 2489 | 2580 | version = "0.2.18" |
| 2490 | 2581 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -2941,6 +3032,19 @@ dependencies = [
| 2941 | 3032 | ] |
| 2942 | 3033 | |
| 2943 | 3034 | [[package]] |
| 3035 | +name = "png" | |
| 3036 | +version = "0.18.1" | |
| 3037 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3038 | +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" | |
| 3039 | +dependencies = [ | |
| 3040 | + "bitflags 2.11.0", | |
| 3041 | + "crc32fast", | |
| 3042 | + "fdeflate", | |
| 3043 | + "flate2", | |
| 3044 | + "miniz_oxide", | |
| 3045 | +] | |
| 3046 | + | |
| 3047 | +[[package]] | |
| 2944 | 3048 | name = "polyval" |
| 2945 | 3049 | version = "0.6.2" |
| 2946 | 3050 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -3079,6 +3183,29 @@ dependencies = [
| 3079 | 3183 | ] |
| 3080 | 3184 | |
| 3081 | 3185 | [[package]] |
| 3186 | +name = "pxfm" | |
| 3187 | +version = "0.1.28" | |
| 3188 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3189 | +checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" | |
| 3190 | + | |
| 3191 | +[[package]] | |
| 3192 | +name = "qrcodegen" | |
| 3193 | +version = "1.8.0" | |
| 3194 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3195 | +checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142" | |
| 3196 | + | |
| 3197 | +[[package]] | |
| 3198 | +name = "qrcodegen-image" | |
| 3199 | +version = "1.5.1" | |
| 3200 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3201 | +checksum = "99530e45ded4640c0eab5420fc60f9a0ec1be51a22e49cc8578b9a0d8be70712" | |
| 3202 | +dependencies = [ | |
| 3203 | + "base64 0.22.1", | |
| 3204 | + "image", | |
| 3205 | + "qrcodegen", | |
| 3206 | +] | |
| 3207 | + | |
| 3208 | +[[package]] | |
| 3082 | 3209 | name = "quinn" |
| 3083 | 3210 | version = "0.11.9" |
| 3084 | 3211 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -3580,6 +3707,7 @@ dependencies = [
| 3580 | 3707 | "bcrypt", |
| 3581 | 3708 | "chrono", |
| 3582 | 3709 | "dotenvy", |
| 3710 | + "futures", | |
| 3583 | 3711 | "hex", |
| 3584 | 3712 | "hyper", |
| 3585 | 3713 | "hyper-util", |
@@ -3600,6 +3728,8 @@ dependencies = [
| 3600 | 3728 | "thiserror 2.0.18", |
| 3601 | 3729 | "time", |
| 3602 | 3730 | "tokio", |
| 3731 | + "tokio-stream", | |
| 3732 | + "totp-rs", | |
| 3603 | 3733 | "tower", |
| 3604 | 3734 | "tower-sessions", |
| 3605 | 3735 | "tower-sessions-redis-store", |
@@ -4084,6 +4214,12 @@ dependencies = [
| 4084 | 4214 | ] |
| 4085 | 4215 | |
| 4086 | 4216 | [[package]] |
| 4217 | +name = "simd-adler32" | |
| 4218 | +version = "0.3.9" | |
| 4219 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4220 | +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" | |
| 4221 | + | |
| 4222 | +[[package]] | |
| 4087 | 4223 | name = "simdutf8" |
| 4088 | 4224 | version = "0.1.5" |
| 4089 | 4225 | source = "registry+https://github.com/rust-lang/crates.io-index" |
@@ -4666,6 +4802,7 @@ dependencies = [
| 4666 | 4802 | "futures-core", |
| 4667 | 4803 | "pin-project-lite", |
| 4668 | 4804 | "tokio", |
| 4805 | + "tokio-util", | |
| 4669 | 4806 | ] |
| 4670 | 4807 | |
| 4671 | 4808 | [[package]] |
@@ -4712,6 +4849,23 @@ dependencies = [
| 4712 | 4849 | ] |
| 4713 | 4850 | |
| 4714 | 4851 | [[package]] |
| 4852 | +name = "totp-rs" | |
| 4853 | +version = "5.7.1" | |
| 4854 | +source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4855 | +checksum = "a2b36a9dd327e9f401320a2cb4572cc76ff43742bcfc3291f871691050f140ba" | |
| 4856 | +dependencies = [ | |
| 4857 | + "base32", | |
| 4858 | + "constant_time_eq", | |
| 4859 | + "hmac", | |
| 4860 | + "qrcodegen-image", | |
| 4861 | + "rand 0.9.2", | |
| 4862 | + "sha1", | |
| 4863 | + "sha2", | |
| 4864 | + "url", | |
| 4865 | + "urlencoding", | |
| 4866 | +] | |
| 4867 | + | |
| 4868 | +[[package]] | |
| 4715 | 4869 | name = "tower" |
| 4716 | 4870 | version = "0.5.3" |
| 4717 | 4871 | source = "registry+https://github.com/rust-lang/crates.io-index" |
M
Cargo.toml
+3
-0
@@ -9,3 +9,6 @@ resolver = "2"
| 9 | 9 | |
| 10 | 10 | [workspace.lints.clippy] |
| 11 | 11 | empty_line_after_doc_comments = "allow" |
| 12 | + | |
| 13 | +[workspace.lints.rust] | |
| 14 | +missing_docs = "warn" |
M
DEVELOPER.md
+178
-7
@@ -20,12 +20,13 @@ Ce document est le guide de reference pour comprendre, etendre et contribuer au
| 20 | 20 | 12. [Hooks : intercepter les evenements](#hooks--intercepter-les-evenements) |
| 21 | 21 | 13. [Internationalisation (i18n)](#internationalisation-i18n) |
| 22 | 22 | 14. [Taches de fond](#taches-de-fond) |
| 23 | -15. [Middleware](#middleware) | |
| 24 | -16. [Configuration](#configuration) | |
| 25 | -17. [Commandes binaires](#commandes-binaires) | |
| 26 | -18. [Le CLI wizard](#le-cli-wizard) | |
| 27 | -19. [Tests](#tests) | |
| 28 | -20. [Production](#production) | |
| 23 | +15. [Rate limiting et notifications temps-reel](#rate-limiting-et-notifications-temps-reel) | |
| 24 | +16. [Middleware](#middleware) | |
| 25 | +17. [Configuration](#configuration) | |
| 26 | +18. [Commandes binaires](#commandes-binaires) | |
| 27 | +19. [Le CLI wizard](#le-cli-wizard) | |
| 28 | +20. [Tests](#tests) | |
| 29 | +21. [Production](#production) | |
| 29 | 30 | |
| 30 | 31 | --- |
| 31 | 32 |
@@ -1418,15 +1419,74 @@ RustwardenApp::builder()
| 1418 | 1419 | | `on_user_registered` | `(db, user_id, username)` | Apres inscription reussie | |
| 1419 | 1420 | | `on_user_login` | `(db, user_id, username)` | Apres login reussi | |
| 1420 | 1421 | | `on_user_logout` | `(db, user_id)` | Apres deconnexion | |
| 1422 | +| `on_user_deleted` | `(db, user_id)` | Apres suppression d'un utilisateur | | |
| 1421 | 1423 | | `on_password_changed` | `(db, user_id)` | Apres changement de mot de passe | |
| 1422 | 1424 | | `on_email_verified` | `(db, user_id)` | Apres verification email | |
| 1423 | 1425 | | `on_role_changed` | `(db, user_id, roles)` | Apres modification des roles | |
| 1426 | +| `on_oauth_login` | `(db, user_id, provider, is_new_user)` | Apres login OAuth reussi | | |
| 1427 | +| `on_totp_enabled` | `(db, user_id)` | Apres activation 2FA/TOTP | | |
| 1428 | +| `on_totp_disabled` | `(db, user_id)` | Apres desactivation 2FA/TOTP | | |
| 1429 | +| `on_resource_registered` | `(db, type, id, owner)` | Apres enregistrement d'une ressource | | |
| 1430 | +| `on_resource_unregistered` | `(db, type, id)` | Apres desenregistrement | | |
| 1424 | 1431 | | `on_resource_shared` | `(db, type, id, user, level, by)` | Apres partage d'une ressource | |
| 1425 | 1432 | | `on_resource_unshared` | `(db, type, id, user)` | Apres revocation d'un partage | |
| 1426 | -| `on_oauth_login` | `(db, user_id, provider, is_new_user)` | Apres login OAuth reussi | | |
| 1433 | +| `on_team_created` | `(db, team_id, creator_id)` | Apres creation d'une team | | |
| 1434 | +| `on_team_deleted` | `(db, team_id)` | Apres suppression d'une team | | |
| 1435 | +| `on_team_member_added` | `(db, team_id, user_id, role)` | Apres ajout d'un membre | | |
| 1436 | +| `on_team_member_removed` | `(db, team_id, user_id)` | Apres retrait d'un membre | | |
| 1437 | +| `on_audit_event` | `(db, event)` | Tout evenement audit (auth, admin, acces...) | | |
| 1427 | 1438 | |
| 1428 | 1439 | Tous les hooks ont une implementation par defaut (no-op), implementez uniquement ceux dont vous avez besoin. |
| 1429 | 1440 | |
| 1441 | +### Exemple : hooks teams pour un audit log | |
| 1442 | + | |
| 1443 | +```rust | |
| 1444 | +use async_trait::async_trait; | |
| 1445 | +use rustwarden_core::hooks::RustwardenHooks; | |
| 1446 | +use sea_orm::DatabaseConnection; | |
| 1447 | +use uuid::Uuid; | |
| 1448 | + | |
| 1449 | +struct AuditHooks; | |
| 1450 | + | |
| 1451 | +#[async_trait] | |
| 1452 | +impl RustwardenHooks for AuditHooks { | |
| 1453 | + async fn on_team_created( | |
| 1454 | + &self, | |
| 1455 | + _db: &DatabaseConnection, | |
| 1456 | + team_id: Uuid, | |
| 1457 | + creator_id: Uuid, | |
| 1458 | + ) -> anyhow::Result<()> { | |
| 1459 | + tracing::info!(%team_id, %creator_id, "team created"); | |
| 1460 | + // Ici : insert dans votre table audit_log métier | |
| 1461 | + Ok(()) | |
| 1462 | + } | |
| 1463 | + | |
| 1464 | + async fn on_team_member_added( | |
| 1465 | + &self, | |
| 1466 | + _db: &DatabaseConnection, | |
| 1467 | + team_id: Uuid, | |
| 1468 | + user_id: Uuid, | |
| 1469 | + role: &str, | |
| 1470 | + ) -> anyhow::Result<()> { | |
| 1471 | + tracing::info!(%team_id, %user_id, role, "team member added"); | |
| 1472 | + Ok(()) | |
| 1473 | + } | |
| 1474 | + | |
| 1475 | + async fn on_team_member_removed( | |
| 1476 | + &self, | |
| 1477 | + _db: &DatabaseConnection, | |
| 1478 | + team_id: Uuid, | |
| 1479 | + user_id: Uuid, | |
| 1480 | + ) -> anyhow::Result<()> { | |
| 1481 | + tracing::info!(%team_id, %user_id, "team member removed"); | |
| 1482 | + Ok(()) | |
| 1483 | + } | |
| 1484 | +} | |
| 1485 | +``` | |
| 1486 | + | |
| 1487 | +Signatures canoniques dans [`crates/rustwarden-core/src/hooks.rs`](crates/rustwarden-core/src/hooks.rs). | |
| 1488 | +Voir aussi `docs/SERVICES_REFERENCE.md#17-hooks-lifecycle-et-audit` pour la reference complete des parametres. | |
| 1489 | + | |
| 1430 | 1490 | --- |
| 1431 | 1491 | |
| 1432 | 1492 | ## Internationalisation (i18n) |
@@ -1489,6 +1549,58 @@ Les taches de fond demarrent automatiquement avec le builder (sauf si `.without_
| 1489 | 1549 | | JWT blacklist cleanup | dans `app.rs` | Supprime les JWT expires de la blacklist | |
| 1490 | 1550 | | IMAP bounce processor | `services/imap_bounce_service.rs` | Verifie les bounces email par IMAP | |
| 1491 | 1551 | |
| 1552 | +### Nettoyage des Personal Access Tokens | |
| 1553 | + | |
| 1554 | +Le framework fournit `PersonalTokenCleanupProcessor<S: TokenScope>` qui purge | |
| 1555 | +periodiquement les PAT expires **et** revoques. Contrairement aux taches ci-dessus, | |
| 1556 | +il **n'est pas demarre automatiquement** : il est generique sur le scope `S` | |
| 1557 | +(enum metier defini par votre app), et le framework ne peut pas connaitre ce | |
| 1558 | +type a la compilation. | |
| 1559 | + | |
| 1560 | +Dans votre `main.rs`, apres avoir defini votre enum `Scope` et son | |
| 1561 | +implementation de `TokenScope`, demarrez la tache juste avant `run()` : | |
| 1562 | + | |
| 1563 | +```rust | |
| 1564 | +use rustwarden_core::prelude::{PersonalTokenCleanupProcessor, RustwardenApp, TokenScope}; | |
| 1565 | +use sea_orm::DatabaseConnection; | |
| 1566 | +use std::sync::Arc; | |
| 1567 | + | |
| 1568 | +#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] | |
| 1569 | +enum MyScope { ApiRead, ApiWrite, Admin } | |
| 1570 | + | |
| 1571 | +impl TokenScope for MyScope { | |
| 1572 | + fn as_str(&self) -> &'static str { | |
| 1573 | + match self { | |
| 1574 | + Self::ApiRead => "api:read", | |
| 1575 | + Self::ApiWrite => "api:write", | |
| 1576 | + Self::Admin => "admin", | |
| 1577 | + } | |
| 1578 | + } | |
| 1579 | + fn parse(raw: &str) -> Option<Self> { | |
| 1580 | + match raw { | |
| 1581 | + "api:read" => Some(Self::ApiRead), | |
| 1582 | + "api:write" => Some(Self::ApiWrite), | |
| 1583 | + "admin" => Some(Self::Admin), | |
| 1584 | + _ => None, | |
| 1585 | + } | |
| 1586 | + } | |
| 1587 | +} | |
| 1588 | + | |
| 1589 | +#[tokio::main] | |
| 1590 | +async fn main() -> anyhow::Result<()> { | |
| 1591 | + let app = RustwardenApp::builder().from_env().build().await?; | |
| 1592 | + let db_arc: Arc<DatabaseConnection> = Arc::new(app.db().clone()); | |
| 1593 | + PersonalTokenCleanupProcessor::<MyScope>::new(db_arc).start(); | |
| 1594 | + app.run().await | |
| 1595 | +} | |
| 1596 | +``` | |
| 1597 | + | |
| 1598 | +Intervalle configurable via `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` (defaut dans | |
| 1599 | +[`config::tasks::personal_token_cleanup_interval_secs`](crates/rustwarden-core/src/config/tasks.rs)). | |
| 1600 | + | |
| 1601 | +Voir aussi `docs/SERVICES_REFERENCE.md#7-tokens-et-sessions` pour l'API complete | |
| 1602 | +de `PersonalTokenService<S>`. | |
| 1603 | + | |
| 1492 | 1604 | ### Ajouter une tache de fond |
| 1493 | 1605 | |
| 1494 | 1606 | ```rust |
@@ -1505,6 +1617,65 @@ tokio::spawn(async move {
| 1505 | 1617 | |
| 1506 | 1618 | --- |
| 1507 | 1619 | |
| 1620 | +## Rate limiting et notifications temps-reel | |
| 1621 | + | |
| 1622 | +### RateLimitService | |
| 1623 | + | |
| 1624 | +Sliding-window in-process generique. Utilise par le framework (reset mot de passe, | |
| 1625 | +validation email, changement mot de passe) et disponible dans votre app. | |
| 1626 | + | |
| 1627 | +```rust | |
| 1628 | +use rustwarden_core::prelude::RateLimitService; | |
| 1629 | + | |
| 1630 | +let limiter = RateLimitService::new(); | |
| 1631 | +// 5 tentatives max sur une fenetre glissante de 60s, par cle arbitraire | |
| 1632 | +match limiter.check_sliding_window("login:jdoe", 5, 60) { | |
| 1633 | + Ok(()) => { /* requete autorisee */ } | |
| 1634 | + Err(retry_after) => { /* trop de tentatives, retry apres `retry_after` secondes */ } | |
| 1635 | +} | |
| 1636 | +``` | |
| 1637 | + | |
| 1638 | +Voir `docs/SERVICES_REFERENCE.md#15-rate-limiting` pour les options avancees | |
| 1639 | +(double map, decisions atomiques, TTL). | |
| 1640 | + | |
| 1641 | +### Notifications temps-reel (SSE) | |
| 1642 | + | |
| 1643 | +`NotificationBus<T>` est un pub/sub generique avec filtre par subscriber, backé | |
| 1644 | +par `tokio::sync::broadcast`. `sse_response` emballe la subscription en reponse | |
| 1645 | +Server-Sent Events avec keep-alive 30s. | |
| 1646 | + | |
| 1647 | +```rust | |
| 1648 | +use axum::{extract::Extension, response::IntoResponse}; | |
| 1649 | +use rustwarden_core::prelude::{InMemoryBus, NotificationBus, sse_response}; | |
| 1650 | +use std::sync::Arc; | |
| 1651 | + | |
| 1652 | +#[derive(Clone, serde::Serialize)] | |
| 1653 | +enum AppEvent { | |
| 1654 | + ImportProgress { job_id: uuid::Uuid, pct: u8 }, | |
| 1655 | + Ping, | |
| 1656 | +} | |
| 1657 | + | |
| 1658 | +// Dans le builder de l'app, injecter le bus comme Extension : | |
| 1659 | +// let bus: Arc<dyn NotificationBus<AppEvent>> = Arc::new(InMemoryBus::new(128)); | |
| 1660 | +// .with_extension(bus.clone()) | |
| 1661 | + | |
| 1662 | +async fn stream( | |
| 1663 | + Extension(bus): Extension<Arc<dyn NotificationBus<AppEvent>>>, | |
| 1664 | +) -> impl IntoResponse { | |
| 1665 | + let filter = Arc::new(|e: &AppEvent| matches!(e, AppEvent::ImportProgress { .. })); | |
| 1666 | + sse_response(bus.subscribe(filter)) | |
| 1667 | +} | |
| 1668 | + | |
| 1669 | +// Coté producteur : | |
| 1670 | +// bus.publish(AppEvent::ImportProgress { job_id, pct: 42 }).await; | |
| 1671 | +``` | |
| 1672 | + | |
| 1673 | +Limitation : `InMemoryBus` est single-process. Pour le multi-instance | |
| 1674 | +(horizontal scaling), il faudra un backend Redis (trait stable, backend | |
| 1675 | +remplacable sans changer les handlers). Voir `docs/SERVICES_REFERENCE.md#16-notifications-temps-reel-et-sse`. | |
| 1676 | + | |
| 1677 | +--- | |
| 1678 | + | |
| 1508 | 1679 | ## Middleware |
| 1509 | 1680 | |
| 1510 | 1681 | ### Middleware disponibles |
M
app/Cargo.toml
+2
-2
@@ -23,5 +23,5 @@ async-trait = "0.1"
| 23 | 23 | uuid = { version = "1.0", features = ["v4"] } |
| 24 | 24 | serde_json = "1.0" |
| 25 | 25 | |
| 26 | -[lints.clippy] | |
| 27 | -empty_line_after_doc_comments = "allow" | |
| 26 | +[lints] | |
| 27 | +workspace = true |
M
app/examples/custom_hooks.rs
+1
-5
@@ -36,11 +36,7 @@ impl RustwardenHooks for MyAppHooks {
| 36 | 36 | Ok(()) |
| 37 | 37 | } |
| 38 | 38 | |
| 39 | - async fn on_user_logout( | |
| 40 | - &self, | |
| 41 | - _db: &DatabaseConnection, | |
| 42 | - user_id: Uuid, | |
| 43 | - ) -> anyhow::Result<()> { | |
| 39 | + async fn on_user_logout(&self, _db: &DatabaseConnection, user_id: Uuid) -> anyhow::Result<()> { | |
| 44 | 40 | tracing::info!("[HOOK] User logged out: {}", user_id); |
| 45 | 41 | Ok(()) |
| 46 | 42 | } |
M
app/examples/minimal_headless.rs
+1
-2
@@ -25,8 +25,7 @@ async fn main() -> anyhow::Result<()> {
| 25 | 25 | } |
| 26 | 26 | |
| 27 | 27 | fn api_routes() -> Router<DatabaseConnection> { |
| 28 | - Router::new() | |
| 29 | - .route("/api/status", get(status)) | |
| 28 | + Router::new().route("/api/status", get(status)) | |
| 30 | 29 | } |
| 31 | 30 | |
| 32 | 31 | async fn status() -> Json<serde_json::Value> { |
M
app/src/main.rs
+2
-0
@@ -1,3 +1,5 @@
| 1 | +//! Reference application for the Rustwarden framework. | |
| 2 | + | |
| 1 | 3 | use rustwarden_core::prelude::*; |
| 2 | 4 | |
| 3 | 5 | #[tokio::main] |
M
constitution.md
+2
-0
@@ -20,6 +20,8 @@ Sync Impact Report:
| 20 | 20 | |
| 21 | 21 | Toutes les fonctions publiques DOIVENT être documentées avec `///` (doc comments Rust). Le code DOIT respecter les bonnes pratiques Rust (clippy warnings = 0). Les structures de données DOIVENT utiliser `#[derive(Debug, Clone)]` où approprié. La gestion d'erreurs DOIT utiliser `Result<T, E>` avec des types d'erreur explicites. |
| 22 | 22 | |
| 23 | +**Documentation Language** (amendement v0.5.3): Tous les commentaires rustdoc (`///` et `//!`) DOIVENT être rédigés en anglais. La documentation humaine dans `/docs/` reste en français. Cela garantit que la sortie de `cargo doc` est accessible à la communauté Rust internationale. Le lint `missing_docs` est activé au niveau workspace (`warn`) pour prévenir les régressions. | |
| 24 | + | |
| 23 | 25 | **Rationale**: La documentation et la qualité du code facilitent la maintenance, la collaboration et réduisent les bugs. Rust offre des outils puissants (clippy, rustfmt) qui doivent être utilisés. |
| 24 | 26 | |
| 25 | 27 | ### II. Architecture Axum et SeaORM |
M
crates/rustwarden-core/Cargo.toml
+8
-3
@@ -9,6 +9,9 @@ description = "Core library for Rustwarden – authentication, RBAC, user manage
| 9 | 9 | axum = { version = "0.8", features = ["macros"] } |
| 10 | 10 | tokio = { version = "1", features = ["full"] } |
| 11 | 11 | async-trait = "0.1" |
| 12 | +# Streams (SSE / NotificationBus) | |
| 13 | +futures = "0.3" | |
| 14 | +tokio-stream = { version = "0.1", features = ["sync"] } | |
| 12 | 15 | # Serialization |
| 13 | 16 | serde = { version = "1.0", features = ["derive"] } |
| 14 | 17 | serde_json = "1.0" |
@@ -55,8 +58,10 @@ yaml-rust2 = "0.11.0"
| 55 | 58 | num-format = "0.4.4" |
| 56 | 59 | # Templates (macro + LayoutContext) |
| 57 | 60 | askama = "0.15" |
| 58 | -# Encryption (for storing OAuth secrets in DB) | |
| 61 | +# Encryption (for storing OAuth secrets and TOTP secrets in DB) | |
| 59 | 62 | aes-gcm = "0.10" |
| 63 | +# TOTP 2FA | |
| 64 | +totp-rs = { version = "5", features = ["otpauth", "gen_secret", "qr"] } | |
| 60 | 65 | # URL encoding for redirect parameters |
| 61 | 66 | urlencoding = "2" |
| 62 | 67 | # OAuth2 / OpenID Connect (optional) |
@@ -68,8 +73,8 @@ default = []
| 68 | 73 | redis-session = ["tower-sessions-redis-store"] |
| 69 | 74 | oauth = ["openidconnect", "reqwest"] |
| 70 | 75 | |
| 71 | -[lints.clippy] | |
| 72 | -empty_line_after_doc_comments = "allow" | |
| 76 | +[lints] | |
| 77 | +workspace = true | |
| 73 | 78 | |
| 74 | 79 | [[bin]] |
| 75 | 80 | name = "migrate" |
M
crates/rustwarden-core/locales/en.yml
+1
-1
@@ -41,7 +41,7 @@ common:
| 41 | 41 | # i18n messages |
| 42 | 42 | i18n: |
| 43 | 43 | language_updated: "Language preference updated. Please reload the page to apply changes." |
| 44 | - invalid_language: "Invalid language code. Supported: fr, en, de, es" | |
| 44 | + invalid_language: "Invalid language code. Supported: fr, en, de, es, it, pt" | |
| 45 | 45 | |
| 46 | 46 | # Email verification messages |
| 47 | 47 | email_verify: |
M
crates/rustwarden-core/locales/fr.yml
+1
-1
@@ -42,7 +42,7 @@ common:
| 42 | 42 | # Messages i18n |
| 43 | 43 | i18n: |
| 44 | 44 | language_updated: "Préférence de langue mise à jour. Veuillez recharger la page pour appliquer les changements." |
| 45 | - invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es" | |
| 45 | + invalid_language: "Code de langue invalide. Langues supportées : fr, en, de, es, it, pt" | |
| 46 | 46 | |
| 47 | 47 | # Vérification d'email |
| 48 | 48 | email_verify: |
A
crates/rustwarden-core/locales/it.yml
+82
-0
@@ -0,0 +1,82 @@
| 1 | +# Traduzioni italiane — rustwarden-core | |
| 2 | +# Sezioni: error, common, i18n, email, oauth, rate_limit, session | |
| 3 | + | |
| 4 | +# Messaggi di errore | |
| 5 | +error: | |
| 6 | + invalid_credentials: "Credenziali non valide" | |
| 7 | + email_invalid: "Email non valida" | |
| 8 | + required_field: "Questo campo è obbligatorio" | |
| 9 | + unauthorized: "Non autorizzato" | |
| 10 | + not_found: "Risorsa non trovata" | |
| 11 | + validation: "Errore di validazione: {message}" | |
| 12 | + database: "Errore del database" | |
| 13 | + internal: "Errore interno del server" | |
| 14 | + invalid_token: "Token non valido" | |
| 15 | + token_expired: "Token scaduto" | |
| 16 | + token_reused: "Token riutilizzato - si prega di autenticarsi nuovamente" | |
| 17 | + must_change_password: "Cambio password obbligatorio" | |
| 18 | + authentication_required: "Autenticazione richiesta" | |
| 19 | + insufficient_permissions: "Permessi insufficienti" | |
| 20 | + config: "Errore di configurazione del server" | |
| 21 | + template: "Errore di rendering del template" | |
| 22 | + validation_field: | |
| 23 | + username_min_length: "Il nome utente deve contenere almeno {min} caratteri" | |
| 24 | + password_min_length: "La password deve contenere almeno {min} caratteri" | |
| 25 | + email_format: "L'indirizzo email non è valido" | |
| 26 | + password_require_uppercase: "La password deve contenere almeno una lettera maiuscola" | |
| 27 | + password_require_lowercase: "La password deve contenere almeno una lettera minuscola" | |
| 28 | + password_require_digits: "La password deve contenere almeno una cifra" | |
| 29 | + password_require_special: "La password deve contenere almeno un carattere speciale (!@#$%^&*()_+-=[]{}|;:,.<>?)" | |
| 30 | + | |
| 31 | +# Messaggi comuni | |
| 32 | +common: | |
| 33 | + welcome: "Benvenuto {username}" | |
| 34 | + loading: "Caricamento..." | |
| 35 | + save: "Salva" | |
| 36 | + cancel: "Annulla" | |
| 37 | + delete: "Elimina" | |
| 38 | + edit: "Modifica" | |
| 39 | + create: "Crea" | |
| 40 | + load_more: "Carica altro" | |
| 41 | + | |
| 42 | +# Messaggi i18n | |
| 43 | +i18n: | |
| 44 | + language_updated: "Preferenza di lingua aggiornata. Ricarica la pagina per applicare le modifiche." | |
| 45 | + invalid_language: "Codice lingua non valido. Lingue supportate: fr, en, de, es, it" | |
| 46 | + | |
| 47 | +# Verifica email | |
| 48 | +email_verify: | |
| 49 | + config_error: "Errore di configurazione del server. Contattare l'amministratore." | |
| 50 | + success: "Il tuo indirizzo email è stato verificato con successo. Ora puoi accedere al tuo account." | |
| 51 | + invalid_link: "Il link di verifica non è valido o è scaduto." | |
| 52 | + | |
| 53 | +# Oggetti email | |
| 54 | +email_subject: | |
| 55 | + password_reset: "Reimpostazione della password" | |
| 56 | + email_validation: "Verifica del tuo indirizzo email" | |
| 57 | + welcome: "Benvenuto su RustWarden!" | |
| 58 | + | |
| 59 | +# Email di conferma cambio password | |
| 60 | +password_change_confirmation: | |
| 61 | + subject: "Conferma cambio password" | |
| 62 | + greeting: "Ciao {username}" | |
| 63 | + message: "Hai richiesto di cambiare la password del tuo account {app_name}. Per confermare questa modifica, clicca sul link qui sotto." | |
| 64 | + button_text: "Conferma il cambio password" | |
| 65 | + expires_message: "Questo link è valido per {hours} ore ed è monouso." | |
| 66 | + footer: "Questa email è stata inviata automaticamente, si prega di non rispondere." | |
| 67 | + | |
| 68 | +# Rate limiting | |
| 69 | +rate_limit: | |
| 70 | + exceeded: "Troppe richieste. Riprova più tardi." | |
| 71 | + password_change: "Troppe richieste di cambio password. Attendi prima di riprovare." | |
| 72 | + | |
| 73 | +# OAuth | |
| 74 | +oauth: | |
| 75 | + divider: "oppure" | |
| 76 | + login_with: "Accedi con {provider}" | |
| 77 | + error_denied: "Accesso rifiutato dal provider." | |
| 78 | + error_generic: "Errore durante l'accesso OAuth." | |
| 79 | + | |
| 80 | +# Sessioni | |
| 81 | +session: | |
| 82 | + ip_unavailable: "Non disponibile" |
A
crates/rustwarden-core/locales/pt.yml
+82
-0
@@ -0,0 +1,82 @@
| 1 | +# Traduções portuguesas — rustwarden-core | |
| 2 | +# Seções: error, common, i18n, email, oauth, rate_limit, session | |
| 3 | + | |
| 4 | +# Mensagens de erro | |
| 5 | +error: | |
| 6 | + invalid_credentials: "Credenciais inválidas" | |
| 7 | + email_invalid: "Email inválido" | |
| 8 | + required_field: "Este campo é obrigatório" | |
| 9 | + unauthorized: "Não autorizado" | |
| 10 | + not_found: "Recurso não encontrado" | |
| 11 | + validation: "Erro de validação: {message}" | |
| 12 | + database: "Erro no banco de dados" | |
| 13 | + internal: "Erro interno do servidor" | |
| 14 | + invalid_token: "Token inválido" | |
| 15 | + token_expired: "Token expirado" | |
| 16 | + token_reused: "Token reutilizado - por favor, autentique-se novamente" | |
| 17 | + must_change_password: "Alteração de senha obrigatória" | |
| 18 | + authentication_required: "Autenticação necessária" | |
| 19 | + insufficient_permissions: "Permissões insuficientes" | |
| 20 | + config: "Erro de configuração do servidor" | |
| 21 | + template: "Erro de renderização do template" | |
| 22 | + validation_field: | |
| 23 | + username_min_length: "O nome de usuário deve ter pelo menos {min} caracteres" | |
| 24 | + password_min_length: "A senha deve ter pelo menos {min} caracteres" | |
| 25 | + email_format: "O endereço de email não é válido" | |
| 26 | + password_require_uppercase: "A senha deve conter pelo menos uma letra maiúscula" | |
| 27 | + password_require_lowercase: "A senha deve conter pelo menos uma letra minúscula" | |
| 28 | + password_require_digits: "A senha deve conter pelo menos um dígito" | |
| 29 | + password_require_special: "A senha deve conter pelo menos um caractere especial (!@#$%^&*()_+-=[]{}|;:,.<>?)" | |
| 30 | + | |
| 31 | +# Mensagens comuns | |
| 32 | +common: | |
| 33 | + welcome: "Bem-vindo {username}" | |
| 34 | + loading: "Carregando..." | |
| 35 | + save: "Salvar" | |
| 36 | + cancel: "Cancelar" | |
| 37 | + delete: "Excluir" | |
| 38 | + edit: "Editar" | |
| 39 | + create: "Criar" | |
| 40 | + load_more: "Carregar mais" | |
| 41 | + | |
| 42 | +# Mensagens i18n | |
| 43 | +i18n: | |
| 44 | + language_updated: "Preferência de idioma atualizada. Recarregue a página para aplicar as alterações." | |
| 45 | + invalid_language: "Código de idioma inválido. Idiomas suportados: fr, en, de, es, it, pt" | |
| 46 | + | |
| 47 | +# Verificação de email | |
| 48 | +email_verify: | |
| 49 | + config_error: "Erro de configuração do servidor. Entre em contato com o administrador." | |
| 50 | + success: "Seu endereço de email foi verificado com sucesso. Agora você pode acessar sua conta." | |
| 51 | + invalid_link: "O link de verificação é inválido ou expirou." | |
| 52 | + | |
| 53 | +# Assuntos de email | |
| 54 | +email_subject: | |
| 55 | + password_reset: "Redefinição de senha" | |
| 56 | + email_validation: "Verificação do seu endereço de email" | |
| 57 | + welcome: "Bem-vindo ao RustWarden!" | |
| 58 | + | |
| 59 | +# Email de confirmação de alteração de senha | |
| 60 | +password_change_confirmation: | |
| 61 | + subject: "Confirmação de alteração de senha" | |
| 62 | + greeting: "Olá {username}" | |
| 63 | + message: "Você solicitou a alteração da senha da sua conta {app_name}. Para confirmar esta alteração, clique no link abaixo." | |
| 64 | + button_text: "Confirmar a alteração de senha" | |
| 65 | + expires_message: "Este link é válido por {hours} horas e pode ser usado apenas uma vez." | |
| 66 | + footer: "Este email foi enviado automaticamente, por favor não responda." | |
| 67 | + | |
| 68 | +# Limitação de taxa | |
| 69 | +rate_limit: | |
| 70 | + exceeded: "Muitas requisições. Tente novamente mais tarde." | |
| 71 | + password_change: "Muitas solicitações de alteração de senha. Aguarde antes de tentar novamente." | |
| 72 | + | |
| 73 | +# OAuth | |
| 74 | +oauth: | |
| 75 | + divider: "ou" | |
| 76 | + login_with: "Entrar com {provider}" | |
| 77 | + error_denied: "Acesso negado pelo provedor." | |
| 78 | + error_generic: "Erro durante o login OAuth." | |
| 79 | + | |
| 80 | +# Sessões | |
| 81 | +session: | |
| 82 | + ip_unavailable: "Indisponível" |
M
crates/rustwarden-core/migrations/src/lib.rs
+6
-0
@@ -5,6 +5,9 @@ pub mod m20260306_000002_create_app_settings_table;
| 5 | 5 | pub mod m20260309_000003_create_permissions_tables; |
| 6 | 6 | pub mod m20260309_000004_create_resources_tables; |
| 7 | 7 | pub mod m20260310_000005_create_oauth_accounts_table; |
| 8 | +pub mod m20260409_000006_create_totp_tables; | |
| 9 | +pub mod m20260422_000007_create_personal_access_tokens; | |
| 10 | +pub mod m20260423_000008_create_teams; | |
| 8 | 11 | |
| 9 | 12 | pub struct Migrator; |
| 10 | 13 |
@@ -17,6 +20,9 @@ impl MigratorTrait for Migrator {
| 17 | 20 | Box::new(m20260309_000003_create_permissions_tables::Migration), |
| 18 | 21 | Box::new(m20260309_000004_create_resources_tables::Migration), |
| 19 | 22 | Box::new(m20260310_000005_create_oauth_accounts_table::Migration), |
| 23 | + Box::new(m20260409_000006_create_totp_tables::Migration), | |
| 24 | + Box::new(m20260422_000007_create_personal_access_tokens::Migration), | |
| 25 | + Box::new(m20260423_000008_create_teams::Migration), | |
| 20 | 26 | ] |
| 21 | 27 | } |
| 22 | 28 | } |
M
crates/rustwarden-core/migrations/src/m20260305_000001_initial_schema.rs
+25
-32
@@ -1,5 +1,6 @@
| 1 | 1 | use sea_orm_migration::prelude::*; |
| 2 | 2 | |
| 3 | +/// Initial schema migration: creates the core users, roles, and sessions tables. | |
| 3 | 4 | #[derive(DeriveMigrationName)] |
| 4 | 5 | pub struct Migration; |
| 5 | 6 |
@@ -554,10 +555,7 @@ impl MigrationTrait for Migration {
| 554 | 555 | .foreign_key( |
| 555 | 556 | ForeignKey::create() |
| 556 | 557 | .name("fk_email_validation_tokens_user_id") |
| 557 | - .from( | |
| 558 | - EmailValidationTokens::Table, | |
| 559 | - EmailValidationTokens::UserId, | |
| 560 | - ) | |
| 558 | + .from(EmailValidationTokens::Table, EmailValidationTokens::UserId) | |
| 561 | 559 | .to(Users::Table, Users::Id) |
| 562 | 560 | .on_delete(ForeignKeyAction::Cascade), |
| 563 | 561 | ) |
@@ -771,16 +769,8 @@ impl MigrationTrait for Migration {
| 771 | 769 | .null(), |
| 772 | 770 | ) |
| 773 | 771 | .col(ColumnDef::new(EmailQueue::ErrorMessage).text().null()) |
| 774 | - .col( | |
| 775 | - ColumnDef::new(EmailQueue::SmtpCode) | |
| 776 | - .string_len(10) | |
| 777 | - .null(), | |
| 778 | - ) | |
| 779 | - .col( | |
| 780 | - ColumnDef::new(EmailQueue::SmtpResponse) | |
| 781 | - .text() | |
| 782 | - .null(), | |
| 783 | - ) | |
| 772 | + .col(ColumnDef::new(EmailQueue::SmtpCode).string_len(10).null()) | |
| 773 | + .col(ColumnDef::new(EmailQueue::SmtpResponse).text().null()) | |
| 784 | 774 | .col( |
| 785 | 775 | ColumnDef::new(EmailQueue::CreatedAt) |
| 786 | 776 | .timestamp_with_time_zone() |
@@ -986,13 +976,28 @@ impl MigrationTrait for Migration {
| 986 | 976 | Table::create() |
| 987 | 977 | .table(AuditLogs::Table) |
| 988 | 978 | .if_not_exists() |
| 989 | - .col(ColumnDef::new(AuditLogs::Id).uuid().not_null().primary_key()) | |
| 979 | + .col( | |
| 980 | + ColumnDef::new(AuditLogs::Id) | |
| 981 | + .uuid() | |
| 982 | + .not_null() | |
| 983 | + .primary_key(), | |
| 984 | + ) | |
| 990 | 985 | .col(ColumnDef::new(AuditLogs::UserId).uuid()) |
| 991 | 986 | .col(ColumnDef::new(AuditLogs::Action).string_len(100).not_null()) |
| 992 | 987 | .col(ColumnDef::new(AuditLogs::TargetUserId).uuid()) |
| 993 | - .col(ColumnDef::new(AuditLogs::Level).string_len(10).not_null().default("INFO")) | |
| 988 | + .col( | |
| 989 | + ColumnDef::new(AuditLogs::Level) | |
| 990 | + .string_len(10) | |
| 991 | + .not_null() | |
| 992 | + .default("INFO"), | |
| 993 | + ) | |
| 994 | 994 | .col(ColumnDef::new(AuditLogs::Message).text().not_null()) |
| 995 | - .col(ColumnDef::new(AuditLogs::CreatedAt).timestamp_with_time_zone().not_null().default(Expr::current_timestamp())) | |
| 995 | + .col( | |
| 996 | + ColumnDef::new(AuditLogs::CreatedAt) | |
| 997 | + .timestamp_with_time_zone() | |
| 998 | + .not_null() | |
| 999 | + .default(Expr::current_timestamp()), | |
| 1000 | + ) | |
| 996 | 1001 | .foreign_key( |
| 997 | 1002 | ForeignKey::create() |
| 998 | 1003 | .name("fk_audit_logs_user") |
@@ -1067,11 +1072,7 @@ impl MigrationTrait for Migration {
| 1067 | 1072 | .await?; |
| 1068 | 1073 | |
| 1069 | 1074 | manager |
| 1070 | - .drop_table( | |
| 1071 | - Table::drop() | |
| 1072 | - .table(EmailDeliveryStatus::Table) | |
| 1073 | - .to_owned(), | |
| 1074 | - ) | |
| 1075 | + .drop_table(Table::drop().table(EmailDeliveryStatus::Table).to_owned()) | |
| 1075 | 1076 | .await?; |
| 1076 | 1077 | |
| 1077 | 1078 | manager |
@@ -1087,19 +1088,11 @@ impl MigrationTrait for Migration {
| 1087 | 1088 | .await?; |
| 1088 | 1089 | |
| 1089 | 1090 | manager |
| 1090 | - .drop_table( | |
| 1091 | - Table::drop() | |
| 1092 | - .table(EmailValidationTokens::Table) | |
| 1093 | - .to_owned(), | |
| 1094 | - ) | |
| 1091 | + .drop_table(Table::drop().table(EmailValidationTokens::Table).to_owned()) | |
| 1095 | 1092 | .await?; |
| 1096 | 1093 | |
| 1097 | 1094 | manager |
| 1098 | - .drop_table( | |
| 1099 | - Table::drop() | |
| 1100 | - .table(PasswordResetTokens::Table) | |
| 1101 | - .to_owned(), | |
| 1102 | - ) | |
| 1095 | + .drop_table(Table::drop().table(PasswordResetTokens::Table).to_owned()) | |
| 1103 | 1096 | .await?; |
| 1104 | 1097 | |
| 1105 | 1098 | manager |
M
crates/rustwarden-core/migrations/src/m20260306_000002_create_app_settings_table.rs
+1
-0
@@ -1,5 +1,6 @@
| 1 | 1 | use sea_orm_migration::prelude::*; |
| 2 | 2 | |
| 3 | +/// Migration: creates the `app_settings` key-value table. | |
| 3 | 4 | #[derive(DeriveMigrationName)] |
| 4 | 5 | pub struct Migration; |
| 5 | 6 |
M
crates/rustwarden-core/migrations/src/m20260309_000003_create_permissions_tables.rs
+2
-5
@@ -1,5 +1,6 @@
| 1 | 1 | use sea_orm_migration::prelude::*; |
| 2 | 2 | |
| 3 | +/// Migration: creates the `permissions`, `roles`, and `role_permissions` tables. | |
| 3 | 4 | #[derive(DeriveMigrationName)] |
| 4 | 5 | pub struct Migration; |
| 5 | 6 |
@@ -25,11 +26,7 @@ impl MigrationTrait for Migration {
| 25 | 26 | .not_null() |
| 26 | 27 | .unique_key(), |
| 27 | 28 | ) |
| 28 | - .col( | |
| 29 | - ColumnDef::new(Permissions::Description) | |
| 30 | - .text() | |
| 31 | - .null(), | |
| 32 | - ) | |
| 29 | + .col(ColumnDef::new(Permissions::Description).text().null()) | |
| 33 | 30 | .col( |
| 34 | 31 | ColumnDef::new(Permissions::Category) |
| 35 | 32 | .string() |
M
crates/rustwarden-core/migrations/src/m20260309_000004_create_resources_tables.rs
+4
-15
@@ -1,5 +1,6 @@
| 1 | 1 | use sea_orm_migration::prelude::*; |
| 2 | 2 | |
| 3 | +/// Migration: creates the `resources` and `resource_shares` tables. | |
| 3 | 4 | #[derive(DeriveMigrationName)] |
| 4 | 5 | pub struct Migration; |
| 5 | 6 |
@@ -24,16 +25,8 @@ impl MigrationTrait for Migration {
| 24 | 25 | .string_len(100) |
| 25 | 26 | .not_null(), |
| 26 | 27 | ) |
| 27 | - .col( | |
| 28 | - ColumnDef::new(Resources::ResourceId) | |
| 29 | - .uuid() | |
| 30 | - .not_null(), | |
| 31 | - ) | |
| 32 | - .col( | |
| 33 | - ColumnDef::new(Resources::OwnerId) | |
| 34 | - .uuid() | |
| 35 | - .not_null(), | |
| 36 | - ) | |
| 28 | + .col(ColumnDef::new(Resources::ResourceId).uuid().not_null()) | |
| 29 | + .col(ColumnDef::new(Resources::OwnerId).uuid().not_null()) | |
| 37 | 30 | .col( |
| 38 | 31 | ColumnDef::new(Resources::IsPublic) |
| 39 | 32 | .boolean() |
@@ -104,11 +97,7 @@ impl MigrationTrait for Migration {
| 104 | 97 | .not_null() |
| 105 | 98 | .primary_key(), |
| 106 | 99 | ) |
| 107 | - .col( | |
| 108 | - ColumnDef::new(ResourceShares::ResourceId) | |
| 109 | - .uuid() | |
| 110 | - .not_null(), | |
| 111 | - ) | |
| 100 | + .col(ColumnDef::new(ResourceShares::ResourceId).uuid().not_null()) | |
| 112 | 101 | .col( |
| 113 | 102 | ColumnDef::new(ResourceShares::SharedWithUserId) |
| 114 | 103 | .uuid() |
M
crates/rustwarden-core/migrations/src/m20260310_000005_create_oauth_accounts_table.rs
+2
-5
@@ -1,5 +1,6 @@
| 1 | 1 | use sea_orm_migration::prelude::*; |
| 2 | 2 | |
| 3 | +/// Migration: creates the `oauth_accounts` table for linked OAuth identities. | |
| 3 | 4 | #[derive(DeriveMigrationName)] |
| 4 | 5 | pub struct Migration; |
| 5 | 6 |
@@ -17,11 +18,7 @@ impl MigrationTrait for Migration {
| 17 | 18 | .not_null() |
| 18 | 19 | .primary_key(), |
| 19 | 20 | ) |
| 20 | - .col( | |
| 21 | - ColumnDef::new(OauthAccounts::UserId) | |
| 22 | - .uuid() | |
| 23 | - .not_null(), | |
| 24 | - ) | |
| 21 | + .col(ColumnDef::new(OauthAccounts::UserId).uuid().not_null()) | |
| 25 | 22 | .col( |
| 26 | 23 | ColumnDef::new(OauthAccounts::Provider) |
| 27 | 24 | .string() |
A
crates/rustwarden-core/migrations/src/m20260409_000006_create_totp_tables.rs
+184
-0
@@ -0,0 +1,184 @@
| 1 | +use sea_orm_migration::prelude::*; | |
| 2 | + | |
| 3 | +/// Migration: creates the TOTP secrets and backup-codes tables for 2FA. | |
| 4 | +#[derive(DeriveMigrationName)] | |
| 5 | +pub struct Migration; | |
| 6 | + | |
| 7 | +#[async_trait::async_trait] | |
| 8 | +impl MigrationTrait for Migration { | |
| 9 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 10 | + // Table: user_totp (TOTP 2FA data per user) | |
| 11 | + manager | |
| 12 | + .create_table( | |
| 13 | + Table::create() | |
| 14 | + .table(UserTotp::Table) | |
| 15 | + .if_not_exists() | |
| 16 | + .col(ColumnDef::new(UserTotp::Id).uuid().not_null().primary_key()) | |
| 17 | + .col( | |
| 18 | + ColumnDef::new(UserTotp::UserId) | |
| 19 | + .uuid() | |
| 20 | + .not_null() | |
| 21 | + .unique_key(), | |
| 22 | + ) | |
| 23 | + .col(ColumnDef::new(UserTotp::SecretEncrypted).text().not_null()) | |
| 24 | + .col( | |
| 25 | + ColumnDef::new(UserTotp::Enabled) | |
| 26 | + .boolean() | |
| 27 | + .not_null() | |
| 28 | + .default(false), | |
| 29 | + ) | |
| 30 | + .col(ColumnDef::new(UserTotp::BackupCodesEncrypted).text().null()) | |
| 31 | + .col( | |
| 32 | + ColumnDef::new(UserTotp::BackupCodesRemaining) | |
| 33 | + .integer() | |
| 34 | + .not_null() | |
| 35 | + .default(0), | |
| 36 | + ) | |
| 37 | + .col( | |
| 38 | + ColumnDef::new(UserTotp::EnabledAt) | |
| 39 | + .timestamp_with_time_zone() | |
| 40 | + .null(), | |
| 41 | + ) | |
| 42 | + .col( | |
| 43 | + ColumnDef::new(UserTotp::CreatedAt) | |
| 44 | + .timestamp_with_time_zone() | |
| 45 | + .not_null() | |
| 46 | + .default(Expr::current_timestamp()), | |
| 47 | + ) | |
| 48 | + .col( | |
| 49 | + ColumnDef::new(UserTotp::UpdatedAt) | |
| 50 | + .timestamp_with_time_zone() | |
| 51 | + .not_null() | |
| 52 | + .default(Expr::current_timestamp()), | |
| 53 | + ) | |
| 54 | + .foreign_key( | |
| 55 | + ForeignKey::create() | |
| 56 | + .name("fk_user_totp_user_id") | |
| 57 | + .from(UserTotp::Table, UserTotp::UserId) | |
| 58 | + .to(Users::Table, Users::Id) | |
| 59 | + .on_delete(ForeignKeyAction::Cascade), | |
| 60 | + ) | |
| 61 | + .to_owned(), | |
| 62 | + ) | |
| 63 | + .await?; | |
| 64 | + | |
| 65 | + // Table: totp_challenges (ephemeral login 2FA challenges) | |
| 66 | + manager | |
| 67 | + .create_table( | |
| 68 | + Table::create() | |
| 69 | + .table(TotpChallenges::Table) | |
| 70 | + .if_not_exists() | |
| 71 | + .col( | |
| 72 | + ColumnDef::new(TotpChallenges::Id) | |
| 73 | + .uuid() | |
| 74 | + .not_null() | |
| 75 | + .primary_key(), | |
| 76 | + ) | |
| 77 | + .col(ColumnDef::new(TotpChallenges::UserId).uuid().not_null()) | |
| 78 | + .col( | |
| 79 | + ColumnDef::new(TotpChallenges::ChallengeToken) | |
| 80 | + .string() | |
| 81 | + .string_len(128) | |
| 82 | + .not_null() | |
| 83 | + .unique_key(), | |
| 84 | + ) | |
| 85 | + .col( | |
| 86 | + ColumnDef::new(TotpChallenges::Attempts) | |
| 87 | + .integer() | |
| 88 | + .not_null() | |
| 89 | + .default(0), | |
| 90 | + ) | |
| 91 | + .col( | |
| 92 | + ColumnDef::new(TotpChallenges::MaxAttempts) | |
| 93 | + .integer() | |
| 94 | + .not_null() | |
| 95 | + .default(5), | |
| 96 | + ) | |
| 97 | + .col( | |
| 98 | + ColumnDef::new(TotpChallenges::ExpiresAt) | |
| 99 | + .timestamp_with_time_zone() | |
| 100 | + .not_null(), | |
| 101 | + ) | |
| 102 | + .col( | |
| 103 | + ColumnDef::new(TotpChallenges::CreatedAt) | |
| 104 | + .timestamp_with_time_zone() | |
| 105 | + .not_null() | |
| 106 | + .default(Expr::current_timestamp()), | |
| 107 | + ) | |
| 108 | + .foreign_key( | |
| 109 | + ForeignKey::create() | |
| 110 | + .name("fk_totp_challenges_user_id") | |
| 111 | + .from(TotpChallenges::Table, TotpChallenges::UserId) | |
| 112 | + .to(Users::Table, Users::Id) | |
| 113 | + .on_delete(ForeignKeyAction::Cascade), | |
| 114 | + ) | |
| 115 | + .to_owned(), | |
| 116 | + ) | |
| 117 | + .await?; | |
| 118 | + | |
| 119 | + // Index on challenge_token for fast lookup | |
| 120 | + manager | |
| 121 | + .create_index( | |
| 122 | + Index::create() | |
| 123 | + .name("idx_totp_challenges_token") | |
| 124 | + .table(TotpChallenges::Table) | |
| 125 | + .col(TotpChallenges::ChallengeToken) | |
| 126 | + .to_owned(), | |
| 127 | + ) | |
| 128 | + .await?; | |
| 129 | + | |
| 130 | + // Index on expires_at for cleanup queries | |
| 131 | + manager | |
| 132 | + .create_index( | |
| 133 | + Index::create() | |
| 134 | + .name("idx_totp_challenges_expires") | |
| 135 | + .table(TotpChallenges::Table) | |
| 136 | + .col(TotpChallenges::ExpiresAt) | |
| 137 | + .to_owned(), | |
| 138 | + ) | |
| 139 | + .await?; | |
| 140 | + | |
| 141 | + Ok(()) | |
| 142 | + } | |
| 143 | + | |
| 144 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 145 | + manager | |
| 146 | + .drop_table(Table::drop().table(TotpChallenges::Table).to_owned()) | |
| 147 | + .await?; | |
| 148 | + manager | |
| 149 | + .drop_table(Table::drop().table(UserTotp::Table).to_owned()) | |
| 150 | + .await | |
| 151 | + } | |
| 152 | +} | |
| 153 | + | |
| 154 | +#[derive(Iden)] | |
| 155 | +enum UserTotp { | |
| 156 | + Table, | |
| 157 | + Id, | |
| 158 | + UserId, | |
| 159 | + SecretEncrypted, | |
| 160 | + Enabled, | |
| 161 | + BackupCodesEncrypted, | |
| 162 | + BackupCodesRemaining, | |
| 163 | + EnabledAt, | |
| 164 | + CreatedAt, | |
| 165 | + UpdatedAt, | |
| 166 | +} | |
| 167 | + | |
| 168 | +#[derive(Iden)] | |
| 169 | +enum TotpChallenges { | |
| 170 | + Table, | |
| 171 | + Id, | |
| 172 | + UserId, | |
| 173 | + ChallengeToken, | |
| 174 | + Attempts, | |
| 175 | + MaxAttempts, | |
| 176 | + ExpiresAt, | |
| 177 | + CreatedAt, | |
| 178 | +} | |
| 179 | + | |
| 180 | +#[derive(Iden)] | |
| 181 | +enum Users { | |
| 182 | + Table, | |
| 183 | + Id, | |
| 184 | +} |
A
crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs
+147
-0
@@ -0,0 +1,147 @@
| 1 | +use sea_orm_migration::prelude::*; | |
| 2 | + | |
| 3 | +/// Migration: creates the `personal_access_tokens` table. | |
| 4 | +#[derive(DeriveMigrationName)] | |
| 5 | +pub struct Migration; | |
| 6 | + | |
| 7 | +#[async_trait::async_trait] | |
| 8 | +impl MigrationTrait for Migration { | |
| 9 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 10 | + // Table: personal_access_tokens (long-lived API tokens with scopes) | |
| 11 | + // | |
| 12 | + // Complements refresh_token: whereas refresh_token implements JWT | |
| 13 | + // rotation (one-use, detects reuse), PATs are static secrets consumed | |
| 14 | + // unchanged across calls (e.g. git push over HTTPS). Hash-only storage, | |
| 15 | + // nullable expiration, and per-user unique names. | |
| 16 | + manager | |
| 17 | + .create_table( | |
| 18 | + Table::create() | |
| 19 | + .table(PersonalAccessTokens::Table) | |
| 20 | + .if_not_exists() | |
| 21 | + .col( | |
| 22 | + ColumnDef::new(PersonalAccessTokens::Id) | |
| 23 | + .uuid() | |
| 24 | + .not_null() | |
| 25 | + .primary_key(), | |
| 26 | + ) | |
| 27 | + .col( | |
| 28 | + ColumnDef::new(PersonalAccessTokens::UserId) | |
| 29 | + .uuid() | |
| 30 | + .not_null(), | |
| 31 | + ) | |
| 32 | + .col( | |
| 33 | + ColumnDef::new(PersonalAccessTokens::Name) | |
| 34 | + .string() | |
| 35 | + .string_len(128) | |
| 36 | + .not_null(), | |
| 37 | + ) | |
| 38 | + .col( | |
| 39 | + ColumnDef::new(PersonalAccessTokens::TokenHash) | |
| 40 | + .string() | |
| 41 | + .string_len(64) // SHA-256 hex | |
| 42 | + .not_null() | |
| 43 | + .unique_key(), | |
| 44 | + ) | |
| 45 | + .col( | |
| 46 | + ColumnDef::new(PersonalAccessTokens::Scopes) | |
| 47 | + .array(sea_query::ColumnType::Text) | |
| 48 | + .not_null(), | |
| 49 | + ) | |
| 50 | + .col( | |
| 51 | + ColumnDef::new(PersonalAccessTokens::ExpiresAt) | |
| 52 | + .timestamp_with_time_zone() | |
| 53 | + .null(), | |
| 54 | + ) | |
| 55 | + .col( | |
| 56 | + ColumnDef::new(PersonalAccessTokens::LastUsedAt) | |
| 57 | + .timestamp_with_time_zone() | |
| 58 | + .null(), | |
| 59 | + ) | |
| 60 | + .col( | |
| 61 | + ColumnDef::new(PersonalAccessTokens::RevokedAt) | |
| 62 | + .timestamp_with_time_zone() | |
| 63 | + .null(), | |
| 64 | + ) | |
| 65 | + .col( | |
| 66 | + ColumnDef::new(PersonalAccessTokens::CreatedAt) | |
| 67 | + .timestamp_with_time_zone() | |
| 68 | + .not_null() | |
| 69 | + .default(Expr::current_timestamp()), | |
| 70 | + ) | |
| 71 | + .foreign_key( | |
| 72 | + ForeignKey::create() | |
| 73 | + .name("fk_personal_access_tokens_user_id") | |
| 74 | + .from(PersonalAccessTokens::Table, PersonalAccessTokens::UserId) | |
| 75 | + .to(Users::Table, Users::Id) | |
| 76 | + .on_delete(ForeignKeyAction::Cascade), | |
| 77 | + ) | |
| 78 | + .to_owned(), | |
| 79 | + ) | |
| 80 | + .await?; | |
| 81 | + | |
| 82 | + // One token name per user (UI uniqueness guarantee). | |
| 83 | + manager | |
| 84 | + .create_index( | |
| 85 | + Index::create() | |
| 86 | + .name("idx_pat_name_per_user") | |
| 87 | + .table(PersonalAccessTokens::Table) | |
| 88 | + .col(PersonalAccessTokens::UserId) | |
| 89 | + .col(PersonalAccessTokens::Name) | |
| 90 | + .unique() | |
| 91 | + .to_owned(), | |
| 92 | + ) | |
| 93 | + .await?; | |
| 94 | + | |
| 95 | + // Fast lookup for `verify(token)` — token_hash is already unique, but | |
| 96 | + // an explicit btree index documents intent and lets EXPLAIN stay stable | |
| 97 | + // across rewrites. | |
| 98 | + manager | |
| 99 | + .create_index( | |
| 100 | + Index::create() | |
| 101 | + .name("idx_pat_token_hash") | |
| 102 | + .table(PersonalAccessTokens::Table) | |
| 103 | + .col(PersonalAccessTokens::TokenHash) | |
| 104 | + .to_owned(), | |
| 105 | + ) | |
| 106 | + .await?; | |
| 107 | + | |
| 108 | + // Supports the cleanup job `WHERE expires_at < now()`. | |
| 109 | + manager | |
| 110 | + .create_index( | |
| 111 | + Index::create() | |
| 112 | + .name("idx_pat_expires_at") | |
| 113 | + .table(PersonalAccessTokens::Table) | |
| 114 | + .col(PersonalAccessTokens::ExpiresAt) | |
| 115 | + .to_owned(), | |
| 116 | + ) | |
| 117 | + .await?; | |
| 118 | + | |
| 119 | + Ok(()) | |
| 120 | + } | |
| 121 | + | |
| 122 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 123 | + manager | |
| 124 | + .drop_table(Table::drop().table(PersonalAccessTokens::Table).to_owned()) | |
| 125 | + .await | |
| 126 | + } | |
| 127 | +} | |
| 128 | + | |
| 129 | +#[derive(Iden)] | |
| 130 | +enum PersonalAccessTokens { | |
| 131 | + Table, | |
| 132 | + Id, | |
| 133 | + UserId, | |
| 134 | + Name, | |
| 135 | + TokenHash, | |
| 136 | + Scopes, | |
| 137 | + ExpiresAt, | |
| 138 | + LastUsedAt, | |
| 139 | + RevokedAt, | |
| 140 | + CreatedAt, | |
| 141 | +} | |
| 142 | + | |
| 143 | +#[derive(Iden)] | |
| 144 | +enum Users { | |
| 145 | + Table, | |
| 146 | + Id, | |
| 147 | +} |
A
crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs
+277
-0
@@ -0,0 +1,277 @@
| 1 | +use sea_orm_migration::prelude::*; | |
| 2 | + | |
| 3 | +/// Migration: creates the `teams` and `team_members` tables. | |
| 4 | +#[derive(DeriveMigrationName)] | |
| 5 | +pub struct Migration; | |
| 6 | + | |
| 7 | +#[async_trait::async_trait] | |
| 8 | +impl MigrationTrait for Migration { | |
| 9 | + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 10 | + // ----------------------------------------------------------------- | |
| 11 | + // 1. teams — named groups of users with an owner | |
| 12 | + // ----------------------------------------------------------------- | |
| 13 | + manager | |
| 14 | + .create_table( | |
| 15 | + Table::create() | |
| 16 | + .table(Teams::Table) | |
| 17 | + .if_not_exists() | |
| 18 | + .col(ColumnDef::new(Teams::Id).uuid().not_null().primary_key()) | |
| 19 | + .col( | |
| 20 | + ColumnDef::new(Teams::Slug) | |
| 21 | + .string() | |
| 22 | + .string_len(64) | |
| 23 | + .not_null() | |
| 24 | + .unique_key(), | |
| 25 | + ) | |
| 26 | + .col( | |
| 27 | + ColumnDef::new(Teams::Name) | |
| 28 | + .string() | |
| 29 | + .string_len(128) | |
| 30 | + .not_null(), | |
| 31 | + ) | |
| 32 | + .col(ColumnDef::new(Teams::OwnerId).uuid().not_null()) | |
| 33 | + .col( | |
| 34 | + ColumnDef::new(Teams::CreatedAt) | |
| 35 | + .timestamp_with_time_zone() | |
| 36 | + .not_null() | |
| 37 | + .default(Expr::current_timestamp()), | |
| 38 | + ) | |
| 39 | + .foreign_key( | |
| 40 | + ForeignKey::create() | |
| 41 | + .name("fk_teams_owner_id") | |
| 42 | + .from(Teams::Table, Teams::OwnerId) | |
| 43 | + .to(Users::Table, Users::Id) | |
| 44 | + .on_delete(ForeignKeyAction::Cascade), | |
| 45 | + ) | |
| 46 | + .to_owned(), | |
| 47 | + ) | |
| 48 | + .await?; | |
| 49 | + | |
| 50 | + // ----------------------------------------------------------------- | |
| 51 | + // 2. team_members — N:N with role (owner/admin/member) | |
| 52 | + // ----------------------------------------------------------------- | |
| 53 | + manager | |
| 54 | + .create_table( | |
| 55 | + Table::create() | |
| 56 | + .table(TeamMembers::Table) | |
| 57 | + .if_not_exists() | |
| 58 | + .col(ColumnDef::new(TeamMembers::TeamId).uuid().not_null()) | |
| 59 | + .col(ColumnDef::new(TeamMembers::UserId).uuid().not_null()) | |
| 60 | + .col( | |
| 61 | + ColumnDef::new(TeamMembers::Role) | |
| 62 | + .string() | |
| 63 | + .string_len(16) | |
| 64 | + .not_null() | |
| 65 | + .default("member"), | |
| 66 | + ) | |
| 67 | + .col( | |
| 68 | + ColumnDef::new(TeamMembers::JoinedAt) | |
| 69 | + .timestamp_with_time_zone() | |
| 70 | + .not_null() | |
| 71 | + .default(Expr::current_timestamp()), | |
| 72 | + ) | |
| 73 | + .primary_key( | |
| 74 | + Index::create() | |
| 75 | + .col(TeamMembers::TeamId) | |
| 76 | + .col(TeamMembers::UserId), | |
| 77 | + ) | |
| 78 | + .foreign_key( | |
| 79 | + ForeignKey::create() | |
| 80 | + .name("fk_team_members_team_id") | |
| 81 | + .from(TeamMembers::Table, TeamMembers::TeamId) | |
| 82 | + .to(Teams::Table, Teams::Id) | |
| 83 | + .on_delete(ForeignKeyAction::Cascade), | |
| 84 | + ) | |
| 85 | + .foreign_key( | |
| 86 | + ForeignKey::create() | |
| 87 | + .name("fk_team_members_user_id") | |
| 88 | + .from(TeamMembers::Table, TeamMembers::UserId) | |
| 89 | + .to(Users::Table, Users::Id) | |
| 90 | + .on_delete(ForeignKeyAction::Cascade), | |
| 91 | + ) | |
| 92 | + .to_owned(), | |
| 93 | + ) | |
| 94 | + .await?; | |
| 95 | + | |
| 96 | + // Index: list teams of a user in constant time. | |
| 97 | + manager | |
| 98 | + .create_index( | |
| 99 | + Index::create() | |
| 100 | + .name("idx_team_members_user") | |
| 101 | + .table(TeamMembers::Table) | |
| 102 | + .col(TeamMembers::UserId) | |
| 103 | + .to_owned(), | |
| 104 | + ) | |
| 105 | + .await?; | |
| 106 | + | |
| 107 | + // ----------------------------------------------------------------- | |
| 108 | + // 3. resource_shares — extend to accept team principals | |
| 109 | + // | |
| 110 | + // Strategy (option B of the evolution plan): keep the existing | |
| 111 | + // `shared_with_user_id` column (now nullable) and add a companion | |
| 112 | + // `shared_with_team_id`. A DB-level CHECK constraint enforces that | |
| 113 | + // exactly one is set — keeps schema-level referential integrity | |
| 114 | + // instead of a polymorphic ID + discriminator string. | |
| 115 | + // ----------------------------------------------------------------- | |
| 116 | + manager | |
| 117 | + .get_connection() | |
| 118 | + .execute_unprepared( | |
| 119 | + "ALTER TABLE resource_shares ALTER COLUMN shared_with_user_id DROP NOT NULL", | |
| 120 | + ) | |
| 121 | + .await?; | |
| 122 | + | |
| 123 | + manager | |
| 124 | + .alter_table( | |
| 125 | + Table::alter() | |
| 126 | + .table(ResourceShares::Table) | |
| 127 | + .add_column( | |
| 128 | + ColumnDef::new(ResourceShares::SharedWithTeamId) | |
| 129 | + .uuid() | |
| 130 | + .null(), | |
| 131 | + ) | |
| 132 | + .to_owned(), | |
| 133 | + ) | |
| 134 | + .await?; | |
| 135 | + | |
| 136 | + manager | |
| 137 | + .get_connection() | |
| 138 | + .execute_unprepared( | |
| 139 | + "ALTER TABLE resource_shares \ | |
| 140 | + ADD CONSTRAINT fk_resource_shares_shared_with_team \ | |
| 141 | + FOREIGN KEY (shared_with_team_id) REFERENCES teams(id) ON DELETE CASCADE", | |
| 142 | + ) | |
| 143 | + .await?; | |
| 144 | + | |
| 145 | + // Exactly one of (user, team) must be set. | |
| 146 | + manager | |
| 147 | + .get_connection() | |
| 148 | + .execute_unprepared( | |
| 149 | + "ALTER TABLE resource_shares \ | |
| 150 | + ADD CONSTRAINT chk_resource_shares_principal \ | |
| 151 | + CHECK ((shared_with_user_id IS NOT NULL)::int + \ | |
| 152 | + (shared_with_team_id IS NOT NULL)::int = 1)", | |
| 153 | + ) | |
| 154 | + .await?; | |
| 155 | + | |
| 156 | + // The existing unique (resource_id, shared_with_user_id) index becomes | |
| 157 | + // partial — only enforce uniqueness when the user column is set. | |
| 158 | + // We drop & recreate instead of altering to keep this cross-backend-friendly. | |
| 159 | + manager | |
| 160 | + .get_connection() | |
| 161 | + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_user_unique") | |
| 162 | + .await?; | |
| 163 | + | |
| 164 | + manager | |
| 165 | + .get_connection() | |
| 166 | + .execute_unprepared( | |
| 167 | + "CREATE UNIQUE INDEX idx_resource_shares_resource_user_unique \ | |
| 168 | + ON resource_shares (resource_id, shared_with_user_id) \ | |
| 169 | + WHERE shared_with_user_id IS NOT NULL", | |
| 170 | + ) | |
| 171 | + .await?; | |
| 172 | + | |
| 173 | + manager | |
| 174 | + .get_connection() | |
| 175 | + .execute_unprepared( | |
| 176 | + "CREATE UNIQUE INDEX idx_resource_shares_resource_team_unique \ | |
| 177 | + ON resource_shares (resource_id, shared_with_team_id) \ | |
| 178 | + WHERE shared_with_team_id IS NOT NULL", | |
| 179 | + ) | |
| 180 | + .await?; | |
| 181 | + | |
| 182 | + manager | |
| 183 | + .get_connection() | |
| 184 | + .execute_unprepared( | |
| 185 | + "CREATE INDEX idx_resource_shares_team ON resource_shares (shared_with_team_id) \ | |
| 186 | + WHERE shared_with_team_id IS NOT NULL", | |
| 187 | + ) | |
| 188 | + .await?; | |
| 189 | + | |
| 190 | + Ok(()) | |
| 191 | + } | |
| 192 | + | |
| 193 | + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { | |
| 194 | + manager | |
| 195 | + .get_connection() | |
| 196 | + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_team") | |
| 197 | + .await?; | |
| 198 | + manager | |
| 199 | + .get_connection() | |
| 200 | + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_team_unique") | |
| 201 | + .await?; | |
| 202 | + manager | |
| 203 | + .get_connection() | |
| 204 | + .execute_unprepared("DROP INDEX IF EXISTS idx_resource_shares_resource_user_unique") | |
| 205 | + .await?; | |
| 206 | + manager | |
| 207 | + .get_connection() | |
| 208 | + .execute_unprepared( | |
| 209 | + "CREATE UNIQUE INDEX idx_resource_shares_resource_user_unique \ | |
| 210 | + ON resource_shares (resource_id, shared_with_user_id)", | |
| 211 | + ) | |
| 212 | + .await?; | |
| 213 | + manager | |
| 214 | + .get_connection() | |
| 215 | + .execute_unprepared( | |
| 216 | + "ALTER TABLE resource_shares DROP CONSTRAINT IF EXISTS chk_resource_shares_principal", | |
| 217 | + ) | |
| 218 | + .await?; | |
| 219 | + manager | |
| 220 | + .get_connection() | |
| 221 | + .execute_unprepared( | |
| 222 | + "ALTER TABLE resource_shares DROP CONSTRAINT IF EXISTS fk_resource_shares_shared_with_team", | |
| 223 | + ) | |
| 224 | + .await?; | |
| 225 | + manager | |
| 226 | + .alter_table( | |
| 227 | + Table::alter() | |
| 228 | + .table(ResourceShares::Table) | |
| 229 | + .drop_column(ResourceShares::SharedWithTeamId) | |
| 230 | + .to_owned(), | |
| 231 | + ) | |
| 232 | + .await?; | |
| 233 | + manager | |
| 234 | + .get_connection() | |
| 235 | + .execute_unprepared( | |
| 236 | + "ALTER TABLE resource_shares ALTER COLUMN shared_with_user_id SET NOT NULL", | |
| 237 | + ) | |
| 238 | + .await?; | |
| 239 | + manager | |
| 240 | + .drop_table(Table::drop().table(TeamMembers::Table).to_owned()) | |
| 241 | + .await?; | |
| 242 | + manager | |
| 243 | + .drop_table(Table::drop().table(Teams::Table).to_owned()) | |
| 244 | + .await | |
| 245 | + } | |
| 246 | +} | |
| 247 | + | |
| 248 | +#[derive(Iden)] | |
| 249 | +enum Teams { | |
| 250 | + Table, | |
| 251 | + Id, | |
| 252 | + Slug, | |
| 253 | + Name, | |
| 254 | + OwnerId, | |
| 255 | + CreatedAt, | |
| 256 | +} | |
| 257 | + | |
| 258 | +#[derive(Iden)] | |
| 259 | +enum TeamMembers { | |
| 260 | + Table, | |
| 261 | + TeamId, | |
| 262 | + UserId, | |
| 263 | + Role, | |
| 264 | + JoinedAt, | |
| 265 | +} | |
| 266 | + | |
| 267 | +#[derive(Iden)] | |
| 268 | +enum ResourceShares { | |
| 269 | + Table, | |
| 270 | + SharedWithTeamId, | |
| 271 | +} | |
| 272 | + | |
| 273 | +#[derive(Iden)] | |
| 274 | +enum Users { | |
| 275 | + Table, | |
| 276 | + Id, | |
| 277 | +} |
M
crates/rustwarden-core/migrations/src/main.rs
+2
-0
@@ -1,3 +1,5 @@
| 1 | +//! CLI entry point for running SeaORM migrations against the Rustwarden database. | |
| 2 | + | |
| 1 | 3 | use sea_orm_migration::prelude::*; |
| 2 | 4 | |
| 3 | 5 | #[path = "lib.rs"] |
M
crates/rustwarden-core/src/app.rs
+37
-22
@@ -18,7 +18,13 @@
| 18 | 18 | //! } |
| 19 | 19 | //! ``` |
| 20 | 20 | |
| 21 | -use axum::{extract::Request, http::{header::HeaderName, HeaderValue}, middleware::Next, response::Response, Router}; | |
| 21 | +use axum::{ | |
| 22 | + extract::Request, | |
| 23 | + http::{header::HeaderName, HeaderValue}, | |
| 24 | + middleware::Next, | |
| 25 | + response::Response, | |
| 26 | + Router, | |
| 27 | +}; | |
| 22 | 28 | use sea_orm::DatabaseConnection; |
| 23 | 29 | use std::path::PathBuf; |
| 24 | 30 | use std::sync::Arc; |
@@ -140,9 +146,7 @@ impl Default for RustwardenBuilder {
| 140 | 146 | enable_email: true, |
| 141 | 147 | enable_background_tasks: true, |
| 142 | 148 | locale_dirs: Vec::new(), |
| 143 | - supported_locales: vec![ | |
| 144 | - "fr".into(), "en".into(), "de".into(), "es".into(), | |
| 145 | - ], | |
| 149 | + supported_locales: vec!["fr".into(), "en".into(), "de".into(), "es".into()], | |
| 146 | 150 | default_locale: "fr".into(), |
| 147 | 151 | } |
| 148 | 152 | } |
@@ -190,7 +194,10 @@ impl RustwardenBuilder {
| 190 | 194 | |
| 191 | 195 | /// Register the default UI routes (login, profile, admin panel). |
| 192 | 196 | /// Pass the function that returns the UI router (from `rustwarden_ui::default_routes`). |
| 193 | - pub fn with_ui(mut self, routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static) -> Self { | |
| 197 | + pub fn with_ui( | |
| 198 | + mut self, | |
| 199 | + routes_fn: impl FnOnce() -> Router<DatabaseConnection> + Send + 'static, | |
| 200 | + ) -> Self { | |
| 194 | 201 | self.ui_routes_fn = Some(Box::new(routes_fn)); |
| 195 | 202 | self |
| 196 | 203 | } |
@@ -274,8 +281,8 @@ impl RustwardenBuilder {
| 274 | 281 | tracing::info!("Default admin and app settings initialized"); |
| 275 | 282 | |
| 276 | 283 | // 3. Auth config & session (SEC-M1: load once, share via Extension) |
| 277 | - let auth_config = AuthConfig::from_env() | |
| 278 | - .map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?; | |
| 284 | + let auth_config = | |
| 285 | + AuthConfig::from_env().map_err(|e| anyhow::anyhow!("Auth config error: {}", e))?; | |
| 279 | 286 | let session_layer = SessionConfig::create_memory_layer(&auth_config); |
| 280 | 287 | let auth_config = Arc::new(auth_config); |
| 281 | 288 | tracing::info!("Session management configured"); |
@@ -316,11 +323,8 @@ impl RustwardenBuilder {
| 316 | 323 | let oauth_registry = { |
| 317 | 324 | use crate::config::oauth::OAuthConfig; |
| 318 | 325 | use crate::services::oauth::providers::{ |
| 319 | - ProviderRegistry, | |
| 320 | - google::GoogleProvider, | |
| 321 | - github::GitHubProvider, | |
| 322 | - discord::DiscordProvider, | |
| 323 | - microsoft::MicrosoftProvider, | |
| 326 | + discord::DiscordProvider, github::GitHubProvider, google::GoogleProvider, | |
| 327 | + microsoft::MicrosoftProvider, ProviderRegistry, | |
| 324 | 328 | }; |
| 325 | 329 | |
| 326 | 330 | let oauth_config = OAuthConfig::load(&db).await; |
@@ -382,8 +386,8 @@ impl RustwardenBuilder {
| 382 | 386 | // 7b. OAuth routes (conditional on feature + config) |
| 383 | 387 | #[cfg(feature = "oauth")] |
| 384 | 388 | { |
| 385 | - use axum::routing::get; | |
| 386 | 389 | use crate::auth::oauth_handlers; |
| 390 | + use axum::routing::get; | |
| 387 | 391 | |
| 388 | 392 | let oauth_routes = Router::new() |
| 389 | 393 | .route( |
@@ -442,7 +446,8 @@ impl RustwardenBuilder {
| 442 | 446 | let app = app.layer(axum::Extension(oauth_registry)); |
| 443 | 447 | |
| 444 | 448 | // 9. Resolve address |
| 445 | - let address = self.server_address | |
| 449 | + let address = self | |
| 450 | + .server_address | |
| 446 | 451 | .unwrap_or_else(|| ServerConfig::from_env().address()); |
| 447 | 452 | |
| 448 | 453 | tracing::info!("Rustwarden application built successfully"); |
@@ -501,10 +506,8 @@ impl RustwardenBuilder {
| 501 | 506 | |
| 502 | 507 | { |
| 503 | 508 | use crate::tasks::password_change_email_retry::PasswordChangeEmailRetryProcessor; |
| 504 | - let retry_processor = PasswordChangeEmailRetryProcessor::new( | |
| 505 | - db_arc.clone(), | |
| 506 | - email_cfg.clone(), | |
| 507 | - ); | |
| 509 | + let retry_processor = | |
| 510 | + PasswordChangeEmailRetryProcessor::new(db_arc.clone(), email_cfg.clone()); | |
| 508 | 511 | retry_processor.start(); |
| 509 | 512 | tracing::info!("Password change email retry task started"); |
| 510 | 513 | } |
@@ -532,11 +535,19 @@ impl RustwardenBuilder {
| 532 | 535 | for (locale, translations) in all_translations { |
| 533 | 536 | key_count += translations.len(); |
| 534 | 537 | locale_count += 1; |
| 535 | - if let Err(e) = service.load_translations(locale.clone(), translations).await { | |
| 538 | + if let Err(e) = service | |
| 539 | + .load_translations(locale.clone(), translations) | |
| 540 | + .await | |
| 541 | + { | |
| 536 | 542 | tracing::error!("Failed to load translations for {}: {}", locale, e); |
| 537 | 543 | } |
| 538 | 544 | } |
| 539 | - tracing::info!("Loaded {} locales ({} keys) from {}", locale_count, key_count, dir); | |
| 545 | + tracing::info!( | |
| 546 | + "Loaded {} locales ({} keys) from {}", | |
| 547 | + locale_count, | |
| 548 | + key_count, | |
| 549 | + dir | |
| 550 | + ); | |
| 540 | 551 | } |
| 541 | 552 | Err(e) => { |
| 542 | 553 | tracing::warn!("Failed to load translations from {}: {}", dir, e); |
@@ -577,7 +588,7 @@ async fn security_headers_middleware(request: Request, next: Next) -> Response {
| 577 | 588 | headers.insert( |
| 578 | 589 | HeaderName::from_static("content-security-policy"), |
| 579 | 590 | HeaderValue::from_static( |
| 580 | - "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'" | |
| 591 | + "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'" | |
| 581 | 592 | ), |
| 582 | 593 | ); |
| 583 | 594 | // SEC-FIX: HSTS conditioned on APP_DEBUG=false (production mode) instead of COOKIE_SECURE |
@@ -606,7 +617,11 @@ async fn locale_middleware(
| 606 | 617 | .get("locale") |
| 607 | 618 | .and_then(|c| { |
| 608 | 619 | let v = c.value().to_string(); |
| 609 | - if supported.contains(&v) { Some(v) } else { None } | |
| 620 | + if supported.contains(&v) { | |
| 621 | + Some(v) | |
| 622 | + } else { | |
| 623 | + None | |
| 624 | + } | |
| 610 | 625 | }) |
| 611 | 626 | .or_else(|| { |
| 612 | 627 | parts |
M
crates/rustwarden-core/src/auth/handlers.rs
+310
-46
@@ -13,11 +13,11 @@ use axum::{
| 13 | 13 | response::{Html, IntoResponse, Response}, |
| 14 | 14 | Extension, Json, |
| 15 | 15 | }; |
| 16 | -use std::sync::Arc; | |
| 17 | 16 | use axum_extra::extract::CookieJar; |
| 18 | 17 | use chrono::{DateTime, Utc}; |
| 19 | 18 | use sea_orm::DatabaseConnection; |
| 20 | 19 | use serde::{Deserialize, Serialize}; |
| 20 | +use std::sync::Arc; | |
| 21 | 21 | use time::Duration; |
| 22 | 22 | use tower_sessions::Session; |
| 23 | 23 | use uuid::Uuid; |
@@ -30,124 +30,178 @@ use crate::error::AppError;
| 30 | 30 | use crate::hooks::RustwardenHooks; |
| 31 | 31 | use crate::services::{ |
| 32 | 32 | auth_logging::AuthLoggingService, auth_service::AuthService, |
| 33 | - email_validation_service::EmailValidationService, | |
| 34 | - jwt_blacklist_service::JwtBlacklistService, password_reset_service::PasswordResetService, | |
| 35 | - password_service::PasswordService, refresh_token_service::RefreshTokenService, | |
| 36 | - role_service::RoleService, user_preferences_service::UserPreferencesService, | |
| 37 | - user_service::UserService, | |
| 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, | |
| 38 | 37 | }; |
| 39 | 38 | |
| 40 | 39 | // --- Auth DTOs (inlined, no separate dto module) --- |
| 41 | 40 | |
| 41 | +/// Request body for POST /api/v1/auth/login. | |
| 42 | 42 | #[derive(Debug, Deserialize, Validate)] |
| 43 | 43 | pub struct LoginRequest { |
| 44 | + /// Username or email address of the authenticating user. | |
| 44 | 45 | #[validate(length(min = 1))] |
| 45 | 46 | pub username: String, |
| 47 | + /// Plaintext password (validated then compared against the stored hash). | |
| 46 | 48 | #[validate(length(min = 1))] |
| 47 | 49 | pub password: String, |
| 48 | - /// Si true, le refresh token a une durée de vie étendue. | |
| 49 | - /// Défaut : false (rétrocompatibilité). | |
| 50 | + /// When true, the refresh token gets an extended lifetime. | |
| 51 | + /// Default: false (backward compatible). | |
| 50 | 52 | #[serde(default)] |
| 51 | 53 | pub remember_me: bool, |
| 52 | 54 | } |
| 53 | 55 | |
| 56 | +/// Compact user representation returned inside login/refresh responses. | |
| 54 | 57 | #[derive(Debug, Serialize)] |
| 55 | 58 | pub struct UserInfo { |
| 59 | + /// User's unique identifier. | |
| 56 | 60 | pub id: Uuid, |
| 61 | + /// User's login name. | |
| 57 | 62 | pub username: String, |
| 63 | + /// User's email address. | |
| 58 | 64 | pub email: String, |
| 65 | + /// List of role names assigned to the user. | |
| 59 | 66 | pub roles: Vec<String>, |
| 67 | + /// When `true`, the user must change their password before proceeding. | |
| 60 | 68 | pub must_change_password: Option<bool>, |
| 69 | + /// Indicates that the user must set up 2FA before being allowed to navigate. | |
| 70 | + #[serde(skip_serializing_if = "Option::is_none")] | |
| 71 | + pub must_setup_2fa: Option<bool>, | |
| 61 | 72 | } |
| 62 | 73 | |
| 74 | +/// Response body for a successful login. | |
| 63 | 75 | #[derive(Debug, Serialize)] |
| 64 | 76 | pub struct LoginResponse { |
| 77 | + /// JWT access token. | |
| 65 | 78 | pub access_token: String, |
| 79 | + /// Opaque refresh token (absent when the server has not issued one). | |
| 66 | 80 | pub refresh_token: Option<String>, |
| 81 | + /// Token type, always `"Bearer"`. | |
| 67 | 82 | pub token_type: String, |
| 83 | + /// Access token lifetime in seconds. | |
| 68 | 84 | pub expires_in: i64, |
| 69 | - /// Durée de vie du refresh token en secondes. | |
| 85 | + /// Refresh token lifetime in seconds. | |
| 70 | 86 | pub refresh_expires_in: i64, |
| 87 | + /// Compact user info embedded in the login response. | |
| 71 | 88 | pub user: UserInfo, |
| 72 | 89 | } |
| 73 | 90 | |
| 91 | +/// Response body for GET /api/v1/auth/me. | |
| 74 | 92 | #[derive(Debug, Serialize)] |
| 75 | 93 | pub struct CurrentUserResponse { |
| 94 | + /// User's unique identifier. | |
| 76 | 95 | pub id: Uuid, |
| 96 | + /// User's login name. | |
| 77 | 97 | pub username: String, |
| 98 | + /// User's email address. | |
| 78 | 99 | pub email: String, |
| 100 | + /// List of role names assigned to the user. | |
| 79 | 101 | pub roles: Vec<String>, |
| 102 | + /// Timestamp when the user account was created. | |
| 80 | 103 | pub created_at: DateTime<Utc>, |
| 104 | + /// Timestamp of the last update to the user account. | |
| 81 | 105 | pub updated_at: DateTime<Utc>, |
| 106 | + /// When `true`, the user must change their password before proceeding. | |
| 82 | 107 | pub must_change_password: bool, |
| 83 | 108 | } |
| 84 | 109 | |
| 110 | +/// Request body for POST /api/v1/auth/refresh. | |
| 85 | 111 | #[derive(Debug, Deserialize, Validate)] |
| 86 | 112 | pub struct RefreshRequest { |
| 113 | + /// The opaque refresh token previously issued by the server. | |
| 87 | 114 | #[validate(length(min = 1))] |
| 88 | 115 | pub refresh_token: String, |
| 89 | 116 | } |
| 90 | 117 | |
| 118 | +/// Response body for a successful token refresh. | |
| 91 | 119 | #[derive(Debug, Serialize)] |
| 92 | 120 | pub struct RefreshResponse { |
| 121 | + /// New JWT access token. | |
| 93 | 122 | pub access_token: String, |
| 123 | + /// New refresh token (rotated on each use). | |
| 94 | 124 | pub refresh_token: Option<String>, |
| 125 | + /// Token type, always `"Bearer"`. | |
| 95 | 126 | pub token_type: String, |
| 127 | + /// Access token lifetime in seconds. | |
| 96 | 128 | pub expires_in: i64, |
| 97 | - /// Durée de vie du refresh token en secondes. | |
| 129 | + /// Refresh token lifetime in seconds. | |
| 98 | 130 | pub refresh_expires_in: i64, |
| 99 | 131 | } |
| 100 | 132 | |
| 133 | +/// Request body for POST /api/v1/auth/forgot-password. | |
| 101 | 134 | #[derive(Debug, Deserialize, Validate)] |
| 102 | 135 | pub struct ForgotPasswordRequest { |
| 136 | + /// Email address to send the password-reset link to. | |
| 103 | 137 | #[validate(email)] |
| 104 | 138 | pub email: String, |
| 105 | 139 | } |
| 106 | 140 | |
| 141 | +/// Response body for the forgot-password endpoint. | |
| 107 | 142 | #[derive(Debug, Serialize)] |
| 108 | 143 | pub struct ForgotPasswordResponse { |
| 144 | + /// Generic confirmation message (does not reveal whether the email exists). | |
| 109 | 145 | pub message: String, |
| 110 | 146 | } |
| 111 | 147 | |
| 148 | +/// Request body for POST /api/v1/auth/reset-password. | |
| 112 | 149 | #[derive(Debug, Deserialize, Validate)] |
| 113 | 150 | pub struct ResetPasswordRequest { |
| 151 | + /// Single-use reset token received via email. | |
| 114 | 152 | #[validate(length(min = 1))] |
| 115 | 153 | pub token: String, |
| 154 | + /// The new plaintext password to set (minimum 8 characters). | |
| 116 | 155 | #[validate(length(min = 8))] |
| 117 | 156 | pub new_password: String, |
| 118 | 157 | } |
| 119 | 158 | |
| 159 | +/// Response body for the reset-password endpoint. | |
| 120 | 160 | #[derive(Debug, Serialize)] |
| 121 | 161 | pub struct ResetPasswordResponse { |
| 162 | + /// Confirmation message returned after a successful password reset. | |
| 122 | 163 | pub message: String, |
| 123 | 164 | } |
| 124 | 165 | |
| 166 | +/// Request body for POST /api/v1/auth/resend-verification. | |
| 125 | 167 | #[derive(Debug, Deserialize, Validate)] |
| 126 | 168 | pub struct ResendVerificationRequest { |
| 169 | + /// Email address to resend the verification link to. | |
| 127 | 170 | #[validate(email)] |
| 128 | 171 | pub email: String, |
| 129 | 172 | } |
| 130 | 173 | |
| 174 | +/// Response body for the resend-verification endpoint. | |
| 131 | 175 | #[derive(Debug, Serialize)] |
| 132 | 176 | pub struct ResendVerificationResponse { |
| 177 | + /// Generic confirmation message (does not reveal whether the email exists). | |
| 133 | 178 | pub message: String, |
| 134 | 179 | } |
| 135 | 180 | |
| 181 | +/// Request body for POST /api/v1/auth/register. | |
| 136 | 182 | #[derive(Debug, Deserialize, Validate)] |
| 137 | 183 | pub struct RegisterRequest { |
| 184 | + /// Desired login name (3–64 characters). | |
| 138 | 185 | #[validate(length(min = 3, max = 64))] |
| 139 | 186 | pub username: String, |
| 187 | + /// Email address for the new account. | |
| 140 | 188 | #[validate(email)] |
| 141 | 189 | pub email: String, |
| 190 | + /// Plaintext password for the new account (minimum 8 characters). | |
| 142 | 191 | #[validate(length(min = 8))] |
| 143 | 192 | pub password: String, |
| 144 | 193 | } |
| 145 | 194 | |
| 195 | +/// Response body for a successful registration. | |
| 146 | 196 | #[derive(Debug, Serialize)] |
| 147 | 197 | pub struct RegisterResponse { |
| 198 | + /// Confirmation message describing next steps (e.g. email verification). | |
| 148 | 199 | pub message: String, |
| 200 | + /// UUID of the newly created user. | |
| 149 | 201 | pub user_id: Uuid, |
| 202 | + /// Login name of the newly created user. | |
| 150 | 203 | pub username: String, |
| 204 | + /// Email address of the newly created user. | |
| 151 | 205 | pub email: String, |
| 152 | 206 | } |
| 153 | 207 |
@@ -277,19 +331,57 @@ pub async fn login(
| 277 | 331 | user.email |
| 278 | 332 | ); |
| 279 | 333 | // Journaliser sans stocker de secrets |
| 280 | - AuthLoggingService::log_blocked_login(&db, &user.email, reason).await?; | |
| 334 | + AuthLoggingService::log_blocked_login( | |
| 335 | + &db, | |
| 336 | + &user.email, | |
| 337 | + reason, | |
| 338 | + hooks.as_ref().map(|Extension(h)| h), | |
| 339 | + ) | |
| 340 | + .await?; | |
| 281 | 341 | return Err(AppError::Validation( |
| 282 | 342 | "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(), |
| 283 | 343 | )); |
| 284 | 344 | } |
| 285 | 345 | |
| 346 | + // Check if 2FA is forced globally | |
| 347 | + let totp_force_enabled = | |
| 348 | + crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 349 | + &db, | |
| 350 | + &["totp_force_enabled"], | |
| 351 | + false, | |
| 352 | + ) | |
| 353 | + .await | |
| 354 | + .unwrap_or(false); | |
| 355 | + | |
| 356 | + // Check if user has TOTP 2FA enabled | |
| 357 | + if crate::services::totp_service::TotpService::is_totp_enabled(&db, user.id).await? { | |
| 358 | + // Create a 2FA challenge instead of issuing tokens | |
| 359 | + let challenge_token = | |
| 360 | + crate::services::totp_service::TotpService::create_challenge(&db, user.id).await?; | |
| 361 | + tracing::info!("2FA challenge created for user {}", user.username); | |
| 362 | + let response = serde_json::json!({ | |
| 363 | + "requires_2fa": true, | |
| 364 | + "challenge_token": challenge_token, | |
| 365 | + }); | |
| 366 | + return Ok((axum::http::StatusCode::OK, Json(response)).into_response()); | |
| 367 | + } | |
| 368 | + | |
| 286 | 369 | // Fetch user roles |
| 287 | 370 | let roles = UserService::get_user_roles(&db, user.id).await?; |
| 288 | 371 | |
| 289 | 372 | // Fetch user permissions |
| 290 | - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?; | |
| 373 | + let permissions = | |
| 374 | + crate::services::permission_service::PermissionService::get_permissions_for_user( | |
| 375 | + &db, user.id, | |
| 376 | + ) | |
| 377 | + .await?; | |
| 291 | 378 | |
| 292 | - tracing::debug!("User {} has roles: {:?}, permissions: {:?}", user.username, roles, permissions); | |
| 379 | + tracing::debug!( | |
| 380 | + "User {} has roles: {:?}, permissions: {:?}", | |
| 381 | + user.username, | |
| 382 | + roles, | |
| 383 | + permissions | |
| 384 | + ); | |
| 293 | 385 | |
| 294 | 386 | // Load auth configuration |
| 295 | 387 | let auth_config = AuthConfig::from_env().map_err(|e| { |
@@ -298,8 +390,13 @@ pub async fn login(
| 298 | 390 | })?; |
| 299 | 391 | |
| 300 | 392 | // Generate JWT token |
| 301 | - let access_token = | |
| 302 | - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?; | |
| 393 | + let access_token = JwtService::generate_token( | |
| 394 | + &auth_config, | |
| 395 | + user.id, | |
| 396 | + &user.username, | |
| 397 | + roles.clone(), | |
| 398 | + permissions, | |
| 399 | + )?; | |
| 303 | 400 | |
| 304 | 401 | // JWT token is already generated above |
| 305 | 402 | // No need to store in session serveur - JWT is stateless |
@@ -312,12 +409,8 @@ pub async fn login(
| 312 | 409 | auth_config.refresh_token_expiration_days |
| 313 | 410 | }; |
| 314 | 411 | |
| 315 | - let created_rt = RefreshTokenService::create_refresh_token( | |
| 316 | - &db, | |
| 317 | - user.id, | |
| 318 | - rt_expiration_days, | |
| 319 | - ) | |
| 320 | - .await?; | |
| 412 | + let created_rt = | |
| 413 | + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; | |
| 321 | 414 | |
| 322 | 415 | let refresh_token = Some(created_rt.plain_token); |
| 323 | 416 |
@@ -327,13 +420,14 @@ pub async fn login(
| 327 | 420 | refresh_token, |
| 328 | 421 | token_type: "Bearer".to_string(), |
| 329 | 422 | expires_in: auth_config.jwt_expiration_minutes * 60, // Convert minutes to seconds |
| 330 | - refresh_expires_in: rt_expiration_days * 86400, // Convert days to seconds | |
| 423 | + refresh_expires_in: rt_expiration_days * 86400, // Convert days to seconds | |
| 331 | 424 | user: UserInfo { |
| 332 | 425 | id: user.id, |
| 333 | 426 | username: user.username.clone(), |
| 334 | 427 | email: user.email.clone(), |
| 335 | 428 | roles, |
| 336 | 429 | must_change_password: Some(user.must_change_password), |
| 430 | + must_setup_2fa: if totp_force_enabled { Some(true) } else { None }, | |
| 337 | 431 | }, |
| 338 | 432 | }; |
| 339 | 433 |
@@ -425,6 +519,154 @@ pub async fn login(
| 425 | 519 | Ok(Response::from_parts(parts, body)) |
| 426 | 520 | } |
| 427 | 521 | |
| 522 | +/// Request payload for 2FA verification | |
| 523 | +#[derive(Debug, Deserialize, Validate)] | |
| 524 | +pub struct Verify2faRequest { | |
| 525 | + /// Challenge token from the login response | |
| 526 | + #[validate(length(min = 1))] | |
| 527 | + pub challenge_token: String, | |
| 528 | + /// TOTP code (6 digits) or backup code (8 alphanumeric chars) | |
| 529 | + #[validate(length(min = 6, max = 16))] | |
| 530 | + pub code: String, | |
| 531 | +} | |
| 532 | + | |
| 533 | +/// Handler for POST /api/v1/auth/2fa/verify | |
| 534 | +/// | |
| 535 | +/// Verifies a TOTP code or backup code for the 2FA login flow. | |
| 536 | +/// On success, issues JWT + refresh token (same as normal login). | |
| 537 | +pub async fn verify_2fa( | |
| 538 | + State(db): State<DatabaseConnection>, | |
| 539 | + _user_session: Session, | |
| 540 | + hooks: Option<Extension<Arc<dyn RustwardenHooks>>>, | |
| 541 | + Json(payload): Json<Verify2faRequest>, | |
| 542 | +) -> Result<Response, AppError> { | |
| 543 | + payload | |
| 544 | + .validate() | |
| 545 | + .map_err(|e| AppError::Validation(format!("Invalid 2FA request: {}", e)))?; | |
| 546 | + | |
| 547 | + use crate::services::totp_service::TotpService; | |
| 548 | + | |
| 549 | + // Validate challenge (checks expiry + max attempts) | |
| 550 | + let user_id = TotpService::validate_challenge(&db, &payload.challenge_token).await?; | |
| 551 | + | |
| 552 | + // Try TOTP code first (6 digits), then backup code | |
| 553 | + let verified = if payload.code.len() == 6 && payload.code.chars().all(|c| c.is_ascii_digit()) { | |
| 554 | + TotpService::verify_code(&db, user_id, &payload.code).await? | |
| 555 | + } else { | |
| 556 | + TotpService::verify_backup_code(&db, user_id, &payload.code).await? | |
| 557 | + }; | |
| 558 | + | |
| 559 | + if !verified { | |
| 560 | + TotpService::record_challenge_attempt(&db, &payload.challenge_token).await?; | |
| 561 | + tracing::warn!(user_id = %user_id, "TOTP_FAILED"); | |
| 562 | + return Err(AppError::TotpFailed); | |
| 563 | + } | |
| 564 | + | |
| 565 | + // 2FA verified — consume challenge and issue tokens | |
| 566 | + TotpService::consume_challenge(&db, &payload.challenge_token).await?; | |
| 567 | + tracing::info!(user_id = %user_id, "TOTP_VERIFIED"); | |
| 568 | + | |
| 569 | + // Fetch user data | |
| 570 | + let user = UserService::get_user_by_id(&db, user_id) | |
| 571 | + .await? | |
| 572 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 573 | + | |
| 574 | + let roles = UserService::get_user_roles(&db, user.id).await?; | |
| 575 | + let permissions = | |
| 576 | + crate::services::permission_service::PermissionService::get_permissions_for_user( | |
| 577 | + &db, user.id, | |
| 578 | + ) | |
| 579 | + .await?; | |
| 580 | + | |
| 581 | + // Load auth config and generate tokens | |
| 582 | + let auth_config = AuthConfig::from_env() | |
| 583 | + .map_err(|e| AppError::Config(format!("Auth configuration error: {}", e)))?; | |
| 584 | + | |
| 585 | + let access_token = JwtService::generate_token( | |
| 586 | + &auth_config, | |
| 587 | + user.id, | |
| 588 | + &user.username, | |
| 589 | + roles.clone(), | |
| 590 | + permissions, | |
| 591 | + )?; | |
| 592 | + | |
| 593 | + let rt_expiration_days = auth_config.refresh_token_expiration_days; | |
| 594 | + let created_rt = | |
| 595 | + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; | |
| 596 | + | |
| 597 | + let response_body = LoginResponse { | |
| 598 | + access_token: access_token.clone(), | |
| 599 | + refresh_token: Some(created_rt.plain_token), | |
| 600 | + token_type: "Bearer".to_string(), | |
| 601 | + expires_in: auth_config.jwt_expiration_minutes * 60, | |
| 602 | + refresh_expires_in: rt_expiration_days * 86400, | |
| 603 | + user: UserInfo { | |
| 604 | + id: user.id, | |
| 605 | + username: user.username.clone(), | |
| 606 | + email: user.email.clone(), | |
| 607 | + roles, | |
| 608 | + must_change_password: Some(user.must_change_password), | |
| 609 | + must_setup_2fa: None, | |
| 610 | + }, | |
| 611 | + }; | |
| 612 | + | |
| 613 | + // Create JWT cookie | |
| 614 | + let same_site = match auth_config.cookie_same_site.to_lowercase().as_str() { | |
| 615 | + "strict" => axum_extra::extract::cookie::SameSite::Strict, | |
| 616 | + "lax" => axum_extra::extract::cookie::SameSite::Lax, | |
| 617 | + "none" => axum_extra::extract::cookie::SameSite::None, | |
| 618 | + _ => axum_extra::extract::cookie::SameSite::Strict, | |
| 619 | + }; | |
| 620 | + | |
| 621 | + let cookie = axum_extra::extract::cookie::Cookie::build(("jwt_token", access_token)) | |
| 622 | + .http_only(true) | |
| 623 | + .same_site(same_site) | |
| 624 | + .secure(auth_config.cookie_secure) | |
| 625 | + .path("/") | |
| 626 | + .max_age(Duration::seconds(auth_config.jwt_expiration_minutes * 60)); | |
| 627 | + | |
| 628 | + let json_response: Response = Json(response_body).into_response(); | |
| 629 | + let (mut parts, body) = json_response.into_parts(); | |
| 630 | + | |
| 631 | + if let Ok(hv) = cookie.to_string().parse() { | |
| 632 | + parts.headers.append(axum::http::header::SET_COOKIE, hv); | |
| 633 | + } | |
| 634 | + | |
| 635 | + // Set preference cookies | |
| 636 | + if let Ok(Some(prefs)) = UserPreferencesService::get_preferences(&db, user.id).await { | |
| 637 | + let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", prefs.theme)) | |
| 638 | + .http_only(false) | |
| 639 | + .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 640 | + .path("/") | |
| 641 | + .max_age(Duration::seconds(31536000)); | |
| 642 | + if let Ok(hv) = theme_cookie.to_string().parse() { | |
| 643 | + parts.headers.append(axum::http::header::SET_COOKIE, hv); | |
| 644 | + } | |
| 645 | + | |
| 646 | + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language)) | |
| 647 | + .http_only(false) | |
| 648 | + .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 649 | + .path("/") | |
| 650 | + .max_age(Duration::seconds(31536000)); | |
| 651 | + if let Ok(hv) = locale_cookie.to_string().parse() { | |
| 652 | + parts.headers.append(axum::http::header::SET_COOKIE, hv); | |
| 653 | + } | |
| 654 | + } | |
| 655 | + | |
| 656 | + // Regenerate session | |
| 657 | + if let Err(e) = _user_session.cycle_id().await { | |
| 658 | + tracing::warn!("Failed to cycle session ID: {}", e); | |
| 659 | + } | |
| 660 | + | |
| 661 | + if let Some(Extension(hooks)) = hooks { | |
| 662 | + if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await { | |
| 663 | + tracing::warn!("Login hook error for {}: {}", user.username, e); | |
| 664 | + } | |
| 665 | + } | |
| 666 | + | |
| 667 | + Ok(Response::from_parts(parts, body)) | |
| 668 | +} | |
| 669 | + | |
| 428 | 670 | /// Handler for GET /api/v1/auth/me |
| 429 | 671 | /// |
| 430 | 672 | /// Returns the current authenticated user's information. |
@@ -741,7 +983,9 @@ pub async fn refresh_token(
| 741 | 983 | ) -> Result<Response, AppError> { |
| 742 | 984 | // SEC-C2: Per-IP rate limiting |
| 743 | 985 | if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { |
| 744 | - return Err(AppError::Validation("Too many requests. Please try again later.".to_string())); | |
| 986 | + return Err(AppError::Validation( | |
| 987 | + "Too many requests. Please try again later.".to_string(), | |
| 988 | + )); | |
| 745 | 989 | } |
| 746 | 990 | |
| 747 | 991 | // Validate input |
@@ -784,7 +1028,11 @@ pub async fn refresh_token(
| 784 | 1028 | let roles = UserService::get_user_roles(&db, user.id).await?; |
| 785 | 1029 | |
| 786 | 1030 | // Get user permissions |
| 787 | - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?; | |
| 1031 | + let permissions = | |
| 1032 | + crate::services::permission_service::PermissionService::get_permissions_for_user( | |
| 1033 | + &db, user.id, | |
| 1034 | + ) | |
| 1035 | + .await?; | |
| 788 | 1036 | |
| 789 | 1037 | // Load auth configuration |
| 790 | 1038 | let auth_config = AuthConfig::from_env().map_err(|e| { |
@@ -793,8 +1041,13 @@ pub async fn refresh_token(
| 793 | 1041 | })?; |
| 794 | 1042 | |
| 795 | 1043 | // Generate new JWT access token |
| 796 | - let access_token = | |
| 797 | - JwtService::generate_token(&auth_config, user.id, &user.username, roles.clone(), permissions)?; | |
| 1044 | + let access_token = JwtService::generate_token( | |
| 1045 | + &auth_config, | |
| 1046 | + user.id, | |
| 1047 | + &user.username, | |
| 1048 | + roles.clone(), | |
| 1049 | + permissions, | |
| 1050 | + )?; | |
| 798 | 1051 | |
| 799 | 1052 | // Preserve original refresh token duration (remember me tokens stay long-lived) |
| 800 | 1053 | let original_duration_days = (old_token.expires_at - old_token.created_at).num_days(); |
@@ -803,12 +1056,8 @@ pub async fn refresh_token(
| 803 | 1056 | .max(auth_config.refresh_token_expiration_days); |
| 804 | 1057 | |
| 805 | 1058 | // Generate new refresh token (rotation) |
| 806 | - let new_created_rt = RefreshTokenService::create_refresh_token( | |
| 807 | - &db, | |
| 808 | - user.id, | |
| 809 | - rt_expiration_days, | |
| 810 | - ) | |
| 811 | - .await?; | |
| 1059 | + let new_created_rt = | |
| 1060 | + RefreshTokenService::create_refresh_token(&db, user.id, rt_expiration_days).await?; | |
| 812 | 1061 | |
| 813 | 1062 | // Mark old token as replaced (completes rotation) |
| 814 | 1063 | RefreshTokenService::mark_as_replaced(&db, old_token.id, new_created_rt.model.id).await?; |
@@ -909,7 +1158,9 @@ pub async fn forgot_password(
| 909 | 1158 | ) -> Result<impl IntoResponse, AppError> { |
| 910 | 1159 | // SEC-C2: Per-IP rate limiting |
| 911 | 1160 | if let Err(_resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { |
| 912 | - return Err(AppError::Validation("Too many requests. Please try again later.".to_string())); | |
| 1161 | + return Err(AppError::Validation( | |
| 1162 | + "Too many requests. Please try again later.".to_string(), | |
| 1163 | + )); | |
| 913 | 1164 | } |
| 914 | 1165 | |
| 915 | 1166 | // Validate email format |
@@ -966,7 +1217,11 @@ pub async fn reset_password(
| 966 | 1217 | .map_err(|e| AppError::Validation(format!("Invalid request: {}", e)))?; |
| 967 | 1218 | |
| 968 | 1219 | // SEC-H1: Validate password complexity from DB rules |
| 969 | - crate::middleware::password_complexity::validate_password_complexity(&db, &payload.new_password).await?; | |
| 1220 | + crate::middleware::password_complexity::validate_password_complexity( | |
| 1221 | + &db, | |
| 1222 | + &payload.new_password, | |
| 1223 | + ) | |
| 1224 | + .await?; | |
| 970 | 1225 | |
| 971 | 1226 | // Load email config |
| 972 | 1227 | let email_config = EmailConfig::from_env() |
@@ -1016,7 +1271,9 @@ pub async fn verify_email(
| 1016 | 1271 | tracing::error!("Email configuration error: {}", e); |
| 1017 | 1272 | return EmailVerifiedTemplate { |
| 1018 | 1273 | success: false, |
| 1019 | - message: i18n_service.translate("email_verify.config_error", loc).await, | |
| 1274 | + message: i18n_service | |
| 1275 | + .translate("email_verify.config_error", loc) | |
| 1276 | + .await, | |
| 1020 | 1277 | }; |
| 1021 | 1278 | } |
| 1022 | 1279 | }; |
@@ -1042,7 +1299,11 @@ pub async fn verify_email(
| 1042 | 1299 | message: match e { |
| 1043 | 1300 | AppError::Validation(msg) => msg, |
| 1044 | 1301 | AppError::NotFound(msg) => msg, |
| 1045 | - _ => i18n_service.translate("email_verify.invalid_link", loc).await, | |
| 1302 | + _ => { | |
| 1303 | + i18n_service | |
| 1304 | + .translate("email_verify.invalid_link", loc) | |
| 1305 | + .await | |
| 1306 | + } | |
| 1046 | 1307 | }, |
| 1047 | 1308 | } |
| 1048 | 1309 | } |
@@ -1099,6 +1360,7 @@ pub async fn resend_verification(
| 1099 | 1360 | /// Request body for resend verification by username |
| 1100 | 1361 | #[derive(Debug, Deserialize, Validate)] |
| 1101 | 1362 | pub struct ResendVerificationByUsernameRequest { |
| 1363 | + /// Login name of the account whose verification email should be resent. | |
| 1102 | 1364 | #[validate(length(min = 1))] |
| 1103 | 1365 | pub username: String, |
| 1104 | 1366 | } |
@@ -1184,7 +1446,9 @@ pub async fn register(
| 1184 | 1446 | ) -> Result<Json<RegisterResponse>, AppError> { |
| 1185 | 1447 | // SEC-C2: Per-IP rate limiting |
| 1186 | 1448 | if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() { |
| 1187 | - return Err(AppError::Validation("Too many requests. Please try again later.".to_string())); | |
| 1449 | + return Err(AppError::Validation( | |
| 1450 | + "Too many requests. Please try again later.".to_string(), | |
| 1451 | + )); | |
| 1188 | 1452 | } |
| 1189 | 1453 | |
| 1190 | 1454 | tracing::info!( |
@@ -1199,16 +1463,16 @@ pub async fn register(
| 1199 | 1463 | })?; |
| 1200 | 1464 | |
| 1201 | 1465 | // SEC-H1: Validate password complexity from DB rules |
| 1202 | - crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password).await?; | |
| 1466 | + crate::middleware::password_complexity::validate_password_complexity(&db, &payload.password) | |
| 1467 | + .await?; | |
| 1203 | 1468 | |
| 1204 | - let allow_registration = | |
| 1205 | - crate::services::app_settings_service::AppSettingsService::get_bool( | |
| 1206 | - &db, | |
| 1207 | - "allow_registration", | |
| 1208 | - false, | |
| 1209 | - ) | |
| 1210 | - .await | |
| 1211 | - .unwrap_or(false); | |
| 1469 | + let allow_registration = crate::services::app_settings_service::AppSettingsService::get_bool( | |
| 1470 | + &db, | |
| 1471 | + "allow_registration", | |
| 1472 | + false, | |
| 1473 | + ) | |
| 1474 | + .await | |
| 1475 | + .unwrap_or(false); | |
| 1212 | 1476 | |
| 1213 | 1477 | if !allow_registration { |
| 1214 | 1478 | tracing::warn!("Registration attempt blocked: registration is disabled"); |
M
crates/rustwarden-core/src/auth/mod.rs
+7
-1
@@ -5,10 +5,16 @@
| 5 | 5 | /// - Session management |
| 6 | 6 | /// - Refresh token system |
| 7 | 7 | /// - Authentication middleware |
| 8 | +/// Request handlers for login, logout, refresh, and current-user endpoints. | |
| 8 | 9 | pub mod extractors; |
| 10 | +/// Authentication HTTP handlers. | |
| 9 | 11 | pub mod handlers; |
| 12 | +/// JWT creation and validation service. | |
| 10 | 13 | pub mod jwt; |
| 14 | +/// Axum extractor that validates a Bearer JWT and provides the authenticated user. | |
| 11 | 15 | pub mod jwt_extractor; |
| 12 | -pub mod session; | |
| 16 | +/// OAuth 2.0 / OIDC handlers (redirect initiation and callback processing). | |
| 13 | 17 | #[cfg(feature = "oauth")] |
| 14 | 18 | pub mod oauth_handlers; |
| 19 | +/// Session helpers (CSRF token generation and retrieval). | |
| 20 | +pub mod session; |
M
crates/rustwarden-core/src/auth/oauth_handlers.rs
+70
-26
@@ -17,19 +17,24 @@ use crate::hooks::RustwardenHooks;
| 17 | 17 | use crate::services::oauth::providers::ProviderRegistry; |
| 18 | 18 | use crate::services::oauth::OAuthService; |
| 19 | 19 | use crate::services::refresh_token_service::RefreshTokenService; |
| 20 | -use crate::services::user_service::UserService; | |
| 21 | 20 | use crate::services::user_preferences_service::UserPreferencesService; |
| 21 | +use crate::services::user_service::UserService; | |
| 22 | 22 | |
| 23 | 23 | const OAUTH_STATE_KEY: &str = "oauth_state"; |
| 24 | 24 | const OAUTH_NONCE_KEY: &str = "oauth_nonce"; |
| 25 | 25 | const OAUTH_PKCE_KEY: &str = "oauth_pkce_verifier"; |
| 26 | 26 | const OAUTH_PROVIDER_KEY: &str = "oauth_provider"; |
| 27 | 27 | |
| 28 | +/// Query parameters received on the OAuth callback URL. | |
| 28 | 29 | #[derive(Deserialize)] |
| 29 | 30 | pub struct OAuthCallbackQuery { |
| 31 | + /// Authorization code returned by the provider on success. | |
| 30 | 32 | pub code: Option<String>, |
| 33 | + /// CSRF state value to verify against the session. | |
| 31 | 34 | pub state: Option<String>, |
| 35 | + /// OAuth error code returned by the provider on failure. | |
| 32 | 36 | pub error: Option<String>, |
| 37 | + /// Human-readable description of the OAuth error. | |
| 33 | 38 | pub error_description: Option<String>, |
| 34 | 39 | } |
| 35 | 40 |
@@ -48,29 +53,41 @@ pub async fn oauth_redirect(
| 48 | 53 | return Err(AppError::Validation("OAuth is not enabled".to_string())); |
| 49 | 54 | } |
| 50 | 55 | |
| 51 | - let provider = registry | |
| 52 | - .get(&provider_name) | |
| 53 | - .ok_or_else(|| AppError::NotFound(format!("OAuth provider '{}' not found", provider_name)))?; | |
| 56 | + let provider = registry.get(&provider_name).ok_or_else(|| { | |
| 57 | + AppError::NotFound(format!("OAuth provider '{}' not found", provider_name)) | |
| 58 | + })?; | |
| 54 | 59 | |
| 55 | 60 | let redirect_uri = oauth_config.callback_url(&provider_name); |
| 56 | 61 | let auth_url = provider.authorization_url(&redirect_uri)?; |
| 57 | 62 | |
| 58 | 63 | // Store security params in session |
| 59 | - session.insert(OAUTH_STATE_KEY, &auth_url.csrf_state).await | |
| 64 | + session | |
| 65 | + .insert(OAUTH_STATE_KEY, &auth_url.csrf_state) | |
| 66 | + .await | |
| 60 | 67 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 61 | - session.insert(OAUTH_PROVIDER_KEY, &provider_name).await | |
| 68 | + session | |
| 69 | + .insert(OAUTH_PROVIDER_KEY, &provider_name) | |
| 70 | + .await | |
| 62 | 71 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 63 | 72 | |
| 64 | 73 | if let Some(ref nonce) = auth_url.nonce { |
| 65 | - session.insert(OAUTH_NONCE_KEY, nonce).await | |
| 74 | + session | |
| 75 | + .insert(OAUTH_NONCE_KEY, nonce) | |
| 76 | + .await | |
| 66 | 77 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 67 | 78 | } |
| 68 | 79 | if let Some(ref verifier) = auth_url.pkce_verifier { |
| 69 | - session.insert(OAUTH_PKCE_KEY, verifier).await | |
| 80 | + session | |
| 81 | + .insert(OAUTH_PKCE_KEY, verifier) | |
| 82 | + .await | |
| 70 | 83 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 71 | 84 | } |
| 72 | 85 | |
| 73 | - tracing::info!("OAuth redirect to {} for provider {}", &auth_url.url[..50.min(auth_url.url.len())], provider_name); | |
| 86 | + tracing::info!( | |
| 87 | + "OAuth redirect to {} for provider {}", | |
| 88 | + &auth_url.url[..50.min(auth_url.url.len())], | |
| 89 | + provider_name | |
| 90 | + ); | |
| 74 | 91 | Ok(Redirect::temporary(&auth_url.url).into_response()) |
| 75 | 92 | } |
| 76 | 93 |
@@ -88,9 +105,10 @@ pub async fn oauth_callback(
| 88 | 105 | Path(provider_name): Path<String>, |
| 89 | 106 | Query(params): Query<OAuthCallbackQuery>, |
| 90 | 107 | ) -> Result<Response, AppError> { |
| 91 | - // SEC-C2: Rate limiting | |
| 92 | - if crate::middleware::rate_limit::check_auth_rate_limit(&headers).is_err() { | |
| 93 | - return Err(AppError::Validation("Too many requests".to_string())); | |
| 108 | + // SEC-C2: Rate limiting — surface the proper 429 + Retry-After response | |
| 109 | + // instead of masking it as a generic 400. | |
| 110 | + if let Err(resp) = crate::middleware::rate_limit::check_auth_rate_limit(&headers) { | |
| 111 | + return Ok(resp); | |
| 94 | 112 | } |
| 95 | 113 | |
| 96 | 114 | // Check for error from provider |
@@ -104,27 +122,39 @@ pub async fn oauth_callback(
| 104 | 122 | return Ok(Redirect::to("/login?error=oauth_denied").into_response()); |
| 105 | 123 | } |
| 106 | 124 | |
| 107 | - let code = params.code.as_deref().ok_or_else(|| { | |
| 108 | - AppError::Validation("Missing authorization code".to_string()) | |
| 109 | - })?; | |
| 125 | + let code = params | |
| 126 | + .code | |
| 127 | + .as_deref() | |
| 128 | + .ok_or_else(|| AppError::Validation("Missing authorization code".to_string()))?; | |
| 110 | 129 | |
| 111 | 130 | // Validate CSRF state |
| 112 | - let session_state: Option<String> = session.get(OAUTH_STATE_KEY).await | |
| 131 | + let session_state: Option<String> = session | |
| 132 | + .get(OAUTH_STATE_KEY) | |
| 133 | + .await | |
| 113 | 134 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 114 | - let session_provider: Option<String> = session.get(OAUTH_PROVIDER_KEY).await | |
| 135 | + let session_provider: Option<String> = session | |
| 136 | + .get(OAUTH_PROVIDER_KEY) | |
| 137 | + .await | |
| 115 | 138 | .map_err(|e| AppError::Internal(format!("Session error: {}", e)))?; |
| 116 | 139 | |
| 117 | 140 | let expected_state = session_state |
| 118 | 141 | .ok_or_else(|| AppError::Validation("Missing OAuth state in session".to_string()))?; |
| 119 | - let received_state = params.state.as_deref() | |
| 142 | + let received_state = params | |
| 143 | + .state | |
| 144 | + .as_deref() | |
| 120 | 145 | .ok_or_else(|| AppError::Validation("Missing state parameter".to_string()))?; |
| 121 | 146 | |
| 122 | 147 | // SEC: Constant-time comparison for state |
| 123 | 148 | use subtle::ConstantTimeEq; |
| 124 | - let state_match: bool = expected_state.as_bytes().ct_eq(received_state.as_bytes()).into(); | |
| 149 | + let state_match: bool = expected_state | |
| 150 | + .as_bytes() | |
| 151 | + .ct_eq(received_state.as_bytes()) | |
| 152 | + .into(); | |
| 125 | 153 | if !state_match { |
| 126 | 154 | tracing::warn!("OAuth state mismatch for provider {}", provider_name); |
| 127 | - return Err(AppError::Validation("OAuth state mismatch (possible CSRF attack)".to_string())); | |
| 155 | + return Err(AppError::Validation( | |
| 156 | + "OAuth state mismatch (possible CSRF attack)".to_string(), | |
| 157 | + )); | |
| 128 | 158 | } |
| 129 | 159 | |
| 130 | 160 | if session_provider.as_deref() != Some(&provider_name) { |
@@ -159,11 +189,16 @@ pub async fn oauth_callback(
| 159 | 189 | .await?; |
| 160 | 190 | |
| 161 | 191 | // Find or create user |
| 162 | - let (user, is_new_user) = OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?; | |
| 192 | + let (user, is_new_user) = | |
| 193 | + OAuthService::find_or_create_user(&db, &provider_name, &user_info).await?; | |
| 163 | 194 | |
| 164 | 195 | // Generate JWT |
| 165 | 196 | let roles = UserService::get_user_roles(&db, user.id).await?; |
| 166 | - let permissions = crate::services::permission_service::PermissionService::get_permissions_for_user(&db, user.id).await?; | |
| 197 | + let permissions = | |
| 198 | + crate::services::permission_service::PermissionService::get_permissions_for_user( | |
| 199 | + &db, user.id, | |
| 200 | + ) | |
| 201 | + .await?; | |
| 167 | 202 | |
| 168 | 203 | let auth_config = AuthConfig::from_env() |
| 169 | 204 | .map_err(|e| AppError::Config(format!("Auth config error: {}", e)))?; |
@@ -207,7 +242,9 @@ pub async fn oauth_callback(
| 207 | 242 | |
| 208 | 243 | let mut response = Redirect::to("/home").into_response(); |
| 209 | 244 | if let Ok(hv) = jwt_cookie.to_string().parse() { |
| 210 | - response.headers_mut().insert(axum::http::header::SET_COOKIE, hv); | |
| 245 | + response | |
| 246 | + .headers_mut() | |
| 247 | + .insert(axum::http::header::SET_COOKIE, hv); | |
| 211 | 248 | } |
| 212 | 249 | |
| 213 | 250 | // Set theme/locale cookies from preferences |
@@ -218,7 +255,9 @@ pub async fn oauth_callback(
| 218 | 255 | .path("/") |
| 219 | 256 | .max_age(time::Duration::seconds(31536000)); |
| 220 | 257 | if let Ok(hv) = theme_cookie.to_string().parse() { |
| 221 | - response.headers_mut().append(axum::http::header::SET_COOKIE, hv); | |
| 258 | + response | |
| 259 | + .headers_mut() | |
| 260 | + .append(axum::http::header::SET_COOKIE, hv); | |
| 222 | 261 | } |
| 223 | 262 | |
| 224 | 263 | let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", prefs.language)) |
@@ -227,7 +266,9 @@ pub async fn oauth_callback(
| 227 | 266 | .path("/") |
| 228 | 267 | .max_age(time::Duration::seconds(31536000)); |
| 229 | 268 | if let Ok(hv) = locale_cookie.to_string().parse() { |
| 230 | - response.headers_mut().append(axum::http::header::SET_COOKIE, hv); | |
| 269 | + response | |
| 270 | + .headers_mut() | |
| 271 | + .append(axum::http::header::SET_COOKIE, hv); | |
| 231 | 272 | } |
| 232 | 273 | } |
| 233 | 274 |
@@ -243,7 +284,10 @@ pub async fn oauth_callback(
| 243 | 284 | if let Err(e) = hooks.on_user_login(&db, user.id, &user.username).await { |
| 244 | 285 | tracing::warn!("on_user_login hook error: {}", e); |
| 245 | 286 | } |
| 246 | - if let Err(e) = hooks.on_oauth_login(&db, user.id, &provider_name, is_new_user).await { | |
| 287 | + if let Err(e) = hooks | |
| 288 | + .on_oauth_login(&db, user.id, &provider_name, is_new_user) | |
| 289 | + .await | |
| 290 | + { | |
| 247 | 291 | tracing::warn!("on_oauth_login hook error: {}", e); |
| 248 | 292 | } |
| 249 | 293 | } |
M
crates/rustwarden-core/src/bin/seed.rs
+1
-4
@@ -170,10 +170,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
| 170 | 170 | UserService::assign_role_to_user(&db, user.id, role.id).await?; |
| 171 | 171 | info!(" Assigned role: {}", role_name); |
| 172 | 172 | } else { |
| 173 | - warn!( | |
| 174 | - "Role '{}' not found, user created without role", | |
| 175 | - role_name | |
| 176 | - ); | |
| 173 | + warn!("Role '{}' not found, user created without role", role_name); | |
| 177 | 174 | } |
| 178 | 175 | |
| 179 | 176 | created += 1; |
M
crates/rustwarden-core/src/config/admin.rs
+10
-1
@@ -10,12 +10,16 @@ use validator::ValidationError;
| 10 | 10 | /// Configuration for the default administrator account |
| 11 | 11 | #[derive(Debug, Clone)] |
| 12 | 12 | pub struct AdminConfig { |
| 13 | + /// Login name for the default admin (env: `ADMIN_USERNAME`, default: `"admin"`). | |
| 13 | 14 | pub username: String, |
| 15 | + /// Email address for the default admin (env: `ADMIN_EMAIL`, default: `"admin@localhost"`). | |
| 14 | 16 | pub email: String, |
| 17 | + /// Optional password override (env: `ADMIN_PASSWORD`); generated if absent. | |
| 15 | 18 | pub password: Option<String>, |
| 16 | 19 | } |
| 17 | 20 | |
| 18 | 21 | impl AdminConfig { |
| 22 | + /// Load admin configuration from environment variables. | |
| 19 | 23 | pub fn from_env() -> Self { |
| 20 | 24 | let username = env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".to_string()); |
| 21 | 25 | let email = env::var("ADMIN_EMAIL").unwrap_or_else(|_| "admin@localhost".to_string()); |
@@ -28,11 +32,16 @@ impl AdminConfig {
| 28 | 32 | } |
| 29 | 33 | } |
| 30 | 34 | |
| 35 | + /// Validate the admin configuration, returning an error if any field is invalid. | |
| 31 | 36 | pub fn validate(&self) -> Result<(), ValidationError> { |
| 32 | 37 | if self.username.len() < 3 || self.username.len() > 64 { |
| 33 | 38 | return Err(ValidationError::new("username_length")); |
| 34 | 39 | } |
| 35 | - if !self.username.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '-') { | |
| 40 | + if !self | |
| 41 | + .username | |
| 42 | + .chars() | |
| 43 | + .all(|c| c.is_alphanumeric() || c == '_' || c == '-') | |
| 44 | + { | |
| 36 | 45 | return Err(ValidationError::new("username_format")); |
| 37 | 46 | } |
| 38 | 47 | if !self.email.contains('@') { |
M
crates/rustwarden-core/src/config/app.rs
+10
-0
@@ -12,30 +12,40 @@ pub fn default_app_name() -> String {
| 12 | 12 | /// Application configuration with feature flags |
| 13 | 13 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 14 | 14 | pub struct AppConfig { |
| 15 | + /// Human-readable application name (env: `APP_NAME`). | |
| 15 | 16 | pub app_name: String, |
| 17 | + /// Application version string taken from `Cargo.toml`. | |
| 16 | 18 | pub app_version: String, |
| 19 | + /// Map of feature flag names to enabled/disabled state. | |
| 17 | 20 | pub features: HashMap<String, bool>, |
| 21 | + /// UI theme identifier (env: `APP_THEME`). | |
| 18 | 22 | pub theme: String, |
| 23 | + /// Whether the application is running in debug/development mode. | |
| 19 | 24 | pub debug_mode: bool, |
| 20 | 25 | } |
| 21 | 26 | |
| 22 | 27 | impl AppConfig { |
| 28 | + /// Create a new `AppConfig` using default values. | |
| 23 | 29 | pub fn new() -> Self { |
| 24 | 30 | Self::default() |
| 25 | 31 | } |
| 26 | 32 | |
| 33 | + /// Return `true` if the named feature flag is enabled. | |
| 27 | 34 | pub fn is_feature_enabled(&self, feature: &str) -> bool { |
| 28 | 35 | self.features.get(feature).copied().unwrap_or(false) |
| 29 | 36 | } |
| 30 | 37 | |
| 38 | + /// Enable the named feature flag. | |
| 31 | 39 | pub fn enable_feature(&mut self, feature: &str) { |
| 32 | 40 | self.features.insert(feature.to_string(), true); |
| 33 | 41 | } |
| 34 | 42 | |
| 43 | + /// Disable the named feature flag. | |
| 35 | 44 | pub fn disable_feature(&mut self, feature: &str) { |
| 36 | 45 | self.features.insert(feature.to_string(), false); |
| 37 | 46 | } |
| 38 | 47 | |
| 48 | + /// Load application configuration from environment variables. | |
| 39 | 49 | pub fn from_env() -> Self { |
| 40 | 50 | let mut config = Self::default(); |
| 41 | 51 | if let Ok(name) = std::env::var("APP_NAME") { |
M
crates/rustwarden-core/src/config/auth.rs
+24
-5
@@ -5,12 +5,14 @@
| 5 | 5 | use std::env; |
| 6 | 6 | |
| 7 | 7 | /// Session backend storage type |
| 8 | -#[derive(Debug, Clone, PartialEq)] | |
| 9 | -#[derive(Default)] | |
| 8 | +#[derive(Debug, Clone, PartialEq, Default)] | |
| 10 | 9 | pub enum SessionBackend { |
| 10 | + /// In-process memory store (default; not suitable for multi-instance deployments). | |
| 11 | 11 | #[default] |
| 12 | 12 | Memory, |
| 13 | + /// Persist sessions in the database via SeaORM. | |
| 13 | 14 | SeaOrm, |
| 15 | + /// Persist sessions in a Redis instance. | |
| 14 | 16 | Redis, |
| 15 | 17 | } |
| 16 | 18 |
@@ -33,24 +35,37 @@ impl std::str::FromStr for SessionBackend {
| 33 | 35 | /// Authentication configuration loaded from environment variables |
| 34 | 36 | #[derive(Debug, Clone)] |
| 35 | 37 | pub struct AuthConfig { |
| 38 | + /// Secret key used to sign and verify JWTs (env: `JWT_SECRET`, min 32 bytes). | |
| 36 | 39 | pub jwt_secret: String, |
| 40 | + /// JWT access token lifetime in minutes (env: `JWT_EXPIRATION_MINUTES`, default: 15). | |
| 37 | 41 | pub jwt_expiration_minutes: i64, |
| 42 | + /// Issuer claim embedded in JWTs (env: `JWT_ISSUER`, default: `"rustwarden"`). | |
| 38 | 43 | pub jwt_issuer: String, |
| 44 | + /// Refresh token lifetime in days (env: `REFRESH_TOKEN_EXPIRATION_DAYS`, default: 7). | |
| 39 | 45 | pub refresh_token_expiration_days: i64, |
| 40 | - /// Durée du refresh token quand "remember me" est activé. | |
| 41 | - /// Variable : REMEMBER_ME_EXPIRATION_DAYS (défaut : 30) | |
| 46 | + /// Refresh token lifetime when "remember me" is enabled. | |
| 47 | + /// Env variable: REMEMBER_ME_EXPIRATION_DAYS (default: 30) | |
| 42 | 48 | pub remember_me_expiration_days: i64, |
| 49 | + /// Idle session timeout in minutes (env: `SESSION_TIMEOUT_MINUTES`, default: 30). | |
| 43 | 50 | pub session_timeout_minutes: i64, |
| 51 | + /// Storage backend used for server-side sessions. | |
| 44 | 52 | pub session_backend: SessionBackend, |
| 53 | + /// Redis connection URL, required when `session_backend` is `Redis`. | |
| 45 | 54 | pub redis_url: Option<String>, |
| 55 | + /// Maximum login attempts per minute per IP (env: `RATE_LIMIT_LOGIN_PER_MINUTE`, default: 5). | |
| 46 | 56 | pub rate_limit_login_per_minute: u32, |
| 57 | + /// Maximum token-refresh attempts per minute per IP (env: `RATE_LIMIT_REFRESH_PER_MINUTE`, default: 10). | |
| 47 | 58 | pub rate_limit_refresh_per_minute: u32, |
| 59 | + /// Maximum general API requests per minute per IP (env: `RATE_LIMIT_GENERAL_PER_MINUTE`, default: 100). | |
| 48 | 60 | pub rate_limit_general_per_minute: u32, |
| 61 | + /// Whether the `Secure` flag is set on auth cookies. | |
| 49 | 62 | pub cookie_secure: bool, |
| 63 | + /// Value of the `SameSite` cookie attribute (env: `COOKIE_SAME_SITE`, default: `"Strict"`). | |
| 50 | 64 | pub cookie_same_site: String, |
| 51 | 65 | } |
| 52 | 66 | |
| 53 | 67 | impl AuthConfig { |
| 68 | + /// Load and validate authentication configuration from environment variables. | |
| 54 | 69 | pub fn from_env() -> Result<Self, String> { |
| 55 | 70 | let jwt_secret = env::var("JWT_SECRET") |
| 56 | 71 | .map_err(|_| "JWT_SECRET environment variable is required".to_string())?; |
@@ -173,7 +188,8 @@ impl AuthConfig {
| 173 | 188 | }; |
| 174 | 189 | |
| 175 | 190 | // SEC-L8: Default SameSite to Strict (matching security comments) |
| 176 | - let cookie_same_site = env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string()); | |
| 191 | + let cookie_same_site = | |
| 192 | + env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string()); | |
| 177 | 193 | |
| 178 | 194 | tracing::info!( |
| 179 | 195 | "Auth configuration loaded: JWT expiration={}min, Session backend={:?}, Session timeout={}min", |
@@ -205,16 +221,19 @@ impl AuthConfig {
| 205 | 221 | }) |
| 206 | 222 | } |
| 207 | 223 | |
| 224 | + /// Return the JWT expiration as a `chrono::Duration`. | |
| 208 | 225 | #[allow(dead_code)] |
| 209 | 226 | pub fn jwt_expiration_duration(&self) -> chrono::Duration { |
| 210 | 227 | chrono::Duration::minutes(self.jwt_expiration_minutes) |
| 211 | 228 | } |
| 212 | 229 | |
| 230 | + /// Return the refresh token expiration as a `chrono::Duration`. | |
| 213 | 231 | #[allow(dead_code)] |
| 214 | 232 | pub fn refresh_token_expiration_duration(&self) -> chrono::Duration { |
| 215 | 233 | chrono::Duration::days(self.refresh_token_expiration_days) |
| 216 | 234 | } |
| 217 | 235 | |
| 236 | + /// Return the session timeout as a `chrono::Duration`. | |
| 218 | 237 | #[allow(dead_code)] |
| 219 | 238 | pub fn session_timeout_duration(&self) -> chrono::Duration { |
| 220 | 239 | chrono::Duration::minutes(self.session_timeout_minutes) |
M
crates/rustwarden-core/src/config/mod.rs
+10
-2
@@ -1,12 +1,20 @@
| 1 | 1 | //! Configuration module for Rustwarden |
| 2 | 2 | //! |
| 3 | 3 | //! Server, database, authentication, email, and admin configuration. |
| 4 | +/// Admin account bootstrapping configuration. | |
| 4 | 5 | pub mod admin; |
| 6 | +/// Application-level configuration (name, base URL, etc.). | |
| 5 | 7 | pub mod app; |
| 8 | +/// Authentication configuration (JWT, sessions, rate limits). | |
| 6 | 9 | pub mod auth; |
| 10 | +/// Database connection configuration. | |
| 7 | 11 | pub mod database; |
| 12 | +/// Email (SMTP) configuration. | |
| 8 | 13 | pub mod email; |
| 9 | -pub mod server; | |
| 10 | -pub mod tasks; | |
| 14 | +/// OAuth provider configuration. | |
| 11 | 15 | #[cfg(feature = "oauth")] |
| 12 | 16 | pub mod oauth; |
| 17 | +/// HTTP server configuration (host, port, TLS). | |
| 18 | +pub mod server; | |
| 19 | +/// Background task scheduler configuration. | |
| 20 | +pub mod tasks; |
M
crates/rustwarden-core/src/config/oauth.rs
+19
-2
@@ -8,25 +8,37 @@ use crate::services::crypto_service::CryptoService;
| 8 | 8 | /// Configuration for a single OAuth provider. |
| 9 | 9 | #[derive(Debug, Clone)] |
| 10 | 10 | pub struct OAuthProviderConfig { |
| 11 | + /// Provider identifier (e.g. `"google"`, `"github"`). | |
| 11 | 12 | pub name: String, |
| 13 | + /// OAuth client ID registered with the provider. | |
| 12 | 14 | pub client_id: String, |
| 15 | + /// OAuth client secret (may be AES-GCM encrypted with the `enc:` prefix). | |
| 13 | 16 | pub client_secret: String, |
| 17 | + /// Azure AD / Microsoft tenant ID (only used by the Microsoft provider). | |
| 14 | 18 | pub tenant: Option<String>, |
| 19 | + /// Whether this provider is enabled and available for login. | |
| 15 | 20 | pub enabled: bool, |
| 16 | 21 | } |
| 17 | 22 | |
| 18 | 23 | /// Global OAuth configuration. |
| 19 | 24 | #[derive(Debug, Clone)] |
| 20 | 25 | pub struct OAuthConfig { |
| 26 | + /// Whether OAuth login is enabled globally. | |
| 21 | 27 | pub enabled: bool, |
| 28 | + /// Base URL used to build provider callback URLs (env: `OAUTH_REDIRECT_BASE_URL`). | |
| 22 | 29 | pub redirect_base_url: String, |
| 30 | + /// Automatically register a new user when an unknown OAuth identity logs in. | |
| 23 | 31 | pub auto_register: bool, |
| 32 | + /// Link an OAuth identity to an existing account with the same email. | |
| 24 | 33 | pub link_existing_account: bool, |
| 34 | + /// Configured OAuth providers (only providers with both `client_id` and `client_secret` are included). | |
| 25 | 35 | pub providers: Vec<OAuthProviderConfig>, |
| 26 | 36 | } |
| 27 | 37 | |
| 28 | 38 | impl OAuthConfig { |
| 29 | - pub const KNOWN_PROVIDERS: &'static [&'static str] = &["google", "github", "discord", "microsoft"]; | |
| 39 | + /// Names of all recognised OAuth providers. | |
| 40 | + pub const KNOWN_PROVIDERS: &'static [&'static str] = | |
| 41 | + &["google", "github", "discord", "microsoft"]; | |
| 30 | 42 | |
| 31 | 43 | /// Load OAuth configuration from the database, falling back to environment |
| 32 | 44 | /// variables for any value not set in the DB. |
@@ -126,7 +138,9 @@ impl OAuthConfig {
| 126 | 138 | } |
| 127 | 139 | |
| 128 | 140 | if enabled && providers.is_empty() { |
| 129 | - tracing::warn!("OAuth is enabled but no providers are configured (neither DB nor env vars)"); | |
| 141 | + tracing::warn!( | |
| 142 | + "OAuth is enabled but no providers are configured (neither DB nor env vars)" | |
| 143 | + ); | |
| 130 | 144 | } |
| 131 | 145 | |
| 132 | 146 | Self { |
@@ -187,14 +201,17 @@ impl OAuthConfig {
| 187 | 201 | } |
| 188 | 202 | } |
| 189 | 203 | |
| 204 | + /// Return all providers that are currently enabled. | |
| 190 | 205 | pub fn enabled_providers(&self) -> Vec<&OAuthProviderConfig> { |
| 191 | 206 | self.providers.iter().filter(|p| p.enabled).collect() |
| 192 | 207 | } |
| 193 | 208 | |
| 209 | + /// Look up an enabled provider by name. | |
| 194 | 210 | pub fn get_provider(&self, name: &str) -> Option<&OAuthProviderConfig> { |
| 195 | 211 | self.providers.iter().find(|p| p.name == name && p.enabled) |
| 196 | 212 | } |
| 197 | 213 | |
| 214 | + /// Build the OAuth callback URL for the given provider name. | |
| 198 | 215 | pub fn callback_url(&self, provider_name: &str) -> String { |
| 199 | 216 | format!( |
| 200 | 217 | "{}/api/v1/auth/oauth/{}/callback", |
M
crates/rustwarden-core/src/config/tasks.rs
+8
-0
@@ -71,6 +71,14 @@ pub fn jwt_cleanup_interval_secs() -> u64 {
| 71 | 71 | env_u64("JWT_CLEANUP_INTERVAL_SECS", 3600) |
| 72 | 72 | } |
| 73 | 73 | |
| 74 | +// ── Personal access token cleanup ────────────────────────────────── | |
| 75 | + | |
| 76 | +/// Interval between PAT cleanup runs (seconds). | |
| 77 | +/// Env: `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` — default 3600 (1 h) | |
| 78 | +pub fn personal_token_cleanup_interval_secs() -> u64 { | |
| 79 | + env_u64("PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS", 3600) | |
| 80 | +} | |
| 81 | + | |
| 74 | 82 | // ── Token expiration durations ───────────────────────────────────── |
| 75 | 83 | |
| 76 | 84 | /// Password-change request expiration (hours). |
M
crates/rustwarden-core/src/error.rs
+146
-0
@@ -13,51 +13,117 @@ use thiserror::Error;
| 13 | 13 | |
| 14 | 14 | #[derive(Error, Debug)] |
| 15 | 15 | pub enum AppError { |
| 16 | + /// A SeaORM database operation failed. | |
| 16 | 17 | #[error("Database error: {0}")] |
| 17 | 18 | Database(#[from] sea_orm::DbErr), |
| 18 | 19 | |
| 20 | + /// User-supplied input failed validation. The message describes what was invalid. | |
| 19 | 21 | #[error("Validation error: {0}")] |
| 20 | 22 | Validation(String), |
| 21 | 23 | |
| 24 | + /// The requested resource could not be found. | |
| 22 | 25 | #[error("Resource not found: {0}")] |
| 23 | 26 | NotFound(String), |
| 24 | 27 | |
| 28 | + /// An unexpected internal error occurred. The message contains diagnostic detail. | |
| 25 | 29 | #[error("Internal server error: {0}")] |
| 26 | 30 | Internal(String), |
| 27 | 31 | |
| 32 | + /// The supplied username/password combination is incorrect. | |
| 28 | 33 | #[error("Invalid credentials")] |
| 29 | 34 | InvalidCredentials, |
| 30 | 35 | |
| 36 | + /// The supplied token is malformed or does not match any known record. | |
| 31 | 37 | #[error("Invalid token")] |
| 32 | 38 | InvalidToken, |
| 33 | 39 | |
| 40 | + /// The token was valid but its expiration time has passed. | |
| 34 | 41 | #[error("Token expired")] |
| 35 | 42 | TokenExpired, |
| 36 | 43 | |
| 44 | + /// A refresh token was presented that had already been used, indicating possible token theft. | |
| 37 | 45 | #[error("Token reused - possible security breach")] |
| 38 | 46 | TokenReused, |
| 39 | 47 | |
| 48 | + /// The user must set a new password before accessing the application. | |
| 40 | 49 | #[allow(dead_code)] |
| 41 | 50 | #[error("Password change required")] |
| 42 | 51 | MustChangePassword, |
| 43 | 52 | |
| 53 | + /// The request requires authentication but no valid credentials were provided. | |
| 44 | 54 | #[allow(dead_code)] |
| 45 | 55 | #[error("Authentication required")] |
| 46 | 56 | Unauthorized, |
| 47 | 57 | |
| 58 | + /// The authenticated user does not have permission to perform this action. | |
| 48 | 59 | #[allow(dead_code)] |
| 49 | 60 | #[error("Insufficient permissions")] |
| 50 | 61 | Forbidden, |
| 51 | 62 | |
| 63 | + /// A required configuration value is missing or invalid. | |
| 52 | 64 | #[error("Configuration error: {0}")] |
| 53 | 65 | Config(String), |
| 54 | 66 | |
| 67 | + /// An Askama template failed to render. | |
| 55 | 68 | #[error("Template rendering error: {0}")] |
| 56 | 69 | Template(String), |
| 70 | + | |
| 71 | + /// The submitted TOTP code is incorrect. | |
| 72 | + #[error("TOTP verification failed")] | |
| 73 | + TotpFailed, | |
| 74 | + | |
| 75 | + /// The TOTP challenge token has expired or does not exist. | |
| 76 | + #[error("TOTP challenge expired or invalid")] | |
| 77 | + TotpChallengeInvalid, | |
| 78 | + | |
| 79 | + /// The TOTP challenge was failed too many times and is now locked. | |
| 80 | + #[error("TOTP challenge max attempts exceeded")] | |
| 81 | + TotpMaxAttemptsExceeded, | |
| 82 | + | |
| 83 | + /// The account has TOTP enabled; a second-factor code must be provided to continue. | |
| 84 | + #[error("Two-factor authentication required")] | |
| 85 | + TotpRequired, | |
| 86 | + | |
| 87 | + /// TOTP cannot be enabled because it is already active for this user. | |
| 88 | + #[allow(dead_code)] | |
| 89 | + #[error("TOTP already enabled")] | |
| 90 | + TotpAlreadyEnabled, | |
| 91 | + | |
| 92 | + /// The requested operation requires TOTP to be enabled, but it is not. | |
| 93 | + #[allow(dead_code)] | |
| 94 | + #[error("TOTP not enabled")] | |
| 95 | + TotpNotEnabled, | |
| 96 | + | |
| 97 | + /// Client exceeded a rate limit. Carries the `Retry-After` header value (seconds). | |
| 98 | + #[error("Too many requests, retry after {retry_after_secs}s")] | |
| 99 | + TooManyRequests { | |
| 100 | + /// Number of seconds the client should wait before retrying. | |
| 101 | + retry_after_secs: u64, | |
| 102 | + }, | |
| 57 | 103 | } |
| 58 | 104 | |
| 59 | 105 | impl IntoResponse for AppError { |
| 60 | 106 | fn into_response(self) -> Response { |
| 107 | + // 429 has a required Retry-After header, so handle it before the generic | |
| 108 | + // (status, body) path used by other variants. | |
| 109 | + if let AppError::TooManyRequests { retry_after_secs } = self { | |
| 110 | + tracing::warn!( | |
| 111 | + retry_after_secs, | |
| 112 | + "Rate limit exceeded, returning 429 Too Many Requests" | |
| 113 | + ); | |
| 114 | + let body = Json(json!({ | |
| 115 | + "error": "Too many requests, please retry later", | |
| 116 | + "status": StatusCode::TOO_MANY_REQUESTS.as_u16(), | |
| 117 | + "retry_after_secs": retry_after_secs, | |
| 118 | + })); | |
| 119 | + return ( | |
| 120 | + StatusCode::TOO_MANY_REQUESTS, | |
| 121 | + [("Retry-After", retry_after_secs.to_string())], | |
| 122 | + body, | |
| 123 | + ) | |
| 124 | + .into_response(); | |
| 125 | + } | |
| 126 | + | |
| 61 | 127 | let (status, error_message) = match self { |
| 62 | 128 | AppError::Database(err) => { |
| 63 | 129 | tracing::error!("Database error: {}", err); |
@@ -141,6 +207,48 @@ impl IntoResponse for AppError {
| 141 | 207 | "Template error".to_string(), |
| 142 | 208 | ) |
| 143 | 209 | } |
| 210 | + AppError::TotpFailed => { | |
| 211 | + tracing::warn!("TOTP verification failed"); | |
| 212 | + ( | |
| 213 | + StatusCode::UNAUTHORIZED, | |
| 214 | + "TOTP verification failed".to_string(), | |
| 215 | + ) | |
| 216 | + } | |
| 217 | + AppError::TotpChallengeInvalid => { | |
| 218 | + tracing::warn!("TOTP challenge expired or invalid"); | |
| 219 | + ( | |
| 220 | + StatusCode::UNAUTHORIZED, | |
| 221 | + "TOTP challenge expired or invalid".to_string(), | |
| 222 | + ) | |
| 223 | + } | |
| 224 | + AppError::TotpMaxAttemptsExceeded => { | |
| 225 | + tracing::warn!("TOTP challenge max attempts exceeded"); | |
| 226 | + ( | |
| 227 | + StatusCode::TOO_MANY_REQUESTS, | |
| 228 | + "Too many TOTP attempts".to_string(), | |
| 229 | + ) | |
| 230 | + } | |
| 231 | + AppError::TotpRequired => { | |
| 232 | + tracing::info!("Two-factor authentication setup required"); | |
| 233 | + ( | |
| 234 | + StatusCode::FORBIDDEN, | |
| 235 | + "Two-factor authentication required".to_string(), | |
| 236 | + ) | |
| 237 | + } | |
| 238 | + AppError::TotpAlreadyEnabled => { | |
| 239 | + tracing::info!("TOTP already enabled for user"); | |
| 240 | + ( | |
| 241 | + StatusCode::BAD_REQUEST, | |
| 242 | + "TOTP is already enabled".to_string(), | |
| 243 | + ) | |
| 244 | + } | |
| 245 | + AppError::TotpNotEnabled => { | |
| 246 | + tracing::info!("TOTP not enabled for user"); | |
| 247 | + (StatusCode::BAD_REQUEST, "TOTP is not enabled".to_string()) | |
| 248 | + } | |
| 249 | + AppError::TooManyRequests { .. } => { | |
| 250 | + unreachable!("TooManyRequests is handled above to include the Retry-After header") | |
| 251 | + } | |
| 144 | 252 | }; |
| 145 | 253 | |
| 146 | 254 | let body = Json(json!({ |
@@ -151,3 +259,41 @@ impl IntoResponse for AppError {
| 151 | 259 | (status, body).into_response() |
| 152 | 260 | } |
| 153 | 261 | } |
| 262 | + | |
| 263 | +#[cfg(test)] | |
| 264 | +mod tests { | |
| 265 | + use super::*; | |
| 266 | + use axum::body::to_bytes; | |
| 267 | + | |
| 268 | + #[tokio::test] | |
| 269 | + async fn too_many_requests_returns_429_with_retry_after_header() { | |
| 270 | + let err = AppError::TooManyRequests { | |
| 271 | + retry_after_secs: 42, | |
| 272 | + }; | |
| 273 | + let response = err.into_response(); | |
| 274 | + | |
| 275 | + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); | |
| 276 | + let retry_after = response | |
| 277 | + .headers() | |
| 278 | + .get("Retry-After") | |
| 279 | + .expect("Retry-After header must be set for 429 responses"); | |
| 280 | + assert_eq!(retry_after.to_str().unwrap(), "42"); | |
| 281 | + } | |
| 282 | + | |
| 283 | + #[tokio::test] | |
| 284 | + async fn too_many_requests_body_contains_error_message() { | |
| 285 | + let err = AppError::TooManyRequests { | |
| 286 | + retry_after_secs: 1, | |
| 287 | + }; | |
| 288 | + let response = err.into_response(); | |
| 289 | + let bytes = to_bytes(response.into_body(), 1024).await.unwrap(); | |
| 290 | + let body: serde_json::Value = serde_json::from_slice(&bytes).unwrap(); | |
| 291 | + | |
| 292 | + assert_eq!(body["status"], 429); | |
| 293 | + assert!(body["error"] | |
| 294 | + .as_str() | |
| 295 | + .unwrap() | |
| 296 | + .to_lowercase() | |
| 297 | + .contains("too many")); | |
| 298 | + } | |
| 299 | +} |
M
crates/rustwarden-core/src/hooks.rs
+361
-5
@@ -24,6 +24,29 @@ use async_trait::async_trait;
| 24 | 24 | use sea_orm::DatabaseConnection; |
| 25 | 25 | use uuid::Uuid; |
| 26 | 26 | |
| 27 | +/// Payload handed to [`RustwardenHooks::on_audit_event`]. | |
| 28 | +/// | |
| 29 | +/// Generic escape hatch for framework-level audit wiring: business events that | |
| 30 | +/// don't warrant a dedicated trait method (issue created, PR merged, wall | |
| 31 | +/// shared, etc.) are dispatched here by the core, so consumer apps can plug | |
| 32 | +/// monitoring / SIEM indexing / compliance logging in one place without | |
| 33 | +/// forking the trait. | |
| 34 | +/// | |
| 35 | +/// Borrowed fields are cheap — hook implementations should copy what they | |
| 36 | +/// need before returning (e.g. clone the metadata JSON into storage). | |
| 37 | +#[derive(Debug, Clone, Copy)] | |
| 38 | +pub struct AuditEvent<'a> { | |
| 39 | + /// Short namespaced identifier (`"user.login_success"`, `"repo.created"`). | |
| 40 | + pub kind: &'a str, | |
| 41 | + /// User who triggered the event, if known (system events may leave it `None`). | |
| 42 | + pub actor_id: Option<Uuid>, | |
| 43 | + /// `(resource_type, resource_id)` of the event's target, if any. | |
| 44 | + pub target: Option<(&'a str, Uuid)>, | |
| 45 | + /// Free-form structured metadata — serialize-friendly; consumers decide | |
| 46 | + /// the shape per `kind`. | |
| 47 | + pub metadata: &'a serde_json::Value, | |
| 48 | +} | |
| 49 | + | |
| 27 | 50 | /// Lifecycle hooks for extending Rustwarden behavior. |
| 28 | 51 | /// |
| 29 | 52 | /// All methods have default no-op implementations, so you only need to |
@@ -51,11 +74,7 @@ pub trait RustwardenHooks: Send + Sync {
| 51 | 74 | } |
| 52 | 75 | |
| 53 | 76 | /// Called after a user logs out. |
| 54 | - async fn on_user_logout( | |
| 55 | - &self, | |
| 56 | - _db: &DatabaseConnection, | |
| 57 | - _user_id: Uuid, | |
| 58 | - ) -> anyhow::Result<()> { | |
| 77 | + async fn on_user_logout(&self, _db: &DatabaseConnection, _user_id: Uuid) -> anyhow::Result<()> { | |
| 59 | 78 | Ok(()) |
| 60 | 79 | } |
| 61 | 80 |
@@ -125,6 +144,24 @@ pub trait RustwardenHooks: Send + Sync {
| 125 | 144 | Ok(()) |
| 126 | 145 | } |
| 127 | 146 | |
| 147 | + /// Called after a user enables TOTP two-factor authentication. | |
| 148 | + async fn on_totp_enabled( | |
| 149 | + &self, | |
| 150 | + _db: &DatabaseConnection, | |
| 151 | + _user_id: Uuid, | |
| 152 | + ) -> anyhow::Result<()> { | |
| 153 | + Ok(()) | |
| 154 | + } | |
| 155 | + | |
| 156 | + /// Called after a user disables TOTP two-factor authentication. | |
| 157 | + async fn on_totp_disabled( | |
| 158 | + &self, | |
| 159 | + _db: &DatabaseConnection, | |
| 160 | + _user_id: Uuid, | |
| 161 | + ) -> anyhow::Result<()> { | |
| 162 | + Ok(()) | |
| 163 | + } | |
| 164 | + | |
| 128 | 165 | /// Called after a successful OAuth login (new or existing user). |
| 129 | 166 | async fn on_oauth_login( |
| 130 | 167 | &self, |
@@ -135,6 +172,89 @@ pub trait RustwardenHooks: Send + Sync {
| 135 | 172 | ) -> anyhow::Result<()> { |
| 136 | 173 | Ok(()) |
| 137 | 174 | } |
| 175 | + | |
| 176 | + /// Called after a resource (repo, wall, workspace…) has been registered | |
| 177 | + /// with [`ResourceService`](crate::services::resource_service::ResourceService). | |
| 178 | + /// | |
| 179 | + /// Replaces ad-hoc filtering by `resource_type == "…"` in custom hook | |
| 180 | + /// implementations (see gitrust-hooks in the consumer ecosystem). | |
| 181 | + async fn on_resource_registered( | |
| 182 | + &self, | |
| 183 | + _db: &DatabaseConnection, | |
| 184 | + _resource_type: &str, | |
| 185 | + _resource_id: Uuid, | |
| 186 | + _owner_id: Uuid, | |
| 187 | + ) -> anyhow::Result<()> { | |
| 188 | + Ok(()) | |
| 189 | + } | |
| 190 | + | |
| 191 | + /// Called after a resource has been unregistered. Use this to release | |
| 192 | + /// filesystem / external resources tied to the entity. | |
| 193 | + async fn on_resource_unregistered( | |
| 194 | + &self, | |
| 195 | + _db: &DatabaseConnection, | |
| 196 | + _resource_type: &str, | |
| 197 | + _resource_id: Uuid, | |
| 198 | + ) -> anyhow::Result<()> { | |
| 199 | + Ok(()) | |
| 200 | + } | |
| 201 | + | |
| 202 | + /// Called after a team has been created. Prerequisite for Phase 5 | |
| 203 | + /// (`TeamService`) of the upstream evolution plan — consumers can pre-wire | |
| 204 | + /// auditing today. | |
| 205 | + async fn on_team_created( | |
| 206 | + &self, | |
| 207 | + _db: &DatabaseConnection, | |
| 208 | + _team_id: Uuid, | |
| 209 | + _owner_id: Uuid, | |
| 210 | + ) -> anyhow::Result<()> { | |
| 211 | + Ok(()) | |
| 212 | + } | |
| 213 | + | |
| 214 | + /// Called after a team has been deleted. | |
| 215 | + async fn on_team_deleted( | |
| 216 | + &self, | |
| 217 | + _db: &DatabaseConnection, | |
| 218 | + _team_id: Uuid, | |
| 219 | + ) -> anyhow::Result<()> { | |
| 220 | + Ok(()) | |
| 221 | + } | |
| 222 | + | |
| 223 | + /// Called after a user has been added to a team. | |
| 224 | + async fn on_team_member_added( | |
| 225 | + &self, | |
| 226 | + _db: &DatabaseConnection, | |
| 227 | + _team_id: Uuid, | |
| 228 | + _user_id: Uuid, | |
| 229 | + ) -> anyhow::Result<()> { | |
| 230 | + Ok(()) | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// Called after a user has been removed from a team. | |
| 234 | + async fn on_team_member_removed( | |
| 235 | + &self, | |
| 236 | + _db: &DatabaseConnection, | |
| 237 | + _team_id: Uuid, | |
| 238 | + _user_id: Uuid, | |
| 239 | + ) -> anyhow::Result<()> { | |
| 240 | + Ok(()) | |
| 241 | + } | |
| 242 | + | |
| 243 | + /// Generic audit hook for events that do not warrant a dedicated trait | |
| 244 | + /// method. Invoked by the framework's logging services (see | |
| 245 | + /// [`AuthLoggingService`](crate::services::auth_logging::AuthLoggingService)) | |
| 246 | + /// and available for apps to emit their own events (issue created, PR | |
| 247 | + /// merged, resource-specific operations…). | |
| 248 | + /// | |
| 249 | + /// Errors returned here are propagated to the caller — use with care; a | |
| 250 | + /// panicking or slow hook affects the request path. | |
| 251 | + async fn on_audit_event( | |
| 252 | + &self, | |
| 253 | + _db: &DatabaseConnection, | |
| 254 | + _event: AuditEvent<'_>, | |
| 255 | + ) -> anyhow::Result<()> { | |
| 256 | + Ok(()) | |
| 257 | + } | |
| 138 | 258 | } |
| 139 | 259 | |
| 140 | 260 | /// Default no-op implementation of hooks. |
@@ -142,3 +262,239 @@ pub struct NoopHooks;
| 142 | 262 | |
| 143 | 263 | #[async_trait] |
| 144 | 264 | impl RustwardenHooks for NoopHooks {} |
| 265 | + | |
| 266 | +#[cfg(test)] | |
| 267 | +mod tests { | |
| 268 | + use super::*; | |
| 269 | + use std::sync::{Arc, Mutex}; | |
| 270 | + | |
| 271 | + /// Collect every hook invocation so tests can assert dispatch. | |
| 272 | + #[derive(Default)] | |
| 273 | + struct RecordingHooks { | |
| 274 | + events: Arc<Mutex<Vec<String>>>, | |
| 275 | + } | |
| 276 | + | |
| 277 | + #[async_trait] | |
| 278 | + impl RustwardenHooks for RecordingHooks { | |
| 279 | + async fn on_resource_registered( | |
| 280 | + &self, | |
| 281 | + _db: &DatabaseConnection, | |
| 282 | + resource_type: &str, | |
| 283 | + resource_id: Uuid, | |
| 284 | + owner_id: Uuid, | |
| 285 | + ) -> anyhow::Result<()> { | |
| 286 | + self.events.lock().unwrap().push(format!( | |
| 287 | + "registered:{resource_type}:{resource_id}:{owner_id}" | |
| 288 | + )); | |
| 289 | + Ok(()) | |
| 290 | + } | |
| 291 | + | |
| 292 | + async fn on_resource_unregistered( | |
| 293 | + &self, | |
| 294 | + _db: &DatabaseConnection, | |
| 295 | + resource_type: &str, | |
| 296 | + resource_id: Uuid, | |
| 297 | + ) -> anyhow::Result<()> { | |
| 298 | + self.events | |
| 299 | + .lock() | |
| 300 | + .unwrap() | |
| 301 | + .push(format!("unregistered:{resource_type}:{resource_id}")); | |
| 302 | + Ok(()) | |
| 303 | + } | |
| 304 | + | |
| 305 | + async fn on_team_created( | |
| 306 | + &self, | |
| 307 | + _db: &DatabaseConnection, | |
| 308 | + team_id: Uuid, | |
| 309 | + owner_id: Uuid, | |
| 310 | + ) -> anyhow::Result<()> { | |
| 311 | + self.events | |
| 312 | + .lock() | |
| 313 | + .unwrap() | |
| 314 | + .push(format!("team_created:{team_id}:{owner_id}")); | |
| 315 | + Ok(()) | |
| 316 | + } | |
| 317 | + | |
| 318 | + async fn on_team_deleted( | |
| 319 | + &self, | |
| 320 | + _db: &DatabaseConnection, | |
| 321 | + team_id: Uuid, | |
| 322 | + ) -> anyhow::Result<()> { | |
| 323 | + self.events | |
| 324 | + .lock() | |
| 325 | + .unwrap() | |
| 326 | + .push(format!("team_deleted:{team_id}")); | |
| 327 | + Ok(()) | |
| 328 | + } | |
| 329 | + | |
| 330 | + async fn on_team_member_added( | |
| 331 | + &self, | |
| 332 | + _db: &DatabaseConnection, | |
| 333 | + team_id: Uuid, | |
| 334 | + user_id: Uuid, | |
| 335 | + ) -> anyhow::Result<()> { | |
| 336 | + self.events | |
| 337 | + .lock() | |
| 338 | + .unwrap() | |
| 339 | + .push(format!("member_added:{team_id}:{user_id}")); | |
| 340 | + Ok(()) | |
| 341 | + } | |
| 342 | + | |
| 343 | + async fn on_team_member_removed( | |
| 344 | + &self, | |
| 345 | + _db: &DatabaseConnection, | |
| 346 | + team_id: Uuid, | |
| 347 | + user_id: Uuid, | |
| 348 | + ) -> anyhow::Result<()> { | |
| 349 | + self.events | |
| 350 | + .lock() | |
| 351 | + .unwrap() | |
| 352 | + .push(format!("member_removed:{team_id}:{user_id}")); | |
| 353 | + Ok(()) | |
| 354 | + } | |
| 355 | + | |
| 356 | + async fn on_audit_event( | |
| 357 | + &self, | |
| 358 | + _db: &DatabaseConnection, | |
| 359 | + event: AuditEvent<'_>, | |
| 360 | + ) -> anyhow::Result<()> { | |
| 361 | + let target = event | |
| 362 | + .target | |
| 363 | + .map(|(rt, rid)| format!("{rt}:{rid}")) | |
| 364 | + .unwrap_or_else(|| "-".into()); | |
| 365 | + let actor = event | |
| 366 | + .actor_id | |
| 367 | + .map(|a| a.to_string()) | |
| 368 | + .unwrap_or_else(|| "-".into()); | |
| 369 | + self.events | |
| 370 | + .lock() | |
| 371 | + .unwrap() | |
| 372 | + .push(format!("audit:{}:{}:{}", event.kind, actor, target)); | |
| 373 | + Ok(()) | |
| 374 | + } | |
| 375 | + } | |
| 376 | + | |
| 377 | + /// Per-test connection — see personal_token_service::tests for rationale | |
| 378 | + /// (a static OnceCell across `#[tokio::test]` runtimes causes | |
| 379 | + /// "Tokio 1.x context being shutdown" errors). | |
| 380 | + async fn db() -> DatabaseConnection { | |
| 381 | + dotenvy::dotenv().ok(); | |
| 382 | + let url = std::env::var("DATABASE_URL").expect("DATABASE_URL must be set for hook tests"); | |
| 383 | + sea_orm::Database::connect(&url) | |
| 384 | + .await | |
| 385 | + .expect("hook tests require a reachable DATABASE_URL") | |
| 386 | + } | |
| 387 | + | |
| 388 | + #[tokio::test] | |
| 389 | + async fn noop_hooks_accept_every_new_event_without_panicking() { | |
| 390 | + let hooks = NoopHooks; | |
| 391 | + let id = Uuid::new_v4(); | |
| 392 | + | |
| 393 | + hooks | |
| 394 | + .on_resource_registered(&db().await, "repo", id, id) | |
| 395 | + .await | |
| 396 | + .unwrap(); | |
| 397 | + hooks | |
| 398 | + .on_resource_unregistered(&db().await, "repo", id) | |
| 399 | + .await | |
| 400 | + .unwrap(); | |
| 401 | + hooks.on_team_created(&db().await, id, id).await.unwrap(); | |
| 402 | + hooks.on_team_deleted(&db().await, id).await.unwrap(); | |
| 403 | + hooks | |
| 404 | + .on_team_member_added(&db().await, id, id) | |
| 405 | + .await | |
| 406 | + .unwrap(); | |
| 407 | + hooks | |
| 408 | + .on_team_member_removed(&db().await, id, id) | |
| 409 | + .await | |
| 410 | + .unwrap(); | |
| 411 | + hooks | |
| 412 | + .on_audit_event( | |
| 413 | + &db().await, | |
| 414 | + AuditEvent { | |
| 415 | + kind: "noop.smoke", | |
| 416 | + actor_id: None, | |
| 417 | + target: None, | |
| 418 | + metadata: &serde_json::Value::Null, | |
| 419 | + }, | |
| 420 | + ) | |
| 421 | + .await | |
| 422 | + .unwrap(); | |
| 423 | + } | |
| 424 | + | |
| 425 | + #[tokio::test] | |
| 426 | + async fn custom_hooks_receive_each_event_with_correct_payload() { | |
| 427 | + let hooks = RecordingHooks::default(); | |
| 428 | + let team = Uuid::new_v4(); | |
| 429 | + let owner = Uuid::new_v4(); | |
| 430 | + let member = Uuid::new_v4(); | |
| 431 | + let resource = Uuid::new_v4(); | |
| 432 | + | |
| 433 | + hooks | |
| 434 | + .on_resource_registered(&db().await, "repo", resource, owner) | |
| 435 | + .await | |
| 436 | + .unwrap(); | |
| 437 | + hooks | |
| 438 | + .on_resource_unregistered(&db().await, "repo", resource) | |
| 439 | + .await | |
| 440 | + .unwrap(); | |
| 441 | + hooks | |
| 442 | + .on_team_created(&db().await, team, owner) | |
| 443 | + .await | |
| 444 | + .unwrap(); | |
| 445 | + hooks | |
| 446 | + .on_team_member_added(&db().await, team, member) | |
| 447 | + .await | |
| 448 | + .unwrap(); | |
| 449 | + hooks | |
| 450 | + .on_team_member_removed(&db().await, team, member) | |
| 451 | + .await | |
| 452 | + .unwrap(); | |
| 453 | + hooks.on_team_deleted(&db().await, team).await.unwrap(); | |
| 454 | + | |
| 455 | + let meta = serde_json::json!({"subject": "s"}); | |
| 456 | + hooks | |
| 457 | + .on_audit_event( | |
| 458 | + &db().await, | |
| 459 | + AuditEvent { | |
| 460 | + kind: "pr.merged", | |
| 461 | + actor_id: Some(owner), | |
| 462 | + target: Some(("pr", resource)), | |
| 463 | + metadata: &meta, | |
| 464 | + }, | |
| 465 | + ) | |
| 466 | + .await | |
| 467 | + .unwrap(); | |
| 468 | + | |
| 469 | + let events = hooks.events.lock().unwrap().clone(); | |
| 470 | + assert_eq!( | |
| 471 | + events, | |
| 472 | + vec![ | |
| 473 | + format!("registered:repo:{resource}:{owner}"), | |
| 474 | + format!("unregistered:repo:{resource}"), | |
| 475 | + format!("team_created:{team}:{owner}"), | |
| 476 | + format!("member_added:{team}:{member}"), | |
| 477 | + format!("member_removed:{team}:{member}"), | |
| 478 | + format!("team_deleted:{team}"), | |
| 479 | + format!("audit:pr.merged:{owner}:pr:{resource}"), | |
| 480 | + ] | |
| 481 | + ); | |
| 482 | + } | |
| 483 | + | |
| 484 | + #[test] | |
| 485 | + fn audit_event_carries_structured_metadata() { | |
| 486 | + let actor = Uuid::new_v4(); | |
| 487 | + let target_id = Uuid::new_v4(); | |
| 488 | + let meta = serde_json::json!({"ip": "127.0.0.1", "reason": "manual"}); | |
| 489 | + let event = AuditEvent { | |
| 490 | + kind: "user.ban", | |
| 491 | + actor_id: Some(actor), | |
| 492 | + target: Some(("user", target_id)), | |
| 493 | + metadata: &meta, | |
| 494 | + }; | |
| 495 | + assert_eq!(event.kind, "user.ban"); | |
| 496 | + assert_eq!(event.actor_id, Some(actor)); | |
| 497 | + assert_eq!(event.target, Some(("user", target_id))); | |
| 498 | + assert_eq!(event.metadata["ip"], "127.0.0.1"); | |
| 499 | + } | |
| 500 | +} |
M
crates/rustwarden-core/src/i18n/extractor.rs
+9
-1
@@ -116,7 +116,7 @@ fn is_valid_language_code(code: &str) -> bool {
| 116 | 116 | }; |
| 117 | 117 | |
| 118 | 118 | // Check if base language is supported |
| 119 | - matches!(base_lang, "fr" | "en" | "de" | "es") | |
| 119 | + matches!(base_lang, "fr" | "en" | "de" | "es" | "it" | "pt") | |
| 120 | 120 | } |
| 121 | 121 | |
| 122 | 122 | /// Normalize locale code to base language |
@@ -216,6 +216,8 @@ mod tests {
| 216 | 216 | assert!(is_valid_language_code("en")); |
| 217 | 217 | assert!(is_valid_language_code("de")); |
| 218 | 218 | assert!(is_valid_language_code("es")); |
| 219 | + assert!(is_valid_language_code("it")); | |
| 220 | + assert!(is_valid_language_code("pt")); | |
| 219 | 221 | assert!(!is_valid_language_code("ja")); |
| 220 | 222 | assert!(!is_valid_language_code("")); |
| 221 | 223 | } |
@@ -240,6 +242,12 @@ mod tests {
| 240 | 242 | Some("es".to_string()) |
| 241 | 243 | ); |
| 242 | 244 | |
| 245 | + // Italian | |
| 246 | + assert_eq!( | |
| 247 | + parse_accept_language("it-IT,it;q=0.9"), | |
| 248 | + Some("it".to_string()) | |
| 249 | + ); | |
| 250 | + | |
| 243 | 251 | // Unsupported languages |
| 244 | 252 | assert_eq!(parse_accept_language("ja-JP,ja;q=0.9"), None); // Not supported |
| 245 | 253 | assert_eq!(parse_accept_language("zh-CN,zh;q=0.9"), None); // Not supported |
M
crates/rustwarden-core/src/i18n/helpers.rs
+30
-3
@@ -57,8 +57,8 @@ pub fn format_date(date: &chrono::DateTime<chrono::Utc>, locale: &str) -> String
| 57 | 57 | // German format: DD.MM.YYYY HH:MM |
| 58 | 58 | date.format("%d.%m.%Y %H:%M").to_string() |
| 59 | 59 | } |
| 60 | - "es" => { | |
| 61 | - // Spanish format: DD/MM/YYYY HH:MM (same as French) | |
| 60 | + "es" | "it" | "pt" => { | |
| 61 | + // Spanish/Italian/Portuguese format: DD/MM/YYYY HH:MM (same as French) | |
| 62 | 62 | date.format("%d/%m/%Y %H:%M").to_string() |
| 63 | 63 | } |
| 64 | 64 | "en" => { |
@@ -111,6 +111,8 @@ pub fn format_number(number: f64, locale: &str) -> String {
| 111 | 111 | "fr" => Locale::fr, |
| 112 | 112 | "de" => Locale::de, |
| 113 | 113 | "es" => Locale::es, |
| 114 | + "it" => Locale::it, | |
| 115 | + "pt" => Locale::pt, | |
| 114 | 116 | "en" => Locale::en, |
| 115 | 117 | _ => Locale::en, // Default to English for unknown locales |
| 116 | 118 | }; |
@@ -126,7 +128,7 @@ pub fn format_number(number: f64, locale: &str) -> String {
| 126 | 128 | |
| 127 | 129 | // Determine decimal separator based on locale |
| 128 | 130 | let decimal_separator = match locale_normalized { |
| 129 | - "fr" | "de" | "es" => ",", | |
| 131 | + "fr" | "de" | "es" | "it" | "pt" => ",", | |
| 130 | 132 | _ => ".", // English and default |
| 131 | 133 | }; |
| 132 | 134 |
@@ -278,6 +280,17 @@ mod tests {
| 278 | 280 | "English format should contain AM/PM" |
| 279 | 281 | ); |
| 280 | 282 | |
| 283 | + // Test Italian format | |
| 284 | + let it_formatted = format_date(&date, "it"); | |
| 285 | + assert!( | |
| 286 | + it_formatted.contains("31/12/2024"), | |
| 287 | + "Italian format should contain DD/MM/YYYY" | |
| 288 | + ); | |
| 289 | + assert!( | |
| 290 | + it_formatted.contains("14:30"), | |
| 291 | + "Italian format should contain 24-hour time" | |
| 292 | + ); | |
| 293 | + | |
| 281 | 294 | // Test invalid locale (should default to English) |
| 282 | 295 | let invalid_formatted = format_date(&date, "xx"); |
| 283 | 296 | assert!( |
@@ -352,6 +365,20 @@ mod tests {
| 352 | 365 | en_formatted |
| 353 | 366 | ); |
| 354 | 367 | |
| 368 | + // Test Italian format: 1.234,56 (same as German/Spanish) | |
| 369 | + let it_formatted = format_number(number, "it"); | |
| 370 | + println!("Italian formatted: '{}'", it_formatted); | |
| 371 | + assert!( | |
| 372 | + it_formatted.contains(","), | |
| 373 | + "Italian format should use comma as decimal separator, got: {}", | |
| 374 | + it_formatted | |
| 375 | + ); | |
| 376 | + assert!( | |
| 377 | + it_formatted.contains("."), | |
| 378 | + "Italian format should use dot as thousand separator, got: {}", | |
| 379 | + it_formatted | |
| 380 | + ); | |
| 381 | + | |
| 355 | 382 | // Test invalid locale (should default to English) |
| 356 | 383 | let invalid_formatted = format_number(number, "xx"); |
| 357 | 384 | println!("Invalid locale formatted: '{}'", invalid_formatted); |
M
crates/rustwarden-core/src/i18n/middleware.rs
+13
-8
@@ -32,6 +32,7 @@ pub struct LocaleMiddleware<S> {
| 32 | 32 | } |
| 33 | 33 | |
| 34 | 34 | impl<S> LocaleMiddleware<S> { |
| 35 | + /// Create a new `LocaleMiddleware` wrapping the given inner service. | |
| 35 | 36 | pub fn new(inner: S, db: Arc<DatabaseConnection>, i18n_service: Arc<I18nService>) -> Self { |
| 36 | 37 | Self { |
| 37 | 38 | inner, |
@@ -72,7 +73,10 @@ where
| 72 | 73 | // User is authenticated, try to get preference from database |
| 73 | 74 | match UserPreferencesService::get_preferences(&db, claims.sub).await { |
| 74 | 75 | Ok(Some(prefs)) |
| 75 | - if matches!(prefs.language.as_str(), "fr" | "en" | "de" | "es") => | |
| 76 | + if matches!( | |
| 77 | + prefs.language.as_str(), | |
| 78 | + "fr" | "en" | "de" | "es" | "it" | "pt" | |
| 79 | + ) => | |
| 76 | 80 | { |
| 77 | 81 | (Locale(prefs.language.clone()), false) // Don't set cookie for authenticated users |
| 78 | 82 | } |
@@ -104,11 +108,12 @@ where
| 104 | 108 | |
| 105 | 109 | // Create locale cookie (1 year expiration) |
| 106 | 110 | use time::Duration as TimeDuration; |
| 107 | - let cookie = axum_extra::extract::cookie::Cookie::build(("locale", locale.as_str())) | |
| 108 | - .http_only(false) // Allow JavaScript access for client-side language switching | |
| 109 | - .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 110 | - .path("/") | |
| 111 | - .max_age(TimeDuration::seconds(31536000)); // 1 year | |
| 111 | + let cookie = | |
| 112 | + axum_extra::extract::cookie::Cookie::build(("locale", locale.as_str())) | |
| 113 | + .http_only(false) // Allow JavaScript access for client-side language switching | |
| 114 | + .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 115 | + .path("/") | |
| 116 | + .max_age(TimeDuration::seconds(31536000)); // 1 year | |
| 112 | 117 | |
| 113 | 118 | // Add cookie to response headers |
| 114 | 119 | if let Ok(header_value) = cookie.to_string().parse() { |
@@ -140,7 +145,7 @@ fn extract_locale_from_request(
| 140 | 145 | if let Some(cookie) = axum_extra::extract::CookieJar::from_headers(&parts.headers).get("locale") |
| 141 | 146 | { |
| 142 | 147 | let locale_str = cookie.value().to_string(); |
| 143 | - if matches!(locale_str.as_str(), "fr" | "en" | "de" | "es") { | |
| 148 | + if matches!(locale_str.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 144 | 149 | return (Locale(locale_str), false); // Cookie exists, don't set it again |
| 145 | 150 | } |
| 146 | 151 | } |
@@ -164,7 +169,7 @@ fn extract_locale_from_request(
| 164 | 169 | "fr".to_string() // Default to French |
| 165 | 170 | } |
| 166 | 171 | }) |
| 167 | - .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es")) | |
| 172 | + .filter(|s| matches!(s.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt")) | |
| 168 | 173 | { |
| 169 | 174 | // Only use Accept-Language if it's not the default "fr" |
| 170 | 175 | // This ensures that users without explicit preference get French by default |
M
crates/rustwarden-core/src/lib.rs
+27
-4
@@ -3,18 +3,34 @@
| 3 | 3 | //! Reusable web framework for user management, authentication, and RBAC. |
| 4 | 4 | //! Use this crate as a dependency to add secure authentication to any Axum application. |
| 5 | 5 | |
| 6 | +/// Application setup and router builder. | |
| 6 | 7 | pub mod app; |
| 8 | +/// Authentication extractors, JWT service, and session management. | |
| 7 | 9 | pub mod auth; |
| 8 | -pub mod hooks; | |
| 10 | +/// Server, database, authentication, and email configuration. | |
| 9 | 11 | pub mod config; |
| 12 | +/// Application error types and HTTP response conversion. | |
| 10 | 13 | pub mod error; |
| 14 | +/// Extension hooks for consumer applications to react to framework events. | |
| 15 | +pub mod hooks; | |
| 16 | +/// Internationalization utilities. | |
| 11 | 17 | pub mod i18n; |
| 18 | +/// Axum middleware: authentication, CSRF, rate-limiting, error handling. | |
| 12 | 19 | pub mod middleware; |
| 20 | +/// SeaORM database migrations bundled with the core crate. | |
| 13 | 21 | pub mod migrations; |
| 22 | +/// SeaORM entity models. | |
| 14 | 23 | pub mod models; |
| 24 | +/// Server-sent events and in-memory notification bus. | |
| 25 | +pub mod notifications; | |
| 26 | +/// Business logic services (auth, users, roles, resources, teams, etc.). | |
| 15 | 27 | pub mod services; |
| 28 | +/// Background task processors. | |
| 16 | 29 | pub mod tasks; |
| 30 | +/// Askama HTML templates. | |
| 17 | 31 | pub mod templates; |
| 32 | +/// Domain-specific newtype wrappers. | |
| 33 | +pub mod types; | |
| 18 | 34 | |
| 19 | 35 | /// Prelude module re-exporting the most commonly used types. |
| 20 | 36 | pub mod prelude { |
@@ -23,8 +39,15 @@ pub mod prelude {
| 23 | 39 | pub use crate::auth::jwt::{Claims, JwtService}; |
| 24 | 40 | pub use crate::error::AppError; |
| 25 | 41 | pub use crate::hooks::RustwardenHooks; |
| 26 | - pub use crate::services::user_service::UserService; | |
| 27 | - pub use crate::services::role_service::RoleService; | |
| 42 | + pub use crate::notifications::sse::sse_response; | |
| 43 | + pub use crate::notifications::{EventFilter, InMemoryBus, NotificationBus}; | |
| 28 | 44 | pub use crate::services::permission_service::PermissionService; |
| 29 | - pub use crate::services::resource_service::ResourceService; | |
| 45 | + pub use crate::services::personal_token_service::{PersonalTokenService, TokenScope}; | |
| 46 | + pub use crate::services::rate_limit_service::RateLimitService; | |
| 47 | + pub use crate::services::resource_service::{Principal, ResourceService}; | |
| 48 | + pub use crate::services::role_service::RoleService; | |
| 49 | + pub use crate::services::team_service::{TeamRole, TeamService}; | |
| 50 | + pub use crate::services::user_service::UserService; | |
| 51 | + pub use crate::tasks::personal_token_cleanup::PersonalTokenCleanupProcessor; | |
| 52 | + pub use crate::types::TeamSlug; | |
| 30 | 53 | } |
M
crates/rustwarden-core/src/middleware/admin_helper.rs
+1
-1
@@ -7,12 +7,12 @@ use axum::extract::Request;
| 7 | 7 | use axum::http::request::Parts; |
| 8 | 8 | use sea_orm::DatabaseConnection; |
| 9 | 9 | |
| 10 | -use crate::middleware::auth_required::extract_user_from_jwt; | |
| 11 | 10 | use crate::auth::jwt::Claims; |
| 12 | 11 | use crate::auth::jwt::JwtService; |
| 13 | 12 | use crate::auth::jwt_extractor; |
| 14 | 13 | use crate::config::auth::AuthConfig; |
| 15 | 14 | use crate::error::AppError; |
| 15 | +use crate::middleware::auth_required::extract_user_from_jwt; | |
| 16 | 16 | use crate::services::jwt_blacklist_service::JwtBlacklistService; |
| 17 | 17 | |
| 18 | 18 | /// Extract admin claims from request parts and headers |
M
crates/rustwarden-core/src/middleware/admin_required.rs
+1
-12
@@ -11,8 +11,8 @@ use axum::{
| 11 | 11 | }; |
| 12 | 12 | use sea_orm::DatabaseConnection; |
| 13 | 13 | |
| 14 | -use crate::middleware::auth_required::extract_user_from_jwt; | |
| 15 | 14 | use crate::error::AppError; |
| 15 | +use crate::middleware::auth_required::extract_user_from_jwt; | |
| 16 | 16 | |
| 17 | 17 | /// Middleware that requires admin role |
| 18 | 18 | /// |
@@ -136,14 +136,3 @@ pub async fn admin_required_impl(
| 136 | 136 | // User is admin, proceed to handler |
| 137 | 137 | Ok(next.run(request).await) |
| 138 | 138 | } |
| 139 | - | |
| 140 | -#[cfg(test)] | |
| 141 | -mod tests { | |
| 142 | - use super::*; | |
| 143 | - | |
| 144 | - #[test] | |
| 145 | - fn test_admin_required_middleware_compiles() { | |
| 146 | - // This test verifies the middleware function compiles | |
| 147 | - // Integration tests in tests/ directory will test actual behavior | |
| 148 | - } | |
| 149 | -} |
M
crates/rustwarden-core/src/middleware/admin_service.rs
+1
-0
@@ -24,6 +24,7 @@ pub struct AdminService<S> {
| 24 | 24 | } |
| 25 | 25 | |
| 26 | 26 | impl<S> AdminService<S> { |
| 27 | + /// Create a new `AdminService` middleware wrapping the given inner service. | |
| 27 | 28 | pub fn new(inner: S, db: DatabaseConnection) -> Self { |
| 28 | 29 | Self { inner, db } |
| 29 | 30 | } |
M
crates/rustwarden-core/src/middleware/auth_required.rs
+0
-17
@@ -223,20 +223,3 @@ pub async fn optional_auth(
| 223 | 223 | // Always proceed to handler |
| 224 | 224 | next.run(request).await |
| 225 | 225 | } |
| 226 | - | |
| 227 | -#[cfg(test)] | |
| 228 | -mod tests { | |
| 229 | - use super::*; | |
| 230 | - | |
| 231 | - #[test] | |
| 232 | - fn test_extract_user_helper_compiles() { | |
| 233 | - // This test verifies the helper function compiles | |
| 234 | - // Runtime tests would require a test session setup | |
| 235 | - } | |
| 236 | - | |
| 237 | - #[test] | |
| 238 | - fn test_middleware_functions_compile() { | |
| 239 | - // This test verifies middleware functions compile | |
| 240 | - // Integration tests in tests/ directory will test actual behavior | |
| 241 | - } | |
| 242 | -} |
M
crates/rustwarden-core/src/middleware/auth_service.rs
+42
-11
@@ -26,6 +26,7 @@ pub(crate) fn sanitize_redirect(uri: &str) -> String {
| 26 | 26 | } |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | +/// Tower middleware that injects the authenticated user into request extensions. | |
| 29 | 30 | #[derive(Clone)] |
| 30 | 31 | pub struct AuthService<S> { |
| 31 | 32 | inner: S, |
@@ -33,6 +34,7 @@ pub struct AuthService<S> {
| 33 | 34 | } |
| 34 | 35 | |
| 35 | 36 | impl<S> AuthService<S> { |
| 37 | + /// Create a new `AuthService` middleware wrapping the given inner service. | |
| 36 | 38 | pub fn new(inner: S, db: DatabaseConnection) -> Self { |
| 37 | 39 | Self { inner, db } |
| 38 | 40 | } |
@@ -56,11 +58,7 @@ where
| 56 | 58 | let uri = req.uri().clone(); |
| 57 | 59 | let path = uri.path().to_owned(); |
| 58 | 60 | |
| 59 | - let protected_routes = [ | |
| 60 | - "/home", | |
| 61 | - "/profile", | |
| 62 | - "/test-base", | |
| 63 | - ]; | |
| 61 | + let protected_routes = ["/home", "/profile", "/test-base", "/settings", "/admin"]; | |
| 64 | 62 | let is_protected = protected_routes.iter().any(|route| path.starts_with(route)); |
| 65 | 63 | |
| 66 | 64 | if is_protected { |
@@ -155,10 +153,44 @@ where
| 155 | 153 | user_id, |
| 156 | 154 | path |
| 157 | 155 | ); |
| 158 | - return Ok( | |
| 159 | - axum::response::Redirect::to("/settings/password?must_change=true") | |
| 160 | - .into_response(), | |
| 161 | - ); | |
| 156 | + return Ok(axum::response::Redirect::to( | |
| 157 | + "/settings/password?must_change=true", | |
| 158 | + ) | |
| 159 | + .into_response()); | |
| 160 | + } | |
| 161 | + | |
| 162 | + // Check if 2FA is forced globally and user hasn't set it up | |
| 163 | + if !path.starts_with("/settings/security") | |
| 164 | + && !path.starts_with("/api/v1/auth") | |
| 165 | + { | |
| 166 | + let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( | |
| 167 | + &db, | |
| 168 | + &["totp_force_enabled"], | |
| 169 | + false, | |
| 170 | + ) | |
| 171 | + .await | |
| 172 | + .unwrap_or(false); | |
| 173 | + | |
| 174 | + if totp_force_enabled { | |
| 175 | + let totp_enabled = | |
| 176 | + crate::services::totp_service::TotpService::is_totp_enabled( | |
| 177 | + &db, user_id, | |
| 178 | + ) | |
| 179 | + .await | |
| 180 | + .unwrap_or(false); | |
| 181 | + | |
| 182 | + if !totp_enabled { | |
| 183 | + tracing::info!( | |
| 184 | + "User {} must setup 2FA, redirecting from {}", | |
| 185 | + user_id, | |
| 186 | + path | |
| 187 | + ); | |
| 188 | + return Ok(axum::response::Redirect::to( | |
| 189 | + "/settings/security?force_2fa=true", | |
| 190 | + ) | |
| 191 | + .into_response()); | |
| 192 | + } | |
| 193 | + } | |
| 162 | 194 | } |
| 163 | 195 | } |
| 164 | 196 | } |
@@ -200,8 +232,7 @@ where
| 200 | 232 | |
| 201 | 233 | if !is_blacklisted { |
| 202 | 234 | if let Ok(auth_config) = AuthConfig::from_env() { |
| 203 | - if let Ok(claims) = | |
| 204 | - JwtService::validate_token(&auth_config, &jwt_token) | |
| 235 | + if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) | |
| 205 | 236 | { |
| 206 | 237 | parts.extensions.insert(claims.clone()); |
| 207 | 238 | if let Ok(json) = serde_json::to_string(&claims) { |
M
crates/rustwarden-core/src/middleware/csrf.rs
+452
-81
@@ -1,22 +1,68 @@
| 1 | -/// CSRF Protection Middleware | |
| 2 | -/// | |
| 3 | -/// Middleware to protect against Cross-Site Request Forgery (CSRF) attacks. | |
| 4 | -/// Uses session-based CSRF tokens for state-changing operations (POST/PUT/DELETE). | |
| 5 | -/// SEC-H4: Uses constant-time comparison to prevent timing attacks. | |
| 6 | -/// SEC-C3: Blocks requests without CSRF token (except API Bearer routes). | |
| 1 | +//! CSRF Protection Middleware | |
| 2 | +//! | |
| 3 | +//! Protects against Cross-Site Request Forgery (CSRF) attacks via | |
| 4 | +//! session-bound tokens for state-changing operations. | |
| 5 | +//! | |
| 6 | +//! Two entry points: | |
| 7 | +//! - [`csrf_protection`] — default behavior. Accepts the token only in the | |
| 8 | +//! `X-CSRF-Token` header. Suited to JS clients / HTMX. | |
| 9 | +//! - [`csrf_protection_with_config`] — explicit [`CsrfProtectionConfig`]. | |
| 10 | +//! When `accept_form_body = true`, also looks for `csrf_token=...` in a | |
| 11 | +//! `application/x-www-form-urlencoded` body and buffers it up to | |
| 12 | +//! `max_form_body_bytes`. Suited to SSR apps without JS (e.g. Gitrust). | |
| 13 | +//! | |
| 14 | +//! Security properties (inherited): | |
| 15 | +//! - SEC-H4: constant-time token comparison. | |
| 16 | +//! - SEC-C3: blocks state-changing requests without a token, except those | |
| 17 | +//! carrying an `Authorization: Bearer ...` header (API clients, not | |
| 18 | +//! browser-driven, so not CSRF-vulnerable). | |
| 19 | + | |
| 7 | 20 | use axum::{ |
| 21 | + body::Body, | |
| 8 | 22 | extract::{Request, State}, |
| 9 | - http::{Method, StatusCode, Uri}, | |
| 23 | + http::{header::CONTENT_TYPE, Method, StatusCode, Uri}, | |
| 10 | 24 | middleware::Next, |
| 11 | 25 | response::{IntoResponse, Response}, |
| 12 | 26 | }; |
| 13 | 27 | use rand::RngCore; |
| 14 | 28 | use sea_orm::DatabaseConnection; |
| 29 | +use std::sync::Arc; | |
| 15 | 30 | use subtle::ConstantTimeEq; |
| 16 | 31 | use tower_sessions::Session; |
| 17 | 32 | |
| 18 | 33 | use crate::error::AppError; |
| 19 | 34 | |
| 35 | +/// Default buffer cap for form bodies that may carry a CSRF token: 1 MiB. | |
| 36 | +pub const DEFAULT_MAX_FORM_BODY_BYTES: usize = 1024 * 1024; | |
| 37 | + | |
| 38 | +/// Runtime configuration for [`csrf_protection_with_config`]. | |
| 39 | +/// | |
| 40 | +/// The defaults match the behavior of [`csrf_protection`] (header-only, no | |
| 41 | +/// body parsing), so an app that opts in to the configurable wrapper without | |
| 42 | +/// changing any field sees no behavior change. | |
| 43 | +#[derive(Debug, Clone)] | |
| 44 | +pub struct CsrfProtectionConfig { | |
| 45 | + /// When true, if the `X-CSRF-Token` header is absent and the request | |
| 46 | + /// declares `Content-Type: application/x-www-form-urlencoded`, the body | |
| 47 | + /// is buffered and scanned for a `csrf_token` field. | |
| 48 | + /// | |
| 49 | + /// Multipart bodies are never buffered (too risky on large uploads). | |
| 50 | + pub accept_form_body: bool, | |
| 51 | + | |
| 52 | + /// Maximum number of bytes buffered when `accept_form_body` is true. | |
| 53 | + /// Requests that exceed this cap are rejected with HTTP 413. | |
| 54 | + pub max_form_body_bytes: usize, | |
| 55 | +} | |
| 56 | + | |
| 57 | +impl Default for CsrfProtectionConfig { | |
| 58 | + fn default() -> Self { | |
| 59 | + Self { | |
| 60 | + accept_form_body: false, | |
| 61 | + max_form_body_bytes: DEFAULT_MAX_FORM_BODY_BYTES, | |
| 62 | + } | |
| 63 | + } | |
| 64 | +} | |
| 65 | + | |
| 20 | 66 | /// Session key for CSRF token |
| 21 | 67 | const CSRF_TOKEN_KEY: &str = "csrf_token"; |
| 22 | 68 |
@@ -59,28 +105,48 @@ pub async fn get_or_create_csrf_token(session: &Session) -> Result<String, AppEr
| 59 | 105 | Ok(token) |
| 60 | 106 | } |
| 61 | 107 | |
| 62 | -/// Extract CSRF token from request | |
| 63 | -/// | |
| 64 | -/// Checks both header (X-CSRF-Token) and form field (csrf_token). | |
| 108 | +/// Extract the CSRF token from the `X-CSRF-Token` header only. | |
| 65 | 109 | /// |
| 66 | -/// # Arguments | |
| 67 | -/// | |
| 68 | -/// * `request` - HTTP request | |
| 110 | +/// Body-based extraction is handled separately by [`extract_csrf_from_form`] | |
| 111 | +/// when the middleware is configured with `accept_form_body = true`. | |
| 112 | +fn extract_header_token(request: &Request) -> Option<String> { | |
| 113 | + request | |
| 114 | + .headers() | |
| 115 | + .get("X-CSRF-Token") | |
| 116 | + .and_then(|v| v.to_str().ok()) | |
| 117 | + .map(|s| s.to_string()) | |
| 118 | +} | |
| 119 | + | |
| 120 | +/// True iff the request declares `Content-Type: application/x-www-form-urlencoded`. | |
| 121 | +fn is_form_urlencoded(request: &Request) -> bool { | |
| 122 | + request | |
| 123 | + .headers() | |
| 124 | + .get(CONTENT_TYPE) | |
| 125 | + .and_then(|v| v.to_str().ok()) | |
| 126 | + .map(|v| { | |
| 127 | + v.to_ascii_lowercase() | |
| 128 | + .starts_with("application/x-www-form-urlencoded") | |
| 129 | + }) | |
| 130 | + .unwrap_or(false) | |
| 131 | +} | |
| 132 | + | |
| 133 | +/// Extract the `csrf_token` field from a URL-encoded form body. | |
| 69 | 134 | /// |
| 70 | -/// # Returns | |
| 135 | +/// Returns `None` when: | |
| 136 | +/// - the body is not valid UTF-8, | |
| 137 | +/// - no `csrf_token` field is present, | |
| 138 | +/// - the URL-decoded value is invalid. | |
| 71 | 139 | /// |
| 72 | -/// CSRF token if found, None otherwise | |
| 73 | -fn extract_csrf_token_from_request(request: &Request) -> Option<String> { | |
| 74 | - // Check header first | |
| 75 | - if let Some(header_value) = request.headers().get("X-CSRF-Token") { | |
| 76 | - if let Ok(token) = header_value.to_str() { | |
| 77 | - return Some(token.to_string()); | |
| 78 | - } | |
| 79 | - } | |
| 80 | - | |
| 81 | - // For form data, we would need to parse the body | |
| 82 | - // This is handled in handlers that process form data | |
| 83 | - None | |
| 140 | +/// Exposed as `pub` so apps that run their own buffering can still reuse the | |
| 141 | +/// framework's parser (matches proposition B in | |
| 142 | +/// [`docs/UPSTREAM_RUSTWARDEN.md`](../../../../docs/UPSTREAM_RUSTWARDEN.md)). | |
| 143 | +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { | |
| 144 | + let text = std::str::from_utf8(body).ok()?; | |
| 145 | + text.split('&') | |
| 146 | + .filter_map(|pair| pair.split_once('=')) | |
| 147 | + .find(|(k, _)| *k == "csrf_token") | |
| 148 | + .and_then(|(_, v)| urlencoding::decode(v).ok()) | |
| 149 | + .map(|cow| cow.into_owned()) | |
| 84 | 150 | } |
| 85 | 151 | |
| 86 | 152 | /// Middleware that validates CSRF tokens for state-changing operations |
@@ -120,92 +186,178 @@ fn csrf_tokens_match(a: &str, b: &str) -> bool {
| 120 | 186 | a.as_bytes().ct_eq(b.as_bytes()).into() |
| 121 | 187 | } |
| 122 | 188 | |
| 123 | -pub async fn csrf_protection( | |
| 124 | - State(_db): State<DatabaseConnection>, | |
| 125 | - session: Session, | |
| 126 | - uri: Uri, | |
| 189 | +/// Outcome of running the CSRF check against an incoming request. | |
| 190 | +/// | |
| 191 | +/// `Pass` carries the (possibly reconstructed) request forward so body-buffered | |
| 192 | +/// flows can replay the consumed bytes to downstream extractors. | |
| 193 | +#[derive(Debug)] | |
| 194 | +enum CsrfOutcome { | |
| 195 | + /// Request is safe, Bearer-authed, or token validated — forward to `next`. | |
| 196 | + Pass(Request), | |
| 197 | + /// Token missing, mismatched, or body cap exceeded — short-circuit. | |
| 198 | + Reject(Response), | |
| 199 | +} | |
| 200 | + | |
| 201 | +/// Core CSRF check with configurable body buffering. | |
| 202 | +/// | |
| 203 | +/// Flow: | |
| 204 | +/// 1. Safe method (`GET`/`HEAD`/`OPTIONS`) → pass. | |
| 205 | +/// 2. `Authorization: Bearer ...` → pass (API client, not CSRF-vulnerable). | |
| 206 | +/// 3. `X-CSRF-Token` header present → validate and decide. | |
| 207 | +/// 4. Else if `cfg.accept_form_body` and `Content-Type: application/x-www-form-urlencoded` → | |
| 208 | +/// buffer (capped at `cfg.max_form_body_bytes`), scan for `csrf_token`, validate. | |
| 209 | +/// The buffered body is reattached to the request so the downstream handler | |
| 210 | +/// can still decode its `Form<T>`. | |
| 211 | +/// 5. Else → reject (403 CSRF token required). | |
| 212 | +async fn csrf_check( | |
| 213 | + session: &Session, | |
| 214 | + uri: &Uri, | |
| 127 | 215 | request: Request, |
| 128 | - next: Next, | |
| 129 | -) -> Result<Response, AppError> { | |
| 216 | + cfg: &CsrfProtectionConfig, | |
| 217 | +) -> Result<CsrfOutcome, AppError> { | |
| 130 | 218 | let method = request.method().clone(); |
| 131 | 219 | |
| 132 | - // SEC-H4: TRACE removed — only safe idempotent methods are exempt | |
| 220 | + // SEC-H4: TRACE removed — only safe idempotent methods are exempt. | |
| 133 | 221 | if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) { |
| 134 | - return Ok(next.run(request).await); | |
| 222 | + return Ok(CsrfOutcome::Pass(request)); | |
| 135 | 223 | } |
| 136 | 224 | |
| 137 | - // SEC-C3: Skip CSRF only for requests carrying a valid Bearer JWT. | |
| 138 | - // The mere presence of the header is not enough — we must validate the token. | |
| 225 | + // SEC-C3: Skip CSRF for Bearer API clients. | |
| 139 | 226 | if is_bearer_api_request(&request) { |
| 140 | 227 | tracing::debug!("CSRF skip: Bearer API request for {} {}", method, uri); |
| 141 | - return Ok(next.run(request).await); | |
| 228 | + return Ok(CsrfOutcome::Pass(request)); | |
| 142 | 229 | } |
| 143 | 230 | |
| 144 | 231 | tracing::debug!("CSRF protection: validating token for {} {}", method, uri); |
| 232 | + let session_token = get_or_create_csrf_token(session).await?; | |
| 145 | 233 | |
| 146 | - let session_token = get_or_create_csrf_token(&session).await?; | |
| 147 | - let request_token = extract_csrf_token_from_request(&request); | |
| 234 | + // Prefer the header: cheap and non-destructive. | |
| 235 | + if let Some(token) = extract_header_token(&request) { | |
| 236 | + return Ok(validate_or_reject( | |
| 237 | + &session_token, | |
| 238 | + &token, | |
| 239 | + &method, | |
| 240 | + uri, | |
| 241 | + request, | |
| 242 | + )); | |
| 243 | + } | |
| 148 | 244 | |
| 149 | - match request_token { | |
| 150 | - Some(token) => { | |
| 151 | - if !csrf_tokens_match(&token, &session_token) { | |
| 245 | + // Fall back to the form body when explicitly enabled. | |
| 246 | + if cfg.accept_form_body && is_form_urlencoded(&request) { | |
| 247 | + let (parts, body) = request.into_parts(); | |
| 248 | + let bytes = match axum::body::to_bytes(body, cfg.max_form_body_bytes).await { | |
| 249 | + Ok(b) => b, | |
| 250 | + Err(err) => { | |
| 152 | 251 | tracing::warn!( |
| 153 | - "CSRF token mismatch for {} {} (expected: {}..., got: {}...)", | |
| 252 | + cap = cfg.max_form_body_bytes, | |
| 253 | + error = %err, | |
| 254 | + "CSRF body buffering failed (cap exceeded or stream error) for {} {}", | |
| 154 | 255 | method, |
| 155 | 256 | uri, |
| 156 | - &session_token[..8.min(session_token.len())], | |
| 157 | - &token[..8.min(token.len())] | |
| 158 | 257 | ); |
| 159 | - return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()); | |
| 258 | + return Ok(CsrfOutcome::Reject( | |
| 259 | + ( | |
| 260 | + StatusCode::PAYLOAD_TOO_LARGE, | |
| 261 | + "Form body too large for CSRF check", | |
| 262 | + ) | |
| 263 | + .into_response(), | |
| 264 | + )); | |
| 160 | 265 | } |
| 266 | + }; | |
| 267 | + let body_token = extract_csrf_from_form(&bytes); | |
| 268 | + // Reconstruct the request *before* deciding, so that a valid token | |
| 269 | + // still has a body available for the downstream handler. | |
| 270 | + let reconstructed = Request::from_parts(parts, Body::from(bytes)); | |
| 271 | + return Ok(match body_token { | |
| 272 | + Some(t) => validate_or_reject(&session_token, &t, &method, uri, reconstructed), | |
| 273 | + None => { | |
| 274 | + tracing::warn!("CSRF token missing from form body for {} {}", method, uri); | |
| 275 | + CsrfOutcome::Reject((StatusCode::FORBIDDEN, "CSRF token required").into_response()) | |
| 276 | + } | |
| 277 | + }); | |
| 278 | + } | |
| 161 | 279 | |
| 162 | - tracing::debug!("CSRF token validated for {} {}", method, uri); | |
| 163 | - Ok(next.run(request).await) | |
| 164 | - } | |
| 165 | - None => { | |
| 166 | - // SEC-C3: No CSRF token at all → block unconditionally. | |
| 167 | - // The previous /api/ exception has been removed. | |
| 168 | - tracing::warn!("CSRF token missing for {} {}", method, uri); | |
| 169 | - Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response()) | |
| 170 | - } | |
| 280 | + // SEC-C3: No token at all → block. | |
| 281 | + tracing::warn!("CSRF token missing for {} {}", method, uri); | |
| 282 | + Ok(CsrfOutcome::Reject( | |
| 283 | + (StatusCode::FORBIDDEN, "CSRF token required").into_response(), | |
| 284 | + )) | |
| 285 | +} | |
| 286 | + | |
| 287 | +fn validate_or_reject( | |
| 288 | + session_token: &str, | |
| 289 | + incoming: &str, | |
| 290 | + method: &Method, | |
| 291 | + uri: &Uri, | |
| 292 | + request: Request, | |
| 293 | +) -> CsrfOutcome { | |
| 294 | + if csrf_tokens_match(incoming, session_token) { | |
| 295 | + tracing::debug!("CSRF token validated for {} {}", method, uri); | |
| 296 | + CsrfOutcome::Pass(request) | |
| 297 | + } else { | |
| 298 | + tracing::warn!( | |
| 299 | + "CSRF token mismatch for {} {} (expected: {}..., got: {}...)", | |
| 300 | + method, | |
| 301 | + uri, | |
| 302 | + &session_token[..8.min(session_token.len())], | |
| 303 | + &incoming[..8.min(incoming.len())] | |
| 304 | + ); | |
| 305 | + CsrfOutcome::Reject((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()) | |
| 171 | 306 | } |
| 172 | 307 | } |
| 173 | 308 | |
| 174 | -/// Internal implementation that takes db and session as parameters | |
| 175 | -pub async fn csrf_protection_impl( | |
| 176 | - _db: &DatabaseConnection, | |
| 309 | +/// Default header-only CSRF middleware. | |
| 310 | +/// | |
| 311 | +/// Wire as `axum::middleware::from_fn_with_state(db, csrf_protection)` on a | |
| 312 | +/// router whose routes already reject without a token. For SSR apps that only | |
| 313 | +/// have the token in a form body, use [`csrf_protection_with_config`] instead. | |
| 314 | +pub async fn csrf_protection( | |
| 315 | + State(_db): State<DatabaseConnection>, | |
| 177 | 316 | session: Session, |
| 178 | 317 | uri: Uri, |
| 179 | 318 | request: Request, |
| 180 | 319 | next: Next, |
| 181 | 320 | ) -> Result<Response, AppError> { |
| 182 | - let method = request.method().clone(); | |
| 183 | - | |
| 184 | - if matches!(method, Method::GET | Method::HEAD | Method::OPTIONS) { | |
| 185 | - return Ok(next.run(request).await); | |
| 321 | + let cfg = CsrfProtectionConfig::default(); | |
| 322 | + match csrf_check(&session, &uri, request, &cfg).await? { | |
| 323 | + CsrfOutcome::Pass(req) => Ok(next.run(req).await), | |
| 324 | + CsrfOutcome::Reject(resp) => Ok(resp), | |
| 186 | 325 | } |
| 326 | +} | |
| 187 | 327 | |
| 188 | - if is_bearer_api_request(&request) { | |
| 189 | - return Ok(next.run(request).await); | |
| 328 | +/// CSRF middleware with explicit [`CsrfProtectionConfig`]. | |
| 329 | +/// | |
| 330 | +/// Wire with `axum::middleware::from_fn_with_state((db, Arc::new(cfg)), | |
| 331 | +/// csrf_protection_with_config)` when you need `accept_form_body = true` for | |
| 332 | +/// SSR HTML forms (no JS, token in a hidden input). | |
| 333 | +pub async fn csrf_protection_with_config( | |
| 334 | + State((_db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>, | |
| 335 | + session: Session, | |
| 336 | + uri: Uri, | |
| 337 | + request: Request, | |
| 338 | + next: Next, | |
| 339 | +) -> Result<Response, AppError> { | |
| 340 | + match csrf_check(&session, &uri, request, cfg.as_ref()).await? { | |
| 341 | + CsrfOutcome::Pass(req) => Ok(next.run(req).await), | |
| 342 | + CsrfOutcome::Reject(resp) => Ok(resp), | |
| 190 | 343 | } |
| 344 | +} | |
| 191 | 345 | |
| 192 | - let session_token = get_or_create_csrf_token(&session).await?; | |
| 193 | - let request_token = extract_csrf_token_from_request(&request); | |
| 194 | - | |
| 195 | - match request_token { | |
| 196 | - Some(token) => { | |
| 197 | - if !csrf_tokens_match(&token, &session_token) { | |
| 198 | - tracing::warn!("CSRF token mismatch for {} {}", method, uri); | |
| 199 | - return Ok((StatusCode::FORBIDDEN, "Invalid CSRF token").into_response()); | |
| 200 | - } | |
| 201 | - | |
| 202 | - tracing::debug!("CSRF token validated for {} {}", method, uri); | |
| 203 | - Ok(next.run(request).await) | |
| 204 | - } | |
| 205 | - None => { | |
| 206 | - tracing::warn!("CSRF token missing for {} {}", method, uri); | |
| 207 | - Ok((StatusCode::FORBIDDEN, "CSRF token required").into_response()) | |
| 208 | - } | |
| 346 | +/// Internal implementation that takes db by reference instead of via `State`. | |
| 347 | +/// | |
| 348 | +/// Behaviorally identical to [`csrf_protection`]; kept as a separate entry | |
| 349 | +/// point for call sites that already have a `&DatabaseConnection` in scope. | |
| 350 | +pub async fn csrf_protection_impl( | |
| 351 | + _db: &DatabaseConnection, | |
| 352 | + session: Session, | |
| 353 | + uri: Uri, | |
| 354 | + request: Request, | |
| 355 | + next: Next, | |
| 356 | +) -> Result<Response, AppError> { | |
| 357 | + let cfg = CsrfProtectionConfig::default(); | |
| 358 | + match csrf_check(&session, &uri, request, &cfg).await? { | |
| 359 | + CsrfOutcome::Pass(req) => Ok(next.run(req).await), | |
| 360 | + CsrfOutcome::Reject(resp) => Ok(resp), | |
| 209 | 361 | } |
| 210 | 362 | } |
| 211 | 363 |
@@ -238,6 +390,43 @@ pub async fn validate_csrf_token(session: &Session, form_token: &str) -> Result<
| 238 | 390 | #[cfg(test)] |
| 239 | 391 | mod tests { |
| 240 | 392 | use super::*; |
| 393 | + use axum::body::Body; | |
| 394 | + use axum::http::Request as HttpRequest; | |
| 395 | + use std::sync::Arc; | |
| 396 | + use tower_sessions::MemoryStore; | |
| 397 | + | |
| 398 | + // ---------- Form body extraction (pure function) ---------- | |
| 399 | + | |
| 400 | + #[test] | |
| 401 | + fn extract_csrf_from_form_returns_token_value() { | |
| 402 | + let body = b"csrf_token=deadbeef"; | |
| 403 | + assert_eq!(extract_csrf_from_form(body), Some("deadbeef".to_string())); | |
| 404 | + } | |
| 405 | + | |
| 406 | + #[test] | |
| 407 | + fn extract_csrf_from_form_handles_multiple_fields() { | |
| 408 | + let body = b"foo=bar&csrf_token=abc123&baz=qux"; | |
| 409 | + assert_eq!(extract_csrf_from_form(body), Some("abc123".to_string())); | |
| 410 | + } | |
| 411 | + | |
| 412 | + #[test] | |
| 413 | + fn extract_csrf_from_form_url_decodes_token() { | |
| 414 | + // 61 = '=' so %3D is a decoded '=' | |
| 415 | + let body = b"csrf_token=abc%3D%3D"; | |
| 416 | + assert_eq!(extract_csrf_from_form(body), Some("abc==".to_string())); | |
| 417 | + } | |
| 418 | + | |
| 419 | + #[test] | |
| 420 | + fn extract_csrf_from_form_returns_none_when_absent() { | |
| 421 | + let body = b"foo=bar&baz=qux"; | |
| 422 | + assert_eq!(extract_csrf_from_form(body), None); | |
| 423 | + } | |
| 424 | + | |
| 425 | + #[test] | |
| 426 | + fn extract_csrf_from_form_returns_none_on_invalid_utf8() { | |
| 427 | + let body = &[0xff, 0xfe, 0xfd]; | |
| 428 | + assert_eq!(extract_csrf_from_form(body), None); | |
| 429 | + } | |
| 241 | 430 | |
| 242 | 431 | #[test] |
| 243 | 432 | fn test_generate_csrf_token() { |
@@ -247,4 +436,186 @@ mod tests {
| 247 | 436 | assert_eq!(token1.len(), 64); // 32 bytes = 64 hex chars |
| 248 | 437 | assert_ne!(token1, token2); // Tokens should be different |
| 249 | 438 | } |
| 439 | + | |
| 440 | + fn test_session() -> Session { | |
| 441 | + Session::new(None, Arc::new(MemoryStore::default()), None) | |
| 442 | + } | |
| 443 | + | |
| 444 | + fn build_request(method: Method, token_header: Option<&str>, bearer: bool) -> Request { | |
| 445 | + let mut builder = HttpRequest::builder().method(method).uri("/resource"); | |
| 446 | + if let Some(t) = token_header { | |
| 447 | + builder = builder.header("X-CSRF-Token", t); | |
| 448 | + } | |
| 449 | + if bearer { | |
| 450 | + builder = builder.header(axum::http::header::AUTHORIZATION, "Bearer some.jwt.token"); | |
| 451 | + } | |
| 452 | + builder.body(Body::empty()).unwrap() | |
| 453 | + } | |
| 454 | + | |
| 455 | + fn build_form_request(body: &str, content_type: Option<&str>) -> Request { | |
| 456 | + let ct = content_type.unwrap_or("application/x-www-form-urlencoded"); | |
| 457 | + HttpRequest::builder() | |
| 458 | + .method(Method::POST) | |
| 459 | + .uri("/resource") | |
| 460 | + .header(CONTENT_TYPE, ct) | |
| 461 | + .body(Body::from(body.to_owned())) | |
| 462 | + .unwrap() | |
| 463 | + } | |
| 464 | + | |
| 465 | + fn uri() -> Uri { | |
| 466 | + "/resource".parse().unwrap() | |
| 467 | + } | |
| 468 | + | |
| 469 | + fn assert_rejected(outcome: CsrfOutcome, expected: StatusCode) { | |
| 470 | + match outcome { | |
| 471 | + CsrfOutcome::Reject(resp) => assert_eq!(resp.status(), expected), | |
| 472 | + CsrfOutcome::Pass(_) => panic!("expected Reject({expected}), got Pass"), | |
| 473 | + } | |
| 474 | + } | |
| 475 | + | |
| 476 | + fn default_cfg() -> CsrfProtectionConfig { | |
| 477 | + CsrfProtectionConfig::default() | |
| 478 | + } | |
| 479 | + | |
| 480 | + fn form_body_cfg() -> CsrfProtectionConfig { | |
| 481 | + CsrfProtectionConfig { | |
| 482 | + accept_form_body: true, | |
| 483 | + max_form_body_bytes: 4096, | |
| 484 | + } | |
| 485 | + } | |
| 486 | + | |
| 487 | + #[tokio::test] | |
| 488 | + async fn check_passes_safe_method() { | |
| 489 | + let session = test_session(); | |
| 490 | + let req = build_request(Method::GET, None, false); | |
| 491 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 492 | + .await | |
| 493 | + .unwrap(); | |
| 494 | + assert!(matches!(outcome, CsrfOutcome::Pass(_))); | |
| 495 | + } | |
| 496 | + | |
| 497 | + #[tokio::test] | |
| 498 | + async fn check_passes_bearer_auth() { | |
| 499 | + let session = test_session(); | |
| 500 | + let req = build_request(Method::POST, None, true); | |
| 501 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 502 | + .await | |
| 503 | + .unwrap(); | |
| 504 | + assert!(matches!(outcome, CsrfOutcome::Pass(_))); | |
| 505 | + } | |
| 506 | + | |
| 507 | + #[tokio::test] | |
| 508 | + async fn check_accepts_matching_header_token() { | |
| 509 | + let session = test_session(); | |
| 510 | + let token = get_or_create_csrf_token(&session).await.unwrap(); | |
| 511 | + let req = build_request(Method::POST, Some(&token), false); | |
| 512 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 513 | + .await | |
| 514 | + .unwrap(); | |
| 515 | + assert!(matches!(outcome, CsrfOutcome::Pass(_))); | |
| 516 | + } | |
| 517 | + | |
| 518 | + #[tokio::test] | |
| 519 | + async fn check_rejects_mismatched_header_token() { | |
| 520 | + let session = test_session(); | |
| 521 | + let _ = get_or_create_csrf_token(&session).await.unwrap(); | |
| 522 | + let req = build_request(Method::POST, Some("bogus"), false); | |
| 523 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 524 | + .await | |
| 525 | + .unwrap(); | |
| 526 | + assert_rejected(outcome, StatusCode::FORBIDDEN); | |
| 527 | + } | |
| 528 | + | |
| 529 | + #[tokio::test] | |
| 530 | + async fn check_rejects_missing_token() { | |
| 531 | + let session = test_session(); | |
| 532 | + let req = build_request(Method::POST, None, false); | |
| 533 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 534 | + .await | |
| 535 | + .unwrap(); | |
| 536 | + assert_rejected(outcome, StatusCode::FORBIDDEN); | |
| 537 | + } | |
| 538 | + | |
| 539 | + // ---------- Form-body (accept_form_body = true) ---------- | |
| 540 | + | |
| 541 | + #[tokio::test] | |
| 542 | + async fn check_accepts_matching_form_body_token_and_preserves_body() { | |
| 543 | + let session = test_session(); | |
| 544 | + let token = get_or_create_csrf_token(&session).await.unwrap(); | |
| 545 | + let form = format!("csrf_token={token}&other=value"); | |
| 546 | + let req = build_form_request(&form, None); | |
| 547 | + | |
| 548 | + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg()) | |
| 549 | + .await | |
| 550 | + .unwrap(); | |
| 551 | + | |
| 552 | + let forwarded = match outcome { | |
| 553 | + CsrfOutcome::Pass(r) => r, | |
| 554 | + CsrfOutcome::Reject(_) => panic!("expected Pass for valid form token"), | |
| 555 | + }; | |
| 556 | + // Body must survive the buffering so downstream handlers can Form-decode it. | |
| 557 | + let (_, body) = forwarded.into_parts(); | |
| 558 | + let bytes = axum::body::to_bytes(body, 4096).await.unwrap(); | |
| 559 | + assert_eq!(std::str::from_utf8(&bytes).unwrap(), form); | |
| 560 | + } | |
| 561 | + | |
| 562 | + #[tokio::test] | |
| 563 | + async fn check_rejects_form_body_when_accept_form_body_is_false() { | |
| 564 | + let session = test_session(); | |
| 565 | + let token = get_or_create_csrf_token(&session).await.unwrap(); | |
| 566 | + let form = format!("csrf_token={token}"); | |
| 567 | + let req = build_form_request(&form, None); | |
| 568 | + | |
| 569 | + // Default config does NOT buffer the body → token unreachable → 403. | |
| 570 | + let outcome = csrf_check(&session, &uri(), req, &default_cfg()) | |
| 571 | + .await | |
| 572 | + .unwrap(); | |
| 573 | + assert_rejected(outcome, StatusCode::FORBIDDEN); | |
| 574 | + } | |
| 575 | + | |
| 576 | + #[tokio::test] | |
| 577 | + async fn check_rejects_form_body_with_mismatched_token() { | |
| 578 | + let session = test_session(); | |
| 579 | + let _ = get_or_create_csrf_token(&session).await.unwrap(); | |
| 580 | + let req = build_form_request("csrf_token=wrong-token", None); | |
| 581 | + | |
| 582 | + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg()) | |
| 583 | + .await | |
| 584 | + .unwrap(); | |
| 585 | + assert_rejected(outcome, StatusCode::FORBIDDEN); | |
| 586 | + } | |
| 587 | + | |
| 588 | + #[tokio::test] | |
| 589 | + async fn check_rejects_form_body_exceeding_cap() { | |
| 590 | + let session = test_session(); | |
| 591 | + let huge_body = "a=".to_string() + &"x".repeat(1024); | |
| 592 | + let cfg = CsrfProtectionConfig { | |
| 593 | + accept_form_body: true, | |
| 594 | + max_form_body_bytes: 16, // intentionally tiny | |
| 595 | + }; | |
| 596 | + let req = build_form_request(&huge_body, None); | |
| 597 | + | |
| 598 | + let outcome = csrf_check(&session, &uri(), req, &cfg).await.unwrap(); | |
| 599 | + assert_rejected(outcome, StatusCode::PAYLOAD_TOO_LARGE); | |
| 600 | + } | |
| 601 | + | |
| 602 | + #[tokio::test] | |
| 603 | + async fn check_rejects_multipart_body_without_header() { | |
| 604 | + let session = test_session(); | |
| 605 | + let _ = get_or_create_csrf_token(&session).await.unwrap(); | |
| 606 | + // Multipart is never buffered — security choice, avoids large uploads. | |
| 607 | + let req = build_form_request("ignored", Some("multipart/form-data; boundary=----abc")); | |
| 608 | + | |
| 609 | + let outcome = csrf_check(&session, &uri(), req, &form_body_cfg()) | |
| 610 | + .await | |
| 611 | + .unwrap(); | |
| 612 | + assert_rejected(outcome, StatusCode::FORBIDDEN); | |
| 613 | + } | |
| 614 | + | |
| 615 | + #[tokio::test] | |
| 616 | + async fn default_config_matches_header_only_behavior() { | |
| 617 | + let cfg = CsrfProtectionConfig::default(); | |
| 618 | + assert!(!cfg.accept_form_body); | |
| 619 | + assert_eq!(cfg.max_form_body_bytes, DEFAULT_MAX_FORM_BODY_BYTES); | |
| 620 | + } | |
| 250 | 621 | } |
M
crates/rustwarden-core/src/middleware/error_handler.rs
+45
-19
@@ -21,37 +21,56 @@ use crate::error::AppError;
| 21 | 21 | #[derive(Debug, Clone)] |
| 22 | 22 | pub enum ErrorCode { |
| 23 | 23 | // Authentication errors |
| 24 | - Auth001, // Invalid credentials | |
| 25 | - Auth002, // Invalid token | |
| 26 | - Auth003, // Token expired | |
| 27 | - Auth004, // Token reused | |
| 28 | - Auth005, // Authentication required | |
| 29 | - Auth006, // Password change required | |
| 24 | + /// Invalid credentials supplied (wrong username or password). | |
| 25 | + Auth001, | |
| 26 | + /// Token is malformed or unrecognised. | |
| 27 | + Auth002, | |
| 28 | + /// Token has expired and must be refreshed. | |
| 29 | + Auth003, | |
| 30 | + /// Token was already used; possible token-theft detected. | |
| 31 | + Auth004, | |
| 32 | + /// Request requires authentication but no credentials were provided. | |
| 33 | + Auth005, | |
| 34 | + /// User must change their password before proceeding. | |
| 35 | + Auth006, | |
| 30 | 36 | |
| 31 | 37 | // CSRF errors |
| 32 | - Csrf001, // Missing CSRF token | |
| 33 | - Csrf002, // Invalid CSRF token | |
| 34 | - Csrf003, // Expired CSRF token | |
| 38 | + /// CSRF token is absent from the request. | |
| 39 | + Csrf001, | |
| 40 | + /// CSRF token does not match the expected value. | |
| 41 | + Csrf002, | |
| 42 | + /// CSRF token has expired. | |
| 43 | + Csrf003, | |
| 35 | 44 | |
| 36 | 45 | // Rate limiting errors |
| 37 | - Rate001, // Rate limit exceeded | |
| 46 | + /// The client has exceeded the allowed request rate. | |
| 47 | + Rate001, | |
| 38 | 48 | |
| 39 | 49 | // Validation errors |
| 40 | - Valid001, // Invalid input data | |
| 41 | - Valid002, // Password complexity not met | |
| 42 | - Valid003, // Invalid email format | |
| 43 | - Valid004, // Invalid username format | |
| 50 | + /// Input data failed schema or business-rule validation. | |
| 51 | + Valid001, | |
| 52 | + /// Submitted password does not meet complexity requirements. | |
| 53 | + Valid002, | |
| 54 | + /// Supplied email address is not in a valid format. | |
| 55 | + Valid003, | |
| 56 | + /// Supplied username is not in a valid format. | |
| 57 | + Valid004, | |
| 44 | 58 | |
| 45 | 59 | // Authorization errors |
| 46 | - Rbac001, // Insufficient permissions | |
| 47 | - Rbac002, // Admin role required | |
| 60 | + /// The authenticated user lacks the required permission. | |
| 61 | + Rbac001, | |
| 62 | + /// The operation requires administrator role. | |
| 63 | + Rbac002, | |
| 48 | 64 | |
| 49 | 65 | // Database errors |
| 50 | - Db001, // Database operation failed | |
| 51 | - Db002, // Resource not found | |
| 66 | + /// A database operation failed unexpectedly. | |
| 67 | + Db001, | |
| 68 | + /// The requested database record could not be found. | |
| 69 | + Db002, | |
| 52 | 70 | |
| 53 | 71 | // Configuration errors |
| 54 | - Config001, // Configuration error | |
| 72 | + /// A configuration value is missing or invalid. | |
| 73 | + Config001, | |
| 55 | 74 | } |
| 56 | 75 | |
| 57 | 76 | impl ErrorCode { |
@@ -96,6 +115,13 @@ impl ErrorCode {
| 96 | 115 | AppError::Config(_) => ErrorCode::Config001, |
| 97 | 116 | AppError::Internal(_) => ErrorCode::Db001, |
| 98 | 117 | AppError::Template(_) => ErrorCode::Db001, // Template errors treated as internal |
| 118 | + AppError::TotpFailed => ErrorCode::Auth001, | |
| 119 | + AppError::TotpChallengeInvalid => ErrorCode::Auth002, | |
| 120 | + AppError::TotpMaxAttemptsExceeded => ErrorCode::Rate001, | |
| 121 | + AppError::TotpRequired => ErrorCode::Auth006, | |
| 122 | + AppError::TotpAlreadyEnabled => ErrorCode::Valid001, | |
| 123 | + AppError::TotpNotEnabled => ErrorCode::Valid001, | |
| 124 | + AppError::TooManyRequests { .. } => ErrorCode::Rate001, | |
| 99 | 125 | } |
| 100 | 126 | } |
| 101 | 127 | } |
M
crates/rustwarden-core/src/middleware/mod.rs
+11
-0
@@ -1,14 +1,25 @@
| 1 | 1 | /// API Middleware Module |
| 2 | 2 | /// |
| 3 | 3 | /// Middleware for the Rustwarden API. |
| 4 | +/// Extractor for admin identity from request extensions. | |
| 4 | 5 | pub mod admin_extractor; |
| 6 | +/// Helper utilities for admin middleware. | |
| 5 | 7 | pub mod admin_helper; |
| 8 | +/// Middleware that rejects non-admin requests. | |
| 6 | 9 | pub mod admin_required; |
| 10 | +/// Service layer for admin-specific operations used by middleware. | |
| 7 | 11 | pub mod admin_service; |
| 12 | +/// Middleware that rejects unauthenticated requests. | |
| 8 | 13 | pub mod auth_required; |
| 14 | +/// Service layer for authentication checks used by middleware. | |
| 9 | 15 | pub mod auth_service; |
| 16 | +/// CSRF token generation and validation middleware. | |
| 10 | 17 | pub mod csrf; |
| 18 | +/// Centralized error handling middleware that maps errors to structured responses. | |
| 11 | 19 | pub mod error_handler; |
| 20 | +/// Middleware that validates password complexity requirements. | |
| 12 | 21 | pub mod password_complexity; |
| 22 | +/// Token-bucket rate limiting middleware. | |
| 13 | 23 | pub mod rate_limit; |
| 24 | +/// Middleware that updates the user's last-login timestamp on successful requests. | |
| 14 | 25 | pub mod update_last_login; |
M
crates/rustwarden-core/src/middleware/password_complexity.rs
+9
-6
@@ -16,12 +16,17 @@ use crate::services::app_settings_service::AppSettingsService;
| 16 | 16 | /// Password validation rules (loaded from DB or defaults) |
| 17 | 17 | #[derive(Debug, Clone, serde::Serialize)] |
| 18 | 18 | pub struct PasswordRules { |
| 19 | + /// Minimum number of characters required in a password. | |
| 19 | 20 | pub min_length: usize, |
| 20 | 21 | /// SEC-M3: bcrypt silently truncates at 72 bytes; cap passwords to prevent DoS |
| 21 | 22 | pub max_length: usize, |
| 23 | + /// Whether at least one uppercase letter is required. | |
| 22 | 24 | pub require_uppercase: bool, |
| 25 | + /// Whether at least one lowercase letter is required. | |
| 23 | 26 | pub require_lowercase: bool, |
| 27 | + /// Whether at least one digit is required. | |
| 24 | 28 | pub require_digits: bool, |
| 29 | + /// Whether at least one special character is required. | |
| 25 | 30 | pub require_special: bool, |
| 26 | 31 | } |
| 27 | 32 |
@@ -43,12 +48,10 @@ impl PasswordRules {
| 43 | 48 | /// Load password rules from the `app_settings` table. |
| 44 | 49 | /// Falls back to defaults for any missing key. |
| 45 | 50 | pub async fn from_db(db: &DatabaseConnection) -> Self { |
| 46 | - let get_bool = |key: &'static str, default: bool| { | |
| 47 | - async move { | |
| 48 | - AppSettingsService::get_bool(db, key, default) | |
| 49 | - .await | |
| 50 | - .unwrap_or(default) | |
| 51 | - } | |
| 51 | + let get_bool = |key: &'static str, default: bool| async move { | |
| 52 | + AppSettingsService::get_bool(db, key, default) | |
| 53 | + .await | |
| 54 | + .unwrap_or(default) | |
| 52 | 55 | }; |
| 53 | 56 | |
| 54 | 57 | let min_length = match AppSettingsService::get_setting(db, "password_min_length").await { |
M
crates/rustwarden-core/src/middleware/rate_limit.rs
+96
-281
@@ -1,27 +1,37 @@
| 1 | -/// Rate Limiting Middleware for Admin Routes and Public Share Endpoints | |
| 2 | -/// Feature 009: Admin & Settings | |
| 3 | -/// Feature 041: Files Sync & Sharing (T074) | |
| 4 | -/// | |
| 5 | -/// Middleware to apply rate limiting to admin routes and public share endpoints with different limits: | |
| 6 | -/// - Admin endpoints: 100 req/min | |
| 7 | -/// - User management: 20 req/min | |
| 8 | -/// - Reset password: 10 req/min | |
| 9 | -/// - Public share endpoints: 30 req/min per IP | |
| 1 | +//! Rate Limiting Middleware and Helpers | |
| 2 | +//! | |
| 3 | +//! Feature 009: Admin & Settings — per-route tower layers for blunt-instrument | |
| 4 | +//! rate limiting on admin endpoints. | |
| 5 | +//! | |
| 6 | +//! Feature 041: Files Sync & Sharing (T074) — fine-grained per-IP limits for | |
| 7 | +//! public share endpoints and authentication endpoints, routed through the | |
| 8 | +//! generic [`RateLimitService`](crate::services::rate_limit_service::RateLimitService). | |
| 9 | +//! | |
| 10 | +//! # Thresholds | |
| 11 | +//! | |
| 12 | +//! | Scope | Default | | |
| 13 | +//! |-------|---------| | |
| 14 | +//! | Admin (tower `RateLimitLayer`) | 100 req/min | | |
| 15 | +//! | User management (tower) | 20 req/min | | |
| 16 | +//! | Reset password (tower) | 10 req/min | | |
| 17 | +//! | Public share per IP | 30 req/min | | |
| 18 | +//! | Auth per IP | env `RATE_LIMIT_AUTH_PER_MINUTE`, default 10 req/min | | |
| 19 | +//! | |
| 20 | +//! Public share and auth limits are backed by the in-process | |
| 21 | +//! [`RateLimitService`] so they share bounding, cleanup and the canonical | |
| 22 | +//! `TooManyRequests` error (HTTP 429 + `Retry-After`). | |
| 23 | + | |
| 10 | 24 | use axum::{ |
| 11 | 25 | extract::{Request, State}, |
| 12 | - http::StatusCode, | |
| 13 | 26 | middleware::Next, |
| 14 | 27 | response::{IntoResponse, Response}, |
| 15 | 28 | }; |
| 16 | 29 | use sea_orm::DatabaseConnection; |
| 17 | -use std::{ | |
| 18 | - collections::HashMap, | |
| 19 | - sync::{Arc, Mutex}, | |
| 20 | - time::{Duration, Instant}, | |
| 21 | -}; | |
| 30 | +use std::{sync::OnceLock, time::Duration}; | |
| 22 | 31 | use tower::limit::RateLimitLayer; |
| 23 | 32 | |
| 24 | 33 | use crate::error::AppError; |
| 34 | +use crate::services::rate_limit_service::RateLimitService; | |
| 25 | 35 | |
| 26 | 36 | /// Rate limiting configuration for admin routes |
| 27 | 37 | pub struct AdminRateLimitConfig; |
@@ -49,7 +59,7 @@ pub struct PublicShareRateLimitConfig;
| 49 | 59 | |
| 50 | 60 | impl PublicShareRateLimitConfig { |
| 51 | 61 | /// Create rate limiter for public share endpoints (30 requests per minute per IP) |
| 52 | - /// | |
| 62 | + /// | |
| 53 | 63 | /// This limit is more restrictive than authenticated endpoints to prevent abuse |
| 54 | 64 | /// of public links (brute force password attempts, token enumeration, etc.) |
| 55 | 65 | pub fn public_share_limit() -> RateLimitLayer { |
@@ -57,106 +67,46 @@ impl PublicShareRateLimitConfig {
| 57 | 67 | } |
| 58 | 68 | } |
| 59 | 69 | |
| 60 | -/// Middleware that applies rate limiting to admin routes | |
| 61 | -/// | |
| 62 | -/// This middleware uses tower's RateLimitLayer which applies per-IP rate limiting. | |
| 63 | -/// Different limits can be applied to different route groups. | |
| 64 | -/// | |
| 65 | -/// # Arguments | |
| 70 | +/// Placeholder middleware — real admin rate limiting is applied via | |
| 71 | +/// [`AdminRateLimitConfig::admin_limit`] in the router layer. | |
| 66 | 72 | /// |
| 67 | -/// * `State(_db)` - Database connection (reserved for future per-user limits) | |
| 68 | -/// * `request` - HTTP request | |
| 69 | -/// * `next` - Next middleware or handler | |
| 70 | -/// | |
| 71 | -/// # Returns | |
| 72 | -/// | |
| 73 | -/// * `200 OK` if within limits (passes to handler) | |
| 74 | -/// * `429 Too Many Requests` if limit exceeded | |
| 75 | -/// | |
| 76 | -/// # Note | |
| 77 | -/// | |
| 78 | -/// This middleware is a placeholder. The actual rate limiting is applied | |
| 79 | -/// via `RateLimitLayer` in the router configuration (see routes.rs). | |
| 80 | -/// This middleware can be extended to implement per-user rate limiting | |
| 81 | -/// or more sophisticated rate limiting strategies. | |
| 73 | +/// Kept for composability if a future version routes admin traffic through | |
| 74 | +/// [`RateLimitService`] (for per-user quotas). | |
| 82 | 75 | pub async fn admin_rate_limit( |
| 83 | 76 | State(_db): State<DatabaseConnection>, |
| 84 | 77 | request: Request, |
| 85 | 78 | next: Next, |
| 86 | 79 | ) -> Result<Response, AppError> { |
| 87 | - // Rate limiting is handled by RateLimitLayer in router configuration | |
| 88 | - // This middleware can be extended for per-user limits or custom logic | |
| 89 | 80 | Ok(next.run(request).await) |
| 90 | 81 | } |
| 91 | 82 | |
| 92 | -/// SEC-M2: Maximum distinct IPs tracked before forcing a purge. | |
| 93 | -const MAX_RATE_LIMIT_ENTRIES: usize = 100_000; | |
| 83 | +const PUBLIC_SHARE_MAX_PER_MINUTE: u32 = 30; | |
| 84 | +const DEFAULT_AUTH_MAX_PER_MINUTE: u32 = 10; | |
| 94 | 85 | |
| 95 | -/// Rate limit tracker for public share endpoints (per-IP) | |
| 96 | -#[derive(Clone)] | |
| 97 | -pub struct PublicShareRateLimiter { | |
| 98 | - requests: Arc<Mutex<HashMap<String, (u32, Instant)>>>, | |
| 99 | - max_requests: u32, | |
| 100 | - window_seconds: u64, | |
| 101 | -} | |
| 86 | +static PUBLIC_SHARE_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 87 | +static AUTH_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); | |
| 88 | +static AUTH_MAX_PER_MINUTE: OnceLock<u32> = OnceLock::new(); | |
| 102 | 89 | |
| 103 | -impl Default for PublicShareRateLimiter { | |
| 104 | - fn default() -> Self { | |
| 105 | - Self::new(30, 60) | |
| 106 | - } | |
| 90 | +fn public_share_limiter() -> &'static RateLimitService { | |
| 91 | + PUBLIC_SHARE_LIMITER.get_or_init(RateLimitService::new) | |
| 107 | 92 | } |
| 108 | 93 | |
| 109 | -impl PublicShareRateLimiter { | |
| 110 | - pub fn new(max_requests: u32, window_seconds: u64) -> Self { | |
| 111 | - Self { | |
| 112 | - requests: Arc::new(Mutex::new(HashMap::new())), | |
| 113 | - max_requests, | |
| 114 | - window_seconds, | |
| 115 | - } | |
| 116 | - } | |
| 117 | - | |
| 118 | - pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> { | |
| 119 | - let mut requests = self | |
| 120 | - .requests | |
| 121 | - .lock() | |
| 122 | - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| 123 | - | |
| 124 | - let now = Instant::now(); | |
| 125 | - let window_duration = Duration::from_secs(self.window_seconds); | |
| 126 | - | |
| 127 | - requests.retain(|_, (_, start)| now.duration_since(*start) < window_duration); | |
| 128 | - | |
| 129 | - // SEC-M2: Reject if map is still too large after purge (memory DoS protection) | |
| 130 | - if requests.len() >= MAX_RATE_LIMIT_ENTRIES && !requests.contains_key(ip) { | |
| 131 | - tracing::warn!("Rate limiter map full ({} entries), rejecting new IP", requests.len()); | |
| 132 | - return Err(StatusCode::TOO_MANY_REQUESTS); | |
| 133 | - } | |
| 134 | - | |
| 135 | - let (count, start) = requests.entry(ip.to_string()).or_insert_with(|| (0, now)); | |
| 136 | - | |
| 137 | - if now.duration_since(*start) >= window_duration { | |
| 138 | - *count = 0; | |
| 139 | - *start = now; | |
| 140 | - } | |
| 141 | - | |
| 142 | - *count += 1; | |
| 143 | - if *count > self.max_requests { | |
| 144 | - tracing::warn!( | |
| 145 | - ip = %ip, | |
| 146 | - count = %count, | |
| 147 | - max = %self.max_requests, | |
| 148 | - "Rate limit exceeded for public share endpoint" | |
| 149 | - ); | |
| 150 | - return Err(StatusCode::TOO_MANY_REQUESTS); | |
| 151 | - } | |
| 94 | +fn auth_limiter() -> &'static RateLimitService { | |
| 95 | + AUTH_LIMITER.get_or_init(RateLimitService::new) | |
| 96 | +} | |
| 152 | 97 | |
| 153 | - Ok(()) | |
| 154 | - } | |
| 98 | +fn auth_max_per_minute() -> u32 { | |
| 99 | + *AUTH_MAX_PER_MINUTE.get_or_init(|| { | |
| 100 | + std::env::var("RATE_LIMIT_AUTH_PER_MINUTE") | |
| 101 | + .ok() | |
| 102 | + .and_then(|v| v.parse().ok()) | |
| 103 | + .unwrap_or(DEFAULT_AUTH_MAX_PER_MINUTE) | |
| 104 | + }) | |
| 155 | 105 | } |
| 156 | 106 | |
| 157 | 107 | /// SEC-H3: Number of trusted reverse proxy hops (0 = direct connection, ignore XFF). |
| 158 | 108 | /// Read once from TRUSTED_PROXY_HOPS environment variable. |
| 159 | -static TRUSTED_PROXY_HOPS: std::sync::OnceLock<usize> = std::sync::OnceLock::new(); | |
| 109 | +static TRUSTED_PROXY_HOPS: OnceLock<usize> = OnceLock::new(); | |
| 160 | 110 | |
| 161 | 111 | fn trusted_proxy_hops() -> usize { |
| 162 | 112 | *TRUSTED_PROXY_HOPS.get_or_init(|| { |
@@ -170,10 +120,7 @@ fn trusted_proxy_hops() -> usize {
| 170 | 120 | /// SEC-H3: Extract the real client IP, respecting TRUSTED_PROXY_HOPS. |
| 171 | 121 | /// When TRUSTED_PROXY_HOPS > 0, takes the Nth IP from the right of X-Forwarded-For. |
| 172 | 122 | /// Otherwise falls back to the direct connection IP. |
| 173 | -fn extract_ip_from_xff_or_connect( | |
| 174 | - xff: Option<&str>, | |
| 175 | - connect_ip: Option<String>, | |
| 176 | -) -> String { | |
| 123 | +fn extract_ip_from_xff_or_connect(xff: Option<&str>, connect_ip: Option<String>) -> String { | |
| 177 | 124 | let hops = trusted_proxy_hops(); |
| 178 | 125 | if hops > 0 { |
| 179 | 126 | if let Some(xff_value) = xff { |
@@ -201,199 +148,55 @@ pub fn extract_client_ip(request: &Request) -> String {
| 201 | 148 | |
| 202 | 149 | /// Extract IP address from HeaderMap (used in handlers where Request is consumed) |
| 203 | 150 | pub fn extract_client_ip_from_headers(headers: &axum::http::HeaderMap) -> String { |
| 204 | - let xff = headers | |
| 205 | - .get("x-forwarded-for") | |
| 206 | - .and_then(|h| h.to_str().ok()); | |
| 151 | + let xff = headers.get("x-forwarded-for").and_then(|h| h.to_str().ok()); | |
| 207 | 152 | extract_ip_from_xff_or_connect(xff, None) |
| 208 | 153 | } |
| 209 | 154 | |
| 210 | -/// Global rate limiter instance for public share endpoints | |
| 211 | -/// Feature 041: Files Sync & Sharing (T074) | |
| 155 | +/// Check rate limit for a public share endpoint request. | |
| 212 | 156 | /// |
| 213 | -/// This is a static instance that can be used across handlers. | |
| 214 | -/// In a production environment, this could be replaced with a distributed | |
| 215 | -/// rate limiter (Redis, etc.) for multi-instance deployments. | |
| 216 | -static PUBLIC_SHARE_RATE_LIMITER: std::sync::OnceLock<PublicShareRateLimiter> = | |
| 217 | - std::sync::OnceLock::new(); | |
| 218 | - | |
| 219 | -/// Get or initialize the global public share rate limiter | |
| 220 | -/// Feature 041: Files Sync & Sharing (T074) | |
| 221 | -pub fn get_public_share_rate_limiter() -> &'static PublicShareRateLimiter { | |
| 222 | - PUBLIC_SHARE_RATE_LIMITER.get_or_init(PublicShareRateLimiter::default) | |
| 223 | -} | |
| 224 | - | |
| 225 | -/// Check rate limit for a public share endpoint request | |
| 226 | -/// Feature 041: Files Sync & Sharing (T074) | |
| 227 | -/// | |
| 228 | -/// This function can be called directly from handlers to check rate limits. | |
| 229 | -/// Returns an error response if rate limit is exceeded. | |
| 230 | -/// | |
| 231 | -/// # Arguments | |
| 232 | -/// | |
| 233 | -/// * `request` - HTTP request | |
| 234 | -/// | |
| 235 | -/// # Returns | |
| 236 | -/// | |
| 237 | -/// * `Ok(())` if within limits | |
| 238 | -/// * `Err(Response)` with 429 status if limit exceeded | |
| 157 | +/// Feature 041: Files Sync & Sharing (T074). Returns an [`AppError`] | |
| 158 | +/// response (HTTP 429 with `Retry-After`) if the limit is exceeded. | |
| 239 | 159 | #[allow(clippy::result_large_err)] |
| 240 | -pub fn check_public_share_rate_limit( | |
| 241 | - request: &Request, | |
| 242 | -) -> Result<(), axum::response::Response> { | |
| 160 | +pub fn check_public_share_rate_limit(request: &Request) -> Result<(), Response> { | |
| 243 | 161 | let ip = extract_client_ip(request); |
| 244 | - let rate_limiter = get_public_share_rate_limiter(); | |
| 245 | - | |
| 246 | - match rate_limiter.check_rate_limit(&ip) { | |
| 247 | - Ok(()) => { | |
| 248 | - tracing::debug!(ip = %ip, "Public share request allowed"); | |
| 249 | - Ok(()) | |
| 250 | - } | |
| 251 | - Err(status) => { | |
| 252 | - tracing::warn!( | |
| 253 | - ip = %ip, | |
| 254 | - "Public share rate limit exceeded" | |
| 255 | - ); | |
| 256 | - Err(( | |
| 257 | - status, | |
| 258 | - axum::Json(serde_json::json!({ | |
| 259 | - "error": "Rate limit exceeded. Please try again later." | |
| 260 | - })), | |
| 261 | - ) | |
| 262 | - .into_response()) | |
| 162 | + match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) { | |
| 163 | + Ok(()) => Ok(()), | |
| 164 | + Err(err) => { | |
| 165 | + tracing::warn!(ip = %ip, "Public share rate limit exceeded"); | |
| 166 | + Err(err.into_response()) | |
| 263 | 167 | } |
| 264 | 168 | } |
| 265 | 169 | } |
| 266 | 170 | |
| 267 | -/// Check rate limit for a public share endpoint using HeaderMap | |
| 268 | -/// Feature 041: Files Sync & Sharing (T074) | |
| 269 | -/// | |
| 270 | -/// This function can be called directly from handlers to check rate limits. | |
| 271 | -/// Returns an error response if rate limit is exceeded. | |
| 272 | -/// | |
| 273 | -/// # Arguments | |
| 274 | -/// | |
| 275 | -/// * `headers` - HTTP headers | |
| 276 | -/// | |
| 277 | -/// # Returns | |
| 171 | +/// Check rate limit for a public share endpoint using `HeaderMap`. | |
| 278 | 172 | /// |
| 279 | -/// * `Ok(())` if within limits | |
| 280 | -/// * `Err(Response)` with 429 status if limit exceeded | |
| 173 | +/// Feature 041: Files Sync & Sharing (T074). Prefer this variant when the | |
| 174 | +/// `Request` has already been destructured by an extractor. | |
| 281 | 175 | #[allow(clippy::result_large_err)] |
| 282 | 176 | pub fn check_public_share_rate_limit_from_headers( |
| 283 | 177 | headers: &axum::http::HeaderMap, |
| 284 | -) -> Result<(), axum::response::Response> { | |
| 178 | +) -> Result<(), Response> { | |
| 285 | 179 | let ip = extract_client_ip_from_headers(headers); |
| 286 | - let rate_limiter = get_public_share_rate_limiter(); | |
| 287 | - | |
| 288 | - match rate_limiter.check_rate_limit(&ip) { | |
| 289 | - Ok(()) => { | |
| 290 | - tracing::debug!(ip = %ip, "Public share request allowed"); | |
| 291 | - Ok(()) | |
| 292 | - } | |
| 293 | - Err(status) => { | |
| 294 | - tracing::warn!( | |
| 295 | - ip = %ip, | |
| 296 | - "Public share rate limit exceeded" | |
| 297 | - ); | |
| 298 | - Err(( | |
| 299 | - status, | |
| 300 | - axum::Json(serde_json::json!({ | |
| 301 | - "error": "Rate limit exceeded. Please try again later." | |
| 302 | - })), | |
| 303 | - ) | |
| 304 | - .into_response()) | |
| 305 | - } | |
| 306 | - } | |
| 307 | -} | |
| 308 | - | |
| 309 | -/// SEC-C2: Per-IP rate limiter for authentication endpoints | |
| 310 | -/// Protects login, register, refresh, and forgot-password from brute-force attacks. | |
| 311 | -#[derive(Clone)] | |
| 312 | -pub struct AuthRateLimiter { | |
| 313 | - requests: Arc<Mutex<HashMap<String, (u32, Instant)>>>, | |
| 314 | - max_requests: u32, | |
| 315 | - window_seconds: u64, | |
| 316 | -} | |
| 317 | - | |
| 318 | -impl AuthRateLimiter { | |
| 319 | - pub fn new(max_requests: u32, window_seconds: u64) -> Self { | |
| 320 | - Self { | |
| 321 | - requests: Arc::new(Mutex::new(HashMap::new())), | |
| 322 | - max_requests, | |
| 323 | - window_seconds, | |
| 324 | - } | |
| 325 | - } | |
| 326 | - | |
| 327 | - pub fn check_rate_limit(&self, ip: &str) -> Result<(), StatusCode> { | |
| 328 | - let mut requests = self | |
| 329 | - .requests | |
| 330 | - .lock() | |
| 331 | - .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; | |
| 332 | - | |
| 333 | - let now = Instant::now(); | |
| 334 | - let window_duration = Duration::from_secs(self.window_seconds); | |
| 335 | - | |
| 336 | - requests.retain(|_, (_, start)| now.duration_since(*start) < window_duration); | |
| 337 | - | |
| 338 | - // SEC-M2: Reject if map is still too large after purge | |
| 339 | - if requests.len() >= MAX_RATE_LIMIT_ENTRIES && !requests.contains_key(ip) { | |
| 340 | - tracing::warn!("Auth rate limiter map full ({} entries), rejecting new IP", requests.len()); | |
| 341 | - return Err(StatusCode::TOO_MANY_REQUESTS); | |
| 342 | - } | |
| 343 | - | |
| 344 | - let (count, start) = requests.entry(ip.to_string()).or_insert_with(|| (0, now)); | |
| 345 | - | |
| 346 | - if now.duration_since(*start) >= window_duration { | |
| 347 | - *count = 0; | |
| 348 | - *start = now; | |
| 349 | - } | |
| 350 | - | |
| 351 | - *count += 1; | |
| 352 | - if *count > self.max_requests { | |
| 353 | - tracing::warn!( | |
| 354 | - ip = %ip, | |
| 355 | - count = %count, | |
| 356 | - max = %self.max_requests, | |
| 357 | - "Auth rate limit exceeded" | |
| 358 | - ); | |
| 359 | - return Err(StatusCode::TOO_MANY_REQUESTS); | |
| 180 | + match public_share_limiter().check(&format!("share:{ip}"), PUBLIC_SHARE_MAX_PER_MINUTE) { | |
| 181 | + Ok(()) => Ok(()), | |
| 182 | + Err(err) => { | |
| 183 | + tracing::warn!(ip = %ip, "Public share rate limit exceeded"); | |
| 184 | + Err(err.into_response()) | |
| 360 | 185 | } |
| 361 | - | |
| 362 | - Ok(()) | |
| 363 | 186 | } |
| 364 | 187 | } |
| 365 | 188 | |
| 366 | -static AUTH_RATE_LIMITER: std::sync::OnceLock<AuthRateLimiter> = std::sync::OnceLock::new(); | |
| 367 | - | |
| 368 | -/// Get or initialize the global auth rate limiter. | |
| 369 | -/// Default: 10 requests per minute per IP. | |
| 370 | -pub fn get_auth_rate_limiter() -> &'static AuthRateLimiter { | |
| 371 | - AUTH_RATE_LIMITER.get_or_init(|| { | |
| 372 | - let max = std::env::var("RATE_LIMIT_AUTH_PER_MINUTE") | |
| 373 | - .ok() | |
| 374 | - .and_then(|v| v.parse().ok()) | |
| 375 | - .unwrap_or(10); | |
| 376 | - AuthRateLimiter::new(max, 60) | |
| 377 | - }) | |
| 378 | -} | |
| 379 | - | |
| 380 | -/// Check auth rate limit for a request and return an error response if exceeded. | |
| 189 | +/// SEC-C2: Check auth rate limit for an incoming request. | |
| 190 | +/// | |
| 191 | +/// Protects login, register, refresh, and forgot-password from brute-force | |
| 192 | +/// attacks. Returns 429 `Too Many Requests` with a `Retry-After` header when | |
| 193 | +/// the per-IP budget is exceeded. | |
| 381 | 194 | #[allow(clippy::result_large_err)] |
| 382 | -pub fn check_auth_rate_limit( | |
| 383 | - headers: &axum::http::HeaderMap, | |
| 384 | -) -> Result<(), axum::response::Response> { | |
| 195 | +pub fn check_auth_rate_limit(headers: &axum::http::HeaderMap) -> Result<(), Response> { | |
| 385 | 196 | let ip = extract_client_ip_from_headers(headers); |
| 386 | - let limiter = get_auth_rate_limiter(); | |
| 387 | - | |
| 388 | - match limiter.check_rate_limit(&ip) { | |
| 197 | + match auth_limiter().check(&format!("auth:{ip}"), auth_max_per_minute()) { | |
| 389 | 198 | Ok(()) => Ok(()), |
| 390 | - Err(status) => Err(( | |
| 391 | - status, | |
| 392 | - axum::Json(serde_json::json!({ | |
| 393 | - "error": "Too many requests. Please try again later." | |
| 394 | - })), | |
| 395 | - ) | |
| 396 | - .into_response()), | |
| 199 | + Err(err) => Err(err.into_response()), | |
| 397 | 200 | } |
| 398 | 201 | } |
| 399 | 202 |
@@ -409,13 +212,25 @@ mod tests {
| 409 | 212 | } |
| 410 | 213 | |
| 411 | 214 | #[test] |
| 412 | - fn test_auth_rate_limiter() { | |
| 413 | - let limiter = AuthRateLimiter::new(3, 60); | |
| 414 | - assert!(limiter.check_rate_limit("127.0.0.1").is_ok()); | |
| 415 | - assert!(limiter.check_rate_limit("127.0.0.1").is_ok()); | |
| 416 | - assert!(limiter.check_rate_limit("127.0.0.1").is_ok()); | |
| 417 | - assert!(limiter.check_rate_limit("127.0.0.1").is_err()); | |
| 418 | - // Different IP should still be allowed | |
| 419 | - assert!(limiter.check_rate_limit("192.168.1.1").is_ok()); | |
| 215 | + fn auth_limiter_isolates_budget_per_ip() { | |
| 216 | + let rl = RateLimitService::new(); | |
| 217 | + for _ in 0..3 { | |
| 218 | + rl.check("auth:1.2.3.4", 3).unwrap(); | |
| 219 | + } | |
| 220 | + assert!(rl.check("auth:1.2.3.4", 3).is_err()); | |
| 221 | + // Different IP has its own budget. | |
| 222 | + rl.check("auth:5.6.7.8", 3) | |
| 223 | + .expect("distinct IP must not share the other IP's budget"); | |
| 224 | + } | |
| 225 | + | |
| 226 | + #[test] | |
| 227 | + fn share_limiter_isolates_budget_per_ip() { | |
| 228 | + let rl = RateLimitService::new(); | |
| 229 | + for _ in 0..3 { | |
| 230 | + rl.check("share:1.2.3.4", 3).unwrap(); | |
| 231 | + } | |
| 232 | + assert!(rl.check("share:1.2.3.4", 3).is_err()); | |
| 233 | + rl.check("share:9.9.9.9", 3) | |
| 234 | + .expect("distinct IP must not share the other IP's budget"); | |
| 420 | 235 | } |
| 421 | 236 | } |
M
crates/rustwarden-core/src/migrations.rs
+23
-1
@@ -21,21 +21,38 @@
| 21 | 21 | //! } |
| 22 | 22 | //! ``` |
| 23 | 23 | |
| 24 | +/// Migration 1: initial schema (users, roles, refresh tokens, audit logs). | |
| 24 | 25 | #[path = "../migrations/src/m20260305_000001_initial_schema.rs"] |
| 25 | 26 | pub mod m20260305_000001_initial_schema; |
| 26 | 27 | |
| 28 | +/// Migration 2: application settings table. | |
| 27 | 29 | #[path = "../migrations/src/m20260306_000002_create_app_settings_table.rs"] |
| 28 | 30 | pub mod m20260306_000002_create_app_settings_table; |
| 29 | 31 | |
| 32 | +/// Migration 3: RBAC permissions tables. | |
| 30 | 33 | #[path = "../migrations/src/m20260309_000003_create_permissions_tables.rs"] |
| 31 | 34 | pub mod m20260309_000003_create_permissions_tables; |
| 32 | 35 | |
| 36 | +/// Migration 4: generic resources and resource-shares tables. | |
| 33 | 37 | #[path = "../migrations/src/m20260309_000004_create_resources_tables.rs"] |
| 34 | 38 | pub mod m20260309_000004_create_resources_tables; |
| 35 | 39 | |
| 40 | +/// Migration 5: OAuth accounts table. | |
| 36 | 41 | #[path = "../migrations/src/m20260310_000005_create_oauth_accounts_table.rs"] |
| 37 | 42 | pub mod m20260310_000005_create_oauth_accounts_table; |
| 38 | 43 | |
| 44 | +/// Migration 6: TOTP challenges and user-TOTP tables. | |
| 45 | +#[path = "../migrations/src/m20260409_000006_create_totp_tables.rs"] | |
| 46 | +pub mod m20260409_000006_create_totp_tables; | |
| 47 | + | |
| 48 | +/// Migration 7: personal access tokens table. | |
| 49 | +#[path = "../migrations/src/m20260422_000007_create_personal_access_tokens.rs"] | |
| 50 | +pub mod m20260422_000007_create_personal_access_tokens; | |
| 51 | + | |
| 52 | +/// Migration 8: teams and team-members tables. | |
| 53 | +#[path = "../migrations/src/m20260423_000008_create_teams.rs"] | |
| 54 | +pub mod m20260423_000008_create_teams; | |
| 55 | + | |
| 39 | 56 | use sea_orm_migration::prelude::*; |
| 40 | 57 | |
| 41 | 58 | /// Returns all core Rustwarden migrations in order. |
@@ -46,6 +63,9 @@ pub fn core_migrations() -> Vec<Box<dyn MigrationTrait>> {
| 46 | 63 | Box::new(m20260309_000003_create_permissions_tables::Migration), |
| 47 | 64 | Box::new(m20260309_000004_create_resources_tables::Migration), |
| 48 | 65 | Box::new(m20260310_000005_create_oauth_accounts_table::Migration), |
| 66 | + Box::new(m20260409_000006_create_totp_tables::Migration), | |
| 67 | + Box::new(m20260422_000007_create_personal_access_tokens::Migration), | |
| 68 | + Box::new(m20260423_000008_create_teams::Migration), | |
| 49 | 69 | ] |
| 50 | 70 | } |
| 51 | 71 |
@@ -60,7 +80,9 @@ impl MigratorTrait for CoreMigrator {
| 60 | 80 | } |
| 61 | 81 | |
| 62 | 82 | /// Run all pending core migrations on the database. |
| 63 | -pub async fn run_migrations(db: &sea_orm::DatabaseConnection) -> Result<(), sea_orm_migration::DbErr> { | |
| 83 | +pub async fn run_migrations( | |
| 84 | + db: &sea_orm::DatabaseConnection, | |
| 85 | +) -> Result<(), sea_orm_migration::DbErr> { | |
| 64 | 86 | use sea_orm_migration::MigratorTrait; |
| 65 | 87 | CoreMigrator::up(db, None).await |
| 66 | 88 | } |
M
crates/rustwarden-core/src/models/app_settings.rs
+2
-0
@@ -26,8 +26,10 @@ pub struct Model {
| 26 | 26 | pub updated_at: DateTime<Utc>, |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | +/// SeaORM relations for the AppSettings entity. | |
| 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 30 | 31 | pub enum Relation { |
| 32 | + /// The user who last updated this setting. | |
| 31 | 33 | #[sea_orm( |
| 32 | 34 | belongs_to = "super::user::Entity", |
| 33 | 35 | from = "Column::UpdatedBy", |
M
crates/rustwarden-core/src/models/audit_log.rs
+3
-0
@@ -27,14 +27,17 @@ pub struct Model {
| 27 | 27 | pub created_at: DateTime<Utc>, |
| 28 | 28 | } |
| 29 | 29 | |
| 30 | +/// SeaORM relations for the AuditLog entity. | |
| 30 | 31 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 31 | 32 | pub enum Relation { |
| 33 | + /// The user who performed the logged action. | |
| 32 | 34 | #[sea_orm( |
| 33 | 35 | belongs_to = "super::user::Entity", |
| 34 | 36 | from = "Column::UserId", |
| 35 | 37 | to = "super::user::Column::Id" |
| 36 | 38 | )] |
| 37 | 39 | User, |
| 40 | + /// The user targeted by the logged action (if any). | |
| 38 | 41 | #[sea_orm( |
| 39 | 42 | belongs_to = "super::user::Entity", |
| 40 | 43 | from = "Column::TargetUserId", |
M
crates/rustwarden-core/src/models/email_delivery_status.rs
+17
-0
@@ -3,28 +3,45 @@ use sea_orm::entity::prelude::*;
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | use uuid::Uuid; |
| 5 | 5 | |
| 6 | +/// SeaORM model for the `email_delivery_status` table. | |
| 7 | +/// | |
| 8 | +/// Tracks the delivery lifecycle of outbound emails (queued, sent, failed, etc.). | |
| 6 | 9 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 7 | 10 | #[sea_orm(table_name = "email_delivery_status")] |
| 8 | 11 | pub struct Model { |
| 12 | + /// Primary key. | |
| 9 | 13 | #[sea_orm(primary_key)] |
| 10 | 14 | pub id: Uuid, |
| 15 | + /// Category of email (e.g. `"verification"`, `"password_reset"`). | |
| 11 | 16 | pub email_type: String, |
| 17 | + /// Destination email address. | |
| 12 | 18 | pub recipient: String, |
| 19 | + /// Name of the template used to render the email body. | |
| 13 | 20 | pub template: String, |
| 21 | + /// Current delivery state (e.g. `"pending"`, `"sent"`, `"failed"`). | |
| 14 | 22 | #[sea_orm(indexed)] |
| 15 | 23 | pub state: String, |
| 24 | + /// SMTP response code from the last delivery attempt. | |
| 16 | 25 | pub smtp_code: Option<String>, |
| 26 | + /// Full SMTP response message from the last delivery attempt. | |
| 17 | 27 | pub smtp_response: Option<String>, |
| 28 | + /// Number of delivery attempts made so far. | |
| 18 | 29 | pub attempt_count: i32, |
| 30 | + /// Timestamp of the most recent delivery attempt. | |
| 19 | 31 | pub last_attempt_at: Option<DateTime<Utc>>, |
| 32 | + /// Timestamp of the last state transition. | |
| 20 | 33 | #[sea_orm(indexed)] |
| 21 | 34 | pub last_state_at: DateTime<Utc>, |
| 35 | + /// Optional correlation ID linking this record to a business transaction. | |
| 22 | 36 | pub correlation_id: Option<Uuid>, |
| 37 | + /// Timestamp when this record was created. | |
| 23 | 38 | #[sea_orm(indexed)] |
| 24 | 39 | pub created_at: DateTime<Utc>, |
| 40 | + /// Timestamp of the last update to this record. | |
| 25 | 41 | pub updated_at: DateTime<Utc>, |
| 26 | 42 | } |
| 27 | 43 | |
| 44 | +/// SeaORM relations for the EmailDeliveryStatus entity (none defined). | |
| 28 | 45 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 29 | 46 | pub enum Relation {} |
| 30 | 47 |
M
crates/rustwarden-core/src/models/email_queue.rs
+1
-0
@@ -83,6 +83,7 @@ pub struct Model {
| 83 | 83 | pub updated_at: DateTime<Utc>, |
| 84 | 84 | } |
| 85 | 85 | |
| 86 | +/// SeaORM relations for the EmailQueue entity (none defined). | |
| 86 | 87 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 87 | 88 | pub enum Relation {} |
| 88 | 89 |
M
crates/rustwarden-core/src/models/email_validation_token.rs
+1
-0
@@ -50,6 +50,7 @@ pub struct Model {
| 50 | 50 | pub created_at: DateTime<Utc>, |
| 51 | 51 | } |
| 52 | 52 | |
| 53 | +/// SeaORM relations for the EmailValidationToken entity. | |
| 53 | 54 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 54 | 55 | pub enum Relation { |
| 55 | 56 | /// Relationship to User entity |
M
crates/rustwarden-core/src/models/jwt_blacklist.rs
+2
-1
@@ -49,9 +49,10 @@ pub struct Model {
| 49 | 49 | pub created_at: DateTime<Utc>, |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | +/// SeaORM relations for the JwtBlacklist entity. | |
| 52 | 53 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 53 | 54 | pub enum Relation { |
| 54 | - /// Relationship to User entity | |
| 55 | + /// The user whose token was blacklisted. | |
| 55 | 56 | #[sea_orm( |
| 56 | 57 | belongs_to = "super::user::Entity", |
| 57 | 58 | from = "Column::UserId", |
M
crates/rustwarden-core/src/models/mod.rs
+37
-4
@@ -2,25 +2,53 @@
| 2 | 2 | //! |
| 3 | 3 | //! SeaORM entity models for the Rustwarden framework. |
| 4 | 4 | |
| 5 | +/// Application settings entity (key-value configuration store). | |
| 5 | 6 | pub mod app_settings; |
| 7 | +/// Audit log entity for admin operations. | |
| 6 | 8 | pub mod audit_log; |
| 7 | -#[cfg(feature = "oauth")] | |
| 8 | -pub mod oauth_account; | |
| 9 | +/// Email delivery status tracking entity. | |
| 9 | 10 | pub mod email_delivery_status; |
| 11 | +/// Email queue entity for asynchronous email sending. | |
| 10 | 12 | pub mod email_queue; |
| 13 | +/// Email validation token entity. | |
| 11 | 14 | pub mod email_validation_token; |
| 15 | +/// JWT blacklist entity for token revocation. | |
| 12 | 16 | pub mod jwt_blacklist; |
| 17 | +/// OAuth account entity linking external provider identities to users. | |
| 18 | +#[cfg(feature = "oauth")] | |
| 19 | +pub mod oauth_account; | |
| 20 | +/// Password change request entity requiring email confirmation. | |
| 13 | 21 | pub mod password_change_request; |
| 22 | +/// Password reset token entity. | |
| 14 | 23 | pub mod password_reset_token; |
| 24 | +/// Permission entity for RBAC. | |
| 15 | 25 | pub mod permission; |
| 26 | +/// Personal access token entity (long-lived API credentials). | |
| 27 | +pub mod personal_access_token; | |
| 28 | +/// Refresh token entity for JWT rotation. | |
| 16 | 29 | pub mod refresh_token; |
| 30 | +/// Generic resource registry entity. | |
| 17 | 31 | pub mod resource; |
| 32 | +/// Resource share entity granting access to a principal. | |
| 18 | 33 | pub mod resource_share; |
| 34 | +/// Role entity for RBAC. | |
| 19 | 35 | pub mod role; |
| 36 | +/// Role-permission junction entity. | |
| 20 | 37 | pub mod role_permission; |
| 38 | +/// Team entity for group-based access control. | |
| 39 | +pub mod team; | |
| 40 | +/// Team membership entity linking users to teams. | |
| 41 | +pub mod team_member; | |
| 42 | +/// Ephemeral TOTP challenge entity for the 2FA login flow. | |
| 43 | +pub mod totp_challenge; | |
| 44 | +/// User entity. | |
| 21 | 45 | pub mod user; |
| 46 | +/// User preferences entity (theme, language). | |
| 22 | 47 | pub mod user_preferences; |
| 48 | +/// User-role junction entity. | |
| 23 | 49 | pub mod user_role; |
| 50 | +/// User TOTP secrets entity for two-factor authentication. | |
| 51 | +pub mod user_totp; | |
| 24 | 52 | |
| 25 | 53 | pub use app_settings::Entity as AppSettings; |
| 26 | 54 | pub use audit_log::Entity as AuditLog; |
@@ -28,16 +56,21 @@ pub use email_delivery_status::Entity as EmailDeliveryStatus;
| 28 | 56 | pub use email_queue::Entity as EmailQueue; |
| 29 | 57 | pub use email_validation_token::Entity as EmailValidationToken; |
| 30 | 58 | pub use jwt_blacklist::Entity as JwtBlacklist; |
| 59 | +#[cfg(feature = "oauth")] | |
| 60 | +pub use oauth_account::Entity as OauthAccount; | |
| 31 | 61 | pub use password_change_request::Entity as PasswordChangeRequest; |
| 32 | 62 | pub use password_reset_token::Entity as PasswordResetToken; |
| 33 | 63 | pub use permission::Entity as Permission; |
| 64 | +pub use personal_access_token::Entity as PersonalAccessToken; | |
| 34 | 65 | pub use refresh_token::Entity as RefreshToken; |
| 35 | 66 | pub use resource::Entity as Resource; |
| 36 | 67 | pub use resource_share::Entity as ResourceShare; |
| 37 | 68 | pub use role::Entity as Role; |
| 38 | 69 | pub use role_permission::Entity as RolePermission; |
| 70 | +pub use team::Entity as Team; | |
| 71 | +pub use team_member::Entity as TeamMember; | |
| 72 | +pub use totp_challenge::Entity as TotpChallenge; | |
| 39 | 73 | pub use user::Entity as User; |
| 40 | 74 | pub use user_preferences::Entity as UserPreferences; |
| 41 | 75 | pub use user_role::Entity as UserRole; |
| 42 | -#[cfg(feature = "oauth")] | |
| 43 | -pub use oauth_account::Entity as OauthAccount; | |
| 76 | +pub use user_totp::Entity as UserTotp; |
M
crates/rustwarden-core/src/models/oauth_account.rs
+14
-0
@@ -3,23 +3,37 @@ use sea_orm::entity::prelude::*;
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | use uuid::Uuid; |
| 5 | 5 | |
| 6 | +/// SeaORM model for the `oauth_accounts` table. | |
| 7 | +/// | |
| 8 | +/// Links a Rustwarden user account to an external OAuth/OIDC identity. | |
| 6 | 9 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 7 | 10 | #[sea_orm(table_name = "oauth_accounts")] |
| 8 | 11 | pub struct Model { |
| 12 | + /// Primary key. | |
| 9 | 13 | #[sea_orm(primary_key)] |
| 10 | 14 | pub id: Uuid, |
| 15 | + /// FK → users.id — the local user this OAuth identity belongs to. | |
| 11 | 16 | pub user_id: Uuid, |
| 17 | + /// Provider name (e.g. `"google"`, `"github"`). | |
| 12 | 18 | pub provider: String, |
| 19 | + /// Unique user ID as returned by the OAuth provider. | |
| 13 | 20 | pub provider_user_id: String, |
| 21 | + /// Email address reported by the provider (may be absent for some providers). | |
| 14 | 22 | pub email: Option<String>, |
| 23 | + /// Display name reported by the provider. | |
| 15 | 24 | pub display_name: Option<String>, |
| 25 | + /// URL of the user's avatar image reported by the provider. | |
| 16 | 26 | pub avatar_url: Option<String>, |
| 27 | + /// Timestamp when this OAuth link was created. | |
| 17 | 28 | pub created_at: DateTime<Utc>, |
| 29 | + /// Timestamp of the last update to this OAuth link. | |
| 18 | 30 | pub updated_at: DateTime<Utc>, |
| 19 | 31 | } |
| 20 | 32 | |
| 33 | +/// SeaORM relations for the OAuthAccount entity. | |
| 21 | 34 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 22 | 35 | pub enum Relation { |
| 36 | + /// The local user account this OAuth identity is linked to. | |
| 23 | 37 | #[sea_orm( |
| 24 | 38 | belongs_to = "super::user::Entity", |
| 25 | 39 | from = "Column::UserId", |
M
crates/rustwarden-core/src/models/password_change_request.rs
+1
-0
@@ -60,6 +60,7 @@ pub struct Model {
| 60 | 60 | pub retry_count: i32, |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | +/// SeaORM relations for the PasswordChangeRequest entity. | |
| 63 | 64 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 64 | 65 | pub enum Relation { |
| 65 | 66 | /// Relationship to User entity |
M
crates/rustwarden-core/src/models/password_reset_token.rs
+2
-1
@@ -49,9 +49,10 @@ pub struct Model {
| 49 | 49 | pub created_at: DateTime<Utc>, |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | +/// SeaORM relations for the PasswordResetToken entity. | |
| 52 | 53 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 53 | 54 | pub enum Relation { |
| 54 | - /// Relationship to User entity | |
| 55 | + /// The user this password reset token belongs to. | |
| 55 | 56 | #[sea_orm( |
| 56 | 57 | belongs_to = "super::user::Entity", |
| 57 | 58 | from = "Column::UserId", |
M
crates/rustwarden-core/src/models/permission.rs
+9
-0
@@ -3,19 +3,28 @@ use sea_orm::entity::prelude::*;
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | use uuid::Uuid; |
| 5 | 5 | |
| 6 | +/// SeaORM model for the `permissions` table. | |
| 7 | +/// | |
| 8 | +/// Represents a fine-grained permission that can be assigned to roles. | |
| 6 | 9 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 7 | 10 | #[sea_orm(table_name = "permissions")] |
| 8 | 11 | pub struct Model { |
| 12 | + /// Primary key. | |
| 9 | 13 | #[sea_orm(primary_key)] |
| 10 | 14 | pub id: Uuid, |
| 15 | + /// Unique machine-readable permission name (e.g. `"users:write"`). | |
| 11 | 16 | #[sea_orm(unique)] |
| 12 | 17 | pub name: String, |
| 18 | + /// Optional human-readable description of what this permission grants. | |
| 13 | 19 | #[sea_orm(nullable)] |
| 14 | 20 | pub description: Option<String>, |
| 21 | + /// Grouping category for UI display (e.g. `"users"`, `"admin"`). | |
| 15 | 22 | pub category: String, |
| 23 | + /// Timestamp when this permission was created. | |
| 16 | 24 | pub created_at: DateTime<Utc>, |
| 17 | 25 | } |
| 18 | 26 | |
| 27 | +/// SeaORM relations for the Permission entity (none defined). | |
| 19 | 28 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 20 | 29 | pub enum Relation {} |
| 21 | 30 |
A
crates/rustwarden-core/src/models/personal_access_token.rs
+158
-0
@@ -0,0 +1,158 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// Personal access token (long-lived API credential). | |
| 7 | +/// | |
| 8 | +/// Companion of [`refresh_token`](super::refresh_token) — whereas refresh | |
| 9 | +/// tokens rotate on every use, PATs are static secrets consumed unchanged by | |
| 10 | +/// protocols that cannot handle rotation (git push over HTTPS, webhook ingest, | |
| 11 | +/// third-party integrations). | |
| 12 | +/// | |
| 13 | +/// # Storage | |
| 14 | +/// | |
| 15 | +/// Only the SHA-256 hash of the token is persisted. The plaintext is returned | |
| 16 | +/// once from [`crate::services::personal_token_service::PersonalTokenService::create`] | |
| 17 | +/// and never stored anywhere in the framework. | |
| 18 | +/// | |
| 19 | +/// # Lifecycle | |
| 20 | +/// | |
| 21 | +/// 1. **Creation**: a random 32-byte token is generated, shown once to the user, | |
| 22 | +/// its SHA-256 hash is stored with requested scopes. | |
| 23 | +/// 2. **Usage**: every `verify()` call updates `last_used_at` for UX (list | |
| 24 | +/// tokens, show "last used N hours ago"). | |
| 25 | +/// 3. **Revocation**: `revoked_at` is set; the row is kept for audit. | |
| 26 | +/// 4. **Expiration**: optional `expires_at` — `None` means permanent. | |
| 27 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 28 | +#[sea_orm(table_name = "personal_access_tokens")] | |
| 29 | +pub struct Model { | |
| 30 | + /// Unique identifier for the token record. | |
| 31 | + #[sea_orm(primary_key)] | |
| 32 | + pub id: Uuid, | |
| 33 | + | |
| 34 | + /// Owner of the token. Cascade-deleted with the user. | |
| 35 | + pub user_id: Uuid, | |
| 36 | + | |
| 37 | + /// Human-friendly name chosen by the user (unique per user). | |
| 38 | + pub name: String, | |
| 39 | + | |
| 40 | + /// SHA-256 hex digest of the plaintext token (64 chars). | |
| 41 | + #[sea_orm(unique)] | |
| 42 | + pub token_hash: String, | |
| 43 | + | |
| 44 | + /// Requested scopes (app-specific strings, e.g. `"read:repo"`). | |
| 45 | + pub scopes: Vec<String>, | |
| 46 | + | |
| 47 | + /// Optional expiration. `None` = permanent token. | |
| 48 | + #[sea_orm(nullable)] | |
| 49 | + pub expires_at: Option<DateTime<Utc>>, | |
| 50 | + | |
| 51 | + /// Last time this token was used for authentication (for UX). | |
| 52 | + #[sea_orm(nullable)] | |
| 53 | + pub last_used_at: Option<DateTime<Utc>>, | |
| 54 | + | |
| 55 | + /// When the token was revoked (if applicable). A revoked token is kept | |
| 56 | + /// for audit but refuses authentication. | |
| 57 | + #[sea_orm(nullable)] | |
| 58 | + pub revoked_at: Option<DateTime<Utc>>, | |
| 59 | + | |
| 60 | + /// Creation timestamp. | |
| 61 | + pub created_at: DateTime<Utc>, | |
| 62 | +} | |
| 63 | + | |
| 64 | +/// SeaORM relations for the PersonalAccessToken entity. | |
| 65 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 66 | +pub enum Relation { | |
| 67 | + /// The user who owns this personal access token. | |
| 68 | + #[sea_orm( | |
| 69 | + belongs_to = "super::user::Entity", | |
| 70 | + from = "Column::UserId", | |
| 71 | + to = "super::user::Column::Id", | |
| 72 | + on_delete = "Cascade" | |
| 73 | + )] | |
| 74 | + User, | |
| 75 | +} | |
| 76 | + | |
| 77 | +impl Related<super::user::Entity> for Entity { | |
| 78 | + fn to() -> RelationDef { | |
| 79 | + Relation::User.def() | |
| 80 | + } | |
| 81 | +} | |
| 82 | + | |
| 83 | +impl ActiveModelBehavior for ActiveModel {} | |
| 84 | + | |
| 85 | +impl Model { | |
| 86 | + /// Returns true if the token has a set expiration date in the past. | |
| 87 | + pub fn is_expired(&self) -> bool { | |
| 88 | + self.expires_at.is_some_and(|t| Utc::now() > t) | |
| 89 | + } | |
| 90 | + | |
| 91 | + /// Returns true if the token has been explicitly revoked. | |
| 92 | + pub fn is_revoked(&self) -> bool { | |
| 93 | + self.revoked_at.is_some() | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// Returns true if the token is neither expired nor revoked. | |
| 97 | + pub fn is_active(&self) -> bool { | |
| 98 | + !self.is_expired() && !self.is_revoked() | |
| 99 | + } | |
| 100 | +} | |
| 101 | + | |
| 102 | +#[cfg(test)] | |
| 103 | +mod tests { | |
| 104 | + use super::*; | |
| 105 | + use chrono::Duration; | |
| 106 | + | |
| 107 | + fn base_model() -> Model { | |
| 108 | + Model { | |
| 109 | + id: Uuid::new_v4(), | |
| 110 | + user_id: Uuid::new_v4(), | |
| 111 | + name: "test".into(), | |
| 112 | + token_hash: "a".repeat(64), | |
| 113 | + scopes: vec![], | |
| 114 | + expires_at: None, | |
| 115 | + last_used_at: None, | |
| 116 | + revoked_at: None, | |
| 117 | + created_at: Utc::now(), | |
| 118 | + } | |
| 119 | + } | |
| 120 | + | |
| 121 | + #[test] | |
| 122 | + fn permanent_token_never_expires() { | |
| 123 | + assert!(!base_model().is_expired()); | |
| 124 | + } | |
| 125 | + | |
| 126 | + #[test] | |
| 127 | + fn expires_at_in_past_is_expired() { | |
| 128 | + let m = Model { | |
| 129 | + expires_at: Some(Utc::now() - Duration::hours(1)), | |
| 130 | + ..base_model() | |
| 131 | + }; | |
| 132 | + assert!(m.is_expired()); | |
| 133 | + } | |
| 134 | + | |
| 135 | + #[test] | |
| 136 | + fn expires_at_in_future_is_not_expired() { | |
| 137 | + let m = Model { | |
| 138 | + expires_at: Some(Utc::now() + Duration::hours(1)), | |
| 139 | + ..base_model() | |
| 140 | + }; | |
| 141 | + assert!(!m.is_expired()); | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[test] | |
| 145 | + fn revoked_token_is_inactive() { | |
| 146 | + let m = Model { | |
| 147 | + revoked_at: Some(Utc::now()), | |
| 148 | + ..base_model() | |
| 149 | + }; | |
| 150 | + assert!(m.is_revoked()); | |
| 151 | + assert!(!m.is_active()); | |
| 152 | + } | |
| 153 | + | |
| 154 | + #[test] | |
| 155 | + fn fresh_permanent_token_is_active() { | |
| 156 | + assert!(base_model().is_active()); | |
| 157 | + } | |
| 158 | +} |
M
crates/rustwarden-core/src/models/refresh_token.rs
+2
-1
@@ -55,9 +55,10 @@ pub struct Model {
| 55 | 55 | pub replaced_by: Option<Uuid>, |
| 56 | 56 | } |
| 57 | 57 | |
| 58 | +/// SeaORM relations for the RefreshToken entity. | |
| 58 | 59 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 59 | 60 | pub enum Relation { |
| 60 | - /// Relationship to User entity | |
| 61 | + /// The user this refresh token belongs to. | |
| 61 | 62 | #[sea_orm( |
| 62 | 63 | belongs_to = "super::user::Entity", |
| 63 | 64 | from = "Column::UserId", |
M
crates/rustwarden-core/src/models/resource.rs
+4
-0
@@ -11,6 +11,7 @@ use uuid::Uuid;
| 11 | 11 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 12 | 12 | #[sea_orm(table_name = "resources")] |
| 13 | 13 | pub struct Model { |
| 14 | + /// Primary key. | |
| 14 | 15 | #[sea_orm(primary_key)] |
| 15 | 16 | pub id: Uuid, |
| 16 | 17 | /// Application-defined type key (e.g. "document", "project") |
@@ -21,11 +22,14 @@ pub struct Model {
| 21 | 22 | pub owner_id: Uuid, |
| 22 | 23 | /// Whether the resource is publicly visible to all authenticated users |
| 23 | 24 | pub is_public: bool, |
| 25 | + /// Timestamp when this resource was registered. | |
| 24 | 26 | pub created_at: DateTime<Utc>, |
| 25 | 27 | } |
| 26 | 28 | |
| 29 | +/// SeaORM relations for the Resource entity. | |
| 27 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 28 | 31 | pub enum Relation { |
| 32 | + /// The user who owns this resource. | |
| 29 | 33 | #[sea_orm( |
| 30 | 34 | belongs_to = "super::user::Entity", |
| 31 | 35 | from = "Column::OwnerId", |
M
crates/rustwarden-core/src/models/resource_share.rs
+25
-3
@@ -3,25 +3,38 @@ use sea_orm::entity::prelude::*;
| 3 | 3 | use serde::{Deserialize, Serialize}; |
| 4 | 4 | use uuid::Uuid; |
| 5 | 5 | |
| 6 | -/// A share entry granting a user access to a resource with a specific permission level. | |
| 6 | +/// A share entry granting access to a resource for a given principal | |
| 7 | +/// (a user OR a team) with a specific permission level. | |
| 8 | +/// | |
| 9 | +/// Exactly one of `shared_with_user_id` and `shared_with_team_id` is set — | |
| 10 | +/// enforced by the `chk_resource_shares_principal` CHECK constraint at the | |
| 11 | +/// DB level. | |
| 7 | 12 | #[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] |
| 8 | 13 | #[sea_orm(table_name = "resource_shares")] |
| 9 | 14 | pub struct Model { |
| 15 | + /// Primary key. | |
| 10 | 16 | #[sea_orm(primary_key)] |
| 11 | 17 | pub id: Uuid, |
| 12 | 18 | /// FK → resources.id |
| 13 | 19 | pub resource_id: Uuid, |
| 14 | - /// The user the resource is shared with | |
| 15 | - pub shared_with_user_id: Uuid, | |
| 20 | + /// The user the resource is shared with (`Some` for user-principal shares). | |
| 21 | + #[sea_orm(nullable)] | |
| 22 | + pub shared_with_user_id: Option<Uuid>, | |
| 23 | + /// The team the resource is shared with (`Some` for team-principal shares). | |
| 24 | + #[sea_orm(nullable)] | |
| 25 | + pub shared_with_team_id: Option<Uuid>, | |
| 16 | 26 | /// Permission level: "read", "write", or "admin" |
| 17 | 27 | pub permission_level: String, |
| 18 | 28 | /// The user who created this share |
| 19 | 29 | pub shared_by_user_id: Uuid, |
| 30 | + /// Timestamp when this share was created. | |
| 20 | 31 | pub created_at: DateTime<Utc>, |
| 21 | 32 | } |
| 22 | 33 | |
| 34 | +/// SeaORM relations for the ResourceShare entity. | |
| 23 | 35 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 24 | 36 | pub enum Relation { |
| 37 | + /// The resource being shared. | |
| 25 | 38 | #[sea_orm( |
| 26 | 39 | belongs_to = "super::resource::Entity", |
| 27 | 40 | from = "Column::ResourceId", |
@@ -29,12 +42,21 @@ pub enum Relation {
| 29 | 42 | on_delete = "Cascade" |
| 30 | 43 | )] |
| 31 | 44 | Resource, |
| 45 | + /// The user the resource is shared with (user-principal shares). | |
| 32 | 46 | #[sea_orm( |
| 33 | 47 | belongs_to = "super::user::Entity", |
| 34 | 48 | from = "Column::SharedWithUserId", |
| 35 | 49 | to = "super::user::Column::Id" |
| 36 | 50 | )] |
| 37 | 51 | SharedWithUser, |
| 52 | + /// The team the resource is shared with (team-principal shares). | |
| 53 | + #[sea_orm( | |
| 54 | + belongs_to = "super::team::Entity", | |
| 55 | + from = "Column::SharedWithTeamId", | |
| 56 | + to = "super::team::Column::Id" | |
| 57 | + )] | |
| 58 | + SharedWithTeam, | |
| 59 | + /// The user who created the share. | |
| 38 | 60 | #[sea_orm( |
| 39 | 61 | belongs_to = "super::user::Entity", |
| 40 | 62 | from = "Column::SharedByUserId", |
M
crates/rustwarden-core/src/models/role.rs
+1
-0
@@ -26,6 +26,7 @@ pub struct Model {
| 26 | 26 | pub updated_at: DateTime<Utc>, |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | +/// SeaORM relations for the Role entity (none defined). | |
| 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 30 | 31 | pub enum Relation {} |
| 31 | 32 |
M
crates/rustwarden-core/src/models/role_permission.rs
+8
-0
@@ -1,17 +1,24 @@
| 1 | 1 | use sea_orm::entity::prelude::*; |
| 2 | 2 | use uuid::Uuid; |
| 3 | 3 | |
| 4 | +/// SeaORM model for the `role_permissions` junction table. | |
| 5 | +/// | |
| 6 | +/// Associates roles with permissions using a composite primary key. | |
| 4 | 7 | #[derive(Clone, Debug, DeriveEntityModel)] |
| 5 | 8 | #[sea_orm(table_name = "role_permissions")] |
| 6 | 9 | pub struct Model { |
| 10 | + /// FK → roles.id (part of composite primary key). | |
| 7 | 11 | #[sea_orm(primary_key)] |
| 8 | 12 | pub role_id: Uuid, |
| 13 | + /// FK → permissions.id (part of composite primary key). | |
| 9 | 14 | #[sea_orm(primary_key)] |
| 10 | 15 | pub permission_id: Uuid, |
| 11 | 16 | } |
| 12 | 17 | |
| 18 | +/// SeaORM relations for the RolePermission junction entity. | |
| 13 | 19 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 14 | 20 | pub enum Relation { |
| 21 | + /// The role side of the junction. | |
| 15 | 22 | #[sea_orm( |
| 16 | 23 | belongs_to = "super::role::Entity", |
| 17 | 24 | from = "Column::RoleId", |
@@ -19,6 +26,7 @@ pub enum Relation {
| 19 | 26 | on_delete = "Cascade" |
| 20 | 27 | )] |
| 21 | 28 | Role, |
| 29 | + /// The permission side of the junction. | |
| 22 | 30 | #[sea_orm( |
| 23 | 31 | belongs_to = "super::permission::Entity", |
| 24 | 32 | from = "Column::PermissionId", |
A
crates/rustwarden-core/src/models/team.rs
+45
-0
@@ -0,0 +1,45 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// A named group of users. Backs [`TeamService`](crate::services::team_service::TeamService) | |
| 7 | +/// and is an accepted principal for [`ResourceService`](crate::services::resource_service::ResourceService) | |
| 8 | +/// sharing. | |
| 9 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 10 | +#[sea_orm(table_name = "teams")] | |
| 11 | +pub struct Model { | |
| 12 | + /// Primary key. | |
| 13 | + #[sea_orm(primary_key)] | |
| 14 | + pub id: Uuid, | |
| 15 | + /// URL-safe handle (`@backend`, `design-ops`, …). Unique across the tenant. | |
| 16 | + #[sea_orm(unique)] | |
| 17 | + pub slug: String, | |
| 18 | + /// Human-readable label. | |
| 19 | + pub name: String, | |
| 20 | + /// User who created the team and retains admin rights by default. | |
| 21 | + pub owner_id: Uuid, | |
| 22 | + /// Timestamp when the team was created. | |
| 23 | + pub created_at: DateTime<Utc>, | |
| 24 | +} | |
| 25 | + | |
| 26 | +/// SeaORM relations for the Team entity. | |
| 27 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 28 | +pub enum Relation { | |
| 29 | + /// The user who owns (created) this team. | |
| 30 | + #[sea_orm( | |
| 31 | + belongs_to = "super::user::Entity", | |
| 32 | + from = "Column::OwnerId", | |
| 33 | + to = "super::user::Column::Id", | |
| 34 | + on_delete = "Cascade" | |
| 35 | + )] | |
| 36 | + Owner, | |
| 37 | +} | |
| 38 | + | |
| 39 | +impl Related<super::user::Entity> for Entity { | |
| 40 | + fn to() -> RelationDef { | |
| 41 | + Relation::Owner.def() | |
| 42 | + } | |
| 43 | +} | |
| 44 | + | |
| 45 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-core/src/models/team_member.rs
+56
-0
@@ -0,0 +1,56 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// Membership edge between a user and a team. Composite primary key | |
| 7 | +/// `(team_id, user_id)` prevents duplicates at the schema level. | |
| 8 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 9 | +#[sea_orm(table_name = "team_members")] | |
| 10 | +pub struct Model { | |
| 11 | + /// FK → teams.id (part of composite primary key). | |
| 12 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 13 | + pub team_id: Uuid, | |
| 14 | + /// FK → users.id (part of composite primary key). | |
| 15 | + #[sea_orm(primary_key, auto_increment = false)] | |
| 16 | + pub user_id: Uuid, | |
| 17 | + /// `"owner" | "admin" | "member"` (validated at the service layer). | |
| 18 | + pub role: String, | |
| 19 | + /// Timestamp when the user joined (or was added to) the team. | |
| 20 | + pub joined_at: DateTime<Utc>, | |
| 21 | +} | |
| 22 | + | |
| 23 | +/// SeaORM relations for the TeamMember entity. | |
| 24 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 25 | +pub enum Relation { | |
| 26 | + /// The team this membership belongs to. | |
| 27 | + #[sea_orm( | |
| 28 | + belongs_to = "super::team::Entity", | |
| 29 | + from = "Column::TeamId", | |
| 30 | + to = "super::team::Column::Id", | |
| 31 | + on_delete = "Cascade" | |
| 32 | + )] | |
| 33 | + Team, | |
| 34 | + /// The user who is a member of the team. | |
| 35 | + #[sea_orm( | |
| 36 | + belongs_to = "super::user::Entity", | |
| 37 | + from = "Column::UserId", | |
| 38 | + to = "super::user::Column::Id", | |
| 39 | + on_delete = "Cascade" | |
| 40 | + )] | |
| 41 | + User, | |
| 42 | +} | |
| 43 | + | |
| 44 | +impl Related<super::team::Entity> for Entity { | |
| 45 | + fn to() -> RelationDef { | |
| 46 | + Relation::Team.def() | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +impl Related<super::user::Entity> for Entity { | |
| 51 | + fn to() -> RelationDef { | |
| 52 | + Relation::User.def() | |
| 53 | + } | |
| 54 | +} | |
| 55 | + | |
| 56 | +impl ActiveModelBehavior for ActiveModel {} |
A
crates/rustwarden-core/src/models/totp_challenge.rs
+77
-0
@@ -0,0 +1,77 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// TotpChallenge entity model | |
| 7 | +/// | |
| 8 | +/// Ephemeral challenge for the 2FA login flow. | |
| 9 | +/// Created after successful password verification when user has TOTP enabled. | |
| 10 | +/// Expires after 5 minutes. Max 5 attempts per challenge. | |
| 11 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 12 | +#[sea_orm(table_name = "totp_challenges")] | |
| 13 | +pub struct Model { | |
| 14 | + /// Unique identifier | |
| 15 | + #[sea_orm(primary_key)] | |
| 16 | + pub id: Uuid, | |
| 17 | + /// User ID | |
| 18 | + pub user_id: Uuid, | |
| 19 | + /// Random challenge token (sent to client, used to identify the challenge) | |
| 20 | + #[sea_orm(unique)] | |
| 21 | + pub challenge_token: String, | |
| 22 | + /// Number of verification attempts | |
| 23 | + pub attempts: i32, | |
| 24 | + /// Maximum allowed attempts | |
| 25 | + pub max_attempts: i32, | |
| 26 | + /// Challenge expiration time | |
| 27 | + pub expires_at: DateTime<Utc>, | |
| 28 | + /// Creation timestamp | |
| 29 | + pub created_at: DateTime<Utc>, | |
| 30 | +} | |
| 31 | + | |
| 32 | +/// SeaORM relations for the TotpChallenge entity. | |
| 33 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 34 | +pub enum Relation { | |
| 35 | + /// The user this challenge was issued for. | |
| 36 | + #[sea_orm( | |
| 37 | + belongs_to = "super::user::Entity", | |
| 38 | + from = "Column::UserId", | |
| 39 | + to = "super::user::Column::Id" | |
| 40 | + )] | |
| 41 | + User, | |
| 42 | +} | |
| 43 | + | |
| 44 | +impl Related<super::user::Entity> for Entity { | |
| 45 | + fn to() -> RelationDef { | |
| 46 | + Relation::User.def() | |
| 47 | + } | |
| 48 | +} | |
| 49 | + | |
| 50 | +impl ActiveModelBehavior for ActiveModel {} | |
| 51 | + | |
| 52 | +#[cfg(test)] | |
| 53 | +mod tests { | |
| 54 | + use super::*; | |
| 55 | + | |
| 56 | + #[test] | |
| 57 | + fn test_totp_challenge_model() { | |
| 58 | + let id = Uuid::new_v4(); | |
| 59 | + let user_id = Uuid::new_v4(); | |
| 60 | + let now = Utc::now(); | |
| 61 | + | |
| 62 | + let challenge = Model { | |
| 63 | + id, | |
| 64 | + user_id, | |
| 65 | + challenge_token: "test-token".to_string(), | |
| 66 | + attempts: 0, | |
| 67 | + max_attempts: 5, | |
| 68 | + expires_at: now + chrono::Duration::minutes(5), | |
| 69 | + created_at: now, | |
| 70 | + }; | |
| 71 | + | |
| 72 | + assert_eq!(challenge.id, id); | |
| 73 | + assert_eq!(challenge.attempts, 0); | |
| 74 | + assert_eq!(challenge.max_attempts, 5); | |
| 75 | + assert!(challenge.expires_at > now); | |
| 76 | + } | |
| 77 | +} |
M
crates/rustwarden-core/src/models/user.rs
+1
-0
@@ -39,6 +39,7 @@ pub struct Model {
| 39 | 39 | pub updated_at: DateTime<Utc>, |
| 40 | 40 | } |
| 41 | 41 | |
| 42 | +/// SeaORM relations for the User entity (none defined here; relations are declared on child entities). | |
| 42 | 43 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 43 | 44 | pub enum Relation {} |
| 44 | 45 |
M
crates/rustwarden-core/src/models/user_preferences.rs
+2
-0
@@ -26,8 +26,10 @@ pub struct Model {
| 26 | 26 | pub updated_at: DateTime<Utc>, |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | +/// SeaORM relations for the UserPreferences entity. | |
| 29 | 30 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 30 | 31 | pub enum Relation { |
| 32 | + /// The user these preferences belong to. | |
| 31 | 33 | #[sea_orm( |
| 32 | 34 | belongs_to = "super::user::Entity", |
| 33 | 35 | from = "Column::UserId", |
M
crates/rustwarden-core/src/models/user_role.rs
+3
-0
@@ -19,8 +19,10 @@ pub struct Model {
| 19 | 19 | pub created_at: DateTime<Utc>, |
| 20 | 20 | } |
| 21 | 21 | |
| 22 | +/// SeaORM relations for the UserRole junction entity. | |
| 22 | 23 | #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] |
| 23 | 24 | pub enum Relation { |
| 25 | + /// The user side of the user-role junction. | |
| 24 | 26 | #[sea_orm( |
| 25 | 27 | belongs_to = "super::user::Entity", |
| 26 | 28 | from = "Column::UserId", |
@@ -28,6 +30,7 @@ pub enum Relation {
| 28 | 30 | on_delete = "Cascade" |
| 29 | 31 | )] |
| 30 | 32 | User, |
| 33 | + /// The role side of the user-role junction. | |
| 31 | 34 | #[sea_orm( |
| 32 | 35 | belongs_to = "super::role::Entity", |
| 33 | 36 | from = "Column::RoleId", |
A
crates/rustwarden-core/src/models/user_totp.rs
+83
-0
@@ -0,0 +1,83 @@
| 1 | +use chrono::{DateTime, Utc}; | |
| 2 | +use sea_orm::entity::prelude::*; | |
| 3 | +use serde::{Deserialize, Serialize}; | |
| 4 | +use uuid::Uuid; | |
| 5 | + | |
| 6 | +/// UserTotp entity model | |
| 7 | +/// | |
| 8 | +/// Stores TOTP two-factor authentication data for a user. | |
| 9 | +/// One-to-one relationship with User (enforced by unique constraint on user_id). | |
| 10 | +/// The secret and backup codes are encrypted at rest via CryptoService (AES-256-GCM). | |
| 11 | +#[derive(Clone, Debug, Serialize, Deserialize, DeriveEntityModel)] | |
| 12 | +#[sea_orm(table_name = "user_totp")] | |
| 13 | +pub struct Model { | |
| 14 | + /// Unique identifier | |
| 15 | + #[sea_orm(primary_key)] | |
| 16 | + pub id: Uuid, | |
| 17 | + /// User ID (unique, one-to-one with users) | |
| 18 | + #[sea_orm(unique)] | |
| 19 | + pub user_id: Uuid, | |
| 20 | + /// TOTP secret encrypted with AES-256-GCM (enc: prefixed) | |
| 21 | + pub secret_encrypted: String, | |
| 22 | + /// Whether 2FA is fully enabled (true after first code verification) | |
| 23 | + pub enabled: bool, | |
| 24 | + /// Backup codes: JSON array of bcrypt-hashed codes, AES-encrypted | |
| 25 | + pub backup_codes_encrypted: Option<String>, | |
| 26 | + /// Number of remaining unused backup codes | |
| 27 | + pub backup_codes_remaining: i32, | |
| 28 | + /// Timestamp when 2FA was enabled | |
| 29 | + pub enabled_at: Option<DateTime<Utc>>, | |
| 30 | + /// Creation timestamp | |
| 31 | + pub created_at: DateTime<Utc>, | |
| 32 | + /// Last update timestamp | |
| 33 | + pub updated_at: DateTime<Utc>, | |
| 34 | +} | |
| 35 | + | |
| 36 | +/// SeaORM relations for the UserTotp entity. | |
| 37 | +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] | |
| 38 | +pub enum Relation { | |
| 39 | + /// The user these TOTP credentials belong to. | |
| 40 | + #[sea_orm( | |
| 41 | + belongs_to = "super::user::Entity", | |
| 42 | + from = "Column::UserId", | |
| 43 | + to = "super::user::Column::Id" | |
| 44 | + )] | |
| 45 | + User, | |
| 46 | +} | |
| 47 | + | |
| 48 | +impl Related<super::user::Entity> for Entity { | |
| 49 | + fn to() -> RelationDef { | |
| 50 | + Relation::User.def() | |
| 51 | + } | |
| 52 | +} | |
| 53 | + | |
| 54 | +impl ActiveModelBehavior for ActiveModel {} | |
| 55 | + | |
| 56 | +#[cfg(test)] | |
| 57 | +mod tests { | |
| 58 | + use super::*; | |
| 59 | + | |
| 60 | + #[test] | |
| 61 | + fn test_user_totp_model() { | |
| 62 | + let id = Uuid::new_v4(); | |
| 63 | + let user_id = Uuid::new_v4(); | |
| 64 | + let now = Utc::now(); | |
| 65 | + | |
| 66 | + let totp = Model { | |
| 67 | + id, | |
| 68 | + user_id, | |
| 69 | + secret_encrypted: "enc:test".to_string(), | |
| 70 | + enabled: false, | |
| 71 | + backup_codes_encrypted: None, | |
| 72 | + backup_codes_remaining: 0, | |
| 73 | + enabled_at: None, | |
| 74 | + created_at: now, | |
| 75 | + updated_at: now, | |
| 76 | + }; | |
| 77 | + | |
| 78 | + assert_eq!(totp.id, id); | |
| 79 | + assert_eq!(totp.user_id, user_id); | |
| 80 | + assert!(!totp.enabled); | |
| 81 | + assert_eq!(totp.backup_codes_remaining, 0); | |
| 82 | + } | |
| 83 | +} |
A
crates/rustwarden-core/src/notifications/mod.rs
+242
-0
@@ -0,0 +1,242 @@
| 1 | +//! Generic pub/sub bus for real-time notifications. | |
| 2 | +//! | |
| 3 | +//! Central plumbing for features that need to push events to subscribed | |
| 4 | +//! consumers — Server-Sent Events streams, live progress bars, CI logs, | |
| 5 | +//! wall-activity feeds, etc. Each consumer app picks an event type `T` and | |
| 6 | +//! gets `publish` + filtered `subscribe` on top of a single in-memory | |
| 7 | +//! [`tokio::sync::broadcast`] channel. | |
| 8 | +//! | |
| 9 | +//! # Multi-instance deployments | |
| 10 | +//! | |
| 11 | +//! The built-in [`InMemoryBus`] is single-process only. For multi-instance | |
| 12 | +//! (horizontal scaling) the trait is stable enough to add a `redis-bus` | |
| 13 | +//! feature later with the same signatures — out of scope for v1. | |
| 14 | +//! | |
| 15 | +//! # Example | |
| 16 | +//! | |
| 17 | +//! ```no_run | |
| 18 | +//! use std::sync::Arc; | |
| 19 | +//! use rustwarden_core::notifications::{InMemoryBus, NotificationBus}; | |
| 20 | +//! | |
| 21 | +//! #[derive(Clone, serde::Serialize)] | |
| 22 | +//! enum AppEvent { | |
| 23 | +//! ImportProgress { job_id: uuid::Uuid, pct: u8 }, | |
| 24 | +//! Ping, | |
| 25 | +//! } | |
| 26 | +//! | |
| 27 | +//! # async fn usage() { | |
| 28 | +//! let bus: Arc<dyn NotificationBus<AppEvent>> = Arc::new(InMemoryBus::<AppEvent>::new(128)); | |
| 29 | +//! | |
| 30 | +//! // Consumer: only "ImportProgress" events. | |
| 31 | +//! let filter = Arc::new(|e: &AppEvent| matches!(e, AppEvent::ImportProgress { .. })); | |
| 32 | +//! let _stream = bus.subscribe(filter); | |
| 33 | +//! | |
| 34 | +//! bus.publish(AppEvent::Ping).await; | |
| 35 | +//! # } | |
| 36 | +//! ``` | |
| 37 | + | |
| 38 | +pub mod sse; | |
| 39 | + | |
| 40 | +use async_trait::async_trait; | |
| 41 | +use futures::stream::{Stream, StreamExt}; | |
| 42 | +use std::pin::Pin; | |
| 43 | +use std::sync::Arc; | |
| 44 | +use tokio::sync::broadcast; | |
| 45 | +use tokio_stream::wrappers::BroadcastStream; | |
| 46 | + | |
| 47 | +/// Filter predicate applied to each published event before delivery to a | |
| 48 | +/// subscriber. `Arc<dyn Fn>` so subscribers can share the filter without | |
| 49 | +/// boxing it again and so it is trivially clonable. | |
| 50 | +pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>; | |
| 51 | + | |
| 52 | +/// Abstract publish/subscribe bus for cloneable event types. | |
| 53 | +/// | |
| 54 | +/// Consumers hold an `Arc<dyn NotificationBus<T>>` so the concrete backend | |
| 55 | +/// (in-memory today, Redis tomorrow) is swappable without touching call sites. | |
| 56 | +#[async_trait] | |
| 57 | +pub trait NotificationBus<T>: Send + Sync | |
| 58 | +where | |
| 59 | + T: Clone + Send + Sync + 'static, | |
| 60 | +{ | |
| 61 | + /// Broadcast an event to every live subscriber. Drops the event silently | |
| 62 | + /// when no subscriber is registered — the bus does not persist. | |
| 63 | + async fn publish(&self, event: T); | |
| 64 | + | |
| 65 | + /// Subscribe with a filter predicate. Only events for which `filter` | |
| 66 | + /// returns `true` reach the returned stream. | |
| 67 | + /// | |
| 68 | + /// A subscriber that falls behind the channel capacity drops the oldest | |
| 69 | + /// missed events (standard `tokio::sync::broadcast` semantics); the | |
| 70 | + /// stream keeps running. | |
| 71 | + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 72 | +} | |
| 73 | + | |
| 74 | +/// In-memory broadcast-backed implementation. Cheap, lock-free on the hot | |
| 75 | +/// path, suitable for single-process deployments. | |
| 76 | +pub struct InMemoryBus<T: Clone + Send + Sync + 'static> { | |
| 77 | + tx: broadcast::Sender<T>, | |
| 78 | +} | |
| 79 | + | |
| 80 | +impl<T: Clone + Send + Sync + 'static> InMemoryBus<T> { | |
| 81 | + /// Build a bus with the given channel capacity. | |
| 82 | + /// | |
| 83 | + /// When more than `capacity` events pile up for a slow subscriber, the | |
| 84 | + /// oldest are dropped — the stream yields nothing for them but does not | |
| 85 | + /// terminate. Pick capacity proportional to peak burst rate × worst-case | |
| 86 | + /// subscriber latency. | |
| 87 | + pub fn new(capacity: usize) -> Self { | |
| 88 | + let (tx, _) = broadcast::channel(capacity); | |
| 89 | + Self { tx } | |
| 90 | + } | |
| 91 | + | |
| 92 | + /// Current number of live subscribers. Useful for metrics / tests. | |
| 93 | + pub fn receiver_count(&self) -> usize { | |
| 94 | + self.tx.receiver_count() | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +impl<T: Clone + Send + Sync + 'static> Default for InMemoryBus<T> { | |
| 99 | + fn default() -> Self { | |
| 100 | + Self::new(256) | |
| 101 | + } | |
| 102 | +} | |
| 103 | + | |
| 104 | +#[async_trait] | |
| 105 | +impl<T> NotificationBus<T> for InMemoryBus<T> | |
| 106 | +where | |
| 107 | + T: Clone + Send + Sync + 'static, | |
| 108 | +{ | |
| 109 | + async fn publish(&self, event: T) { | |
| 110 | + // `send` returns Err only when there are zero receivers — that's fine | |
| 111 | + // for a transient bus (no persistence guarantee). | |
| 112 | + let _ = self.tx.send(event); | |
| 113 | + } | |
| 114 | + | |
| 115 | + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>> { | |
| 116 | + let rx = self.tx.subscribe(); | |
| 117 | + let stream = BroadcastStream::new(rx) | |
| 118 | + // Drop Lagged events (slow consumer) instead of ending the stream. | |
| 119 | + .filter_map(|res| async move { res.ok() }) | |
| 120 | + .filter(move |event| { | |
| 121 | + let keep = filter(event); | |
| 122 | + async move { keep } | |
| 123 | + }); | |
| 124 | + Box::pin(stream) | |
| 125 | + } | |
| 126 | +} | |
| 127 | + | |
| 128 | +#[cfg(test)] | |
| 129 | +mod tests { | |
| 130 | + use super::*; | |
| 131 | + use std::time::Duration; | |
| 132 | + use tokio::time::timeout; | |
| 133 | + | |
| 134 | + #[derive(Clone, Debug, PartialEq, Eq)] | |
| 135 | + enum TestEvent { | |
| 136 | + Ping, | |
| 137 | + Import { job: u32, pct: u8 }, | |
| 138 | + } | |
| 139 | + | |
| 140 | + fn pass_all<T>() -> EventFilter<T> { | |
| 141 | + Arc::new(|_| true) | |
| 142 | + } | |
| 143 | + | |
| 144 | + #[tokio::test] | |
| 145 | + async fn publish_with_no_subscribers_is_noop() { | |
| 146 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4); | |
| 147 | + bus.publish(TestEvent::Ping).await; | |
| 148 | + assert_eq!(bus.receiver_count(), 0); | |
| 149 | + } | |
| 150 | + | |
| 151 | + #[tokio::test] | |
| 152 | + async fn subscriber_receives_published_events() { | |
| 153 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4); | |
| 154 | + let mut stream = bus.subscribe(pass_all()); | |
| 155 | + | |
| 156 | + bus.publish(TestEvent::Ping).await; | |
| 157 | + bus.publish(TestEvent::Import { job: 1, pct: 50 }).await; | |
| 158 | + | |
| 159 | + let first = timeout(Duration::from_millis(200), stream.next()) | |
| 160 | + .await | |
| 161 | + .unwrap() | |
| 162 | + .unwrap(); | |
| 163 | + let second = timeout(Duration::from_millis(200), stream.next()) | |
| 164 | + .await | |
| 165 | + .unwrap() | |
| 166 | + .unwrap(); | |
| 167 | + | |
| 168 | + assert_eq!(first, TestEvent::Ping); | |
| 169 | + assert_eq!(second, TestEvent::Import { job: 1, pct: 50 }); | |
| 170 | + } | |
| 171 | + | |
| 172 | + #[tokio::test] | |
| 173 | + async fn multiple_subscribers_each_receive_the_event() { | |
| 174 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4); | |
| 175 | + let mut a = bus.subscribe(pass_all()); | |
| 176 | + let mut b = bus.subscribe(pass_all()); | |
| 177 | + | |
| 178 | + bus.publish(TestEvent::Ping).await; | |
| 179 | + | |
| 180 | + let a_got = timeout(Duration::from_millis(200), a.next()) | |
| 181 | + .await | |
| 182 | + .unwrap() | |
| 183 | + .unwrap(); | |
| 184 | + let b_got = timeout(Duration::from_millis(200), b.next()) | |
| 185 | + .await | |
| 186 | + .unwrap() | |
| 187 | + .unwrap(); | |
| 188 | + assert_eq!(a_got, TestEvent::Ping); | |
| 189 | + assert_eq!(b_got, TestEvent::Ping); | |
| 190 | + } | |
| 191 | + | |
| 192 | + #[tokio::test] | |
| 193 | + async fn filter_drops_non_matching_events() { | |
| 194 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4); | |
| 195 | + let only_imports: EventFilter<TestEvent> = | |
| 196 | + Arc::new(|e| matches!(e, TestEvent::Import { .. })); | |
| 197 | + let mut stream = bus.subscribe(only_imports); | |
| 198 | + | |
| 199 | + bus.publish(TestEvent::Ping).await; | |
| 200 | + bus.publish(TestEvent::Import { job: 7, pct: 10 }).await; | |
| 201 | + | |
| 202 | + let got = timeout(Duration::from_millis(200), stream.next()) | |
| 203 | + .await | |
| 204 | + .unwrap() | |
| 205 | + .unwrap(); | |
| 206 | + assert_eq!(got, TestEvent::Import { job: 7, pct: 10 }); | |
| 207 | + } | |
| 208 | + | |
| 209 | + #[tokio::test] | |
| 210 | + async fn subscriber_drop_does_not_block_publisher() { | |
| 211 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(4); | |
| 212 | + { | |
| 213 | + let _stream = bus.subscribe(pass_all()); | |
| 214 | + // _stream drops at end of scope. | |
| 215 | + } | |
| 216 | + // Publisher must not hang or panic after the receiver is gone. | |
| 217 | + bus.publish(TestEvent::Ping).await; | |
| 218 | + assert_eq!(bus.receiver_count(), 0); | |
| 219 | + } | |
| 220 | + | |
| 221 | + #[tokio::test] | |
| 222 | + async fn slow_subscriber_loses_old_events_but_stream_keeps_going() { | |
| 223 | + // Capacity 2 → third event evicts the first for a stalled consumer. | |
| 224 | + let bus: InMemoryBus<TestEvent> = InMemoryBus::new(2); | |
| 225 | + let mut stream = bus.subscribe(pass_all()); | |
| 226 | + | |
| 227 | + for pct in [10, 20, 30, 40] { | |
| 228 | + bus.publish(TestEvent::Import { job: 1, pct }).await; | |
| 229 | + } | |
| 230 | + | |
| 231 | + // We should still eventually get a tail slice of events (exact number | |
| 232 | + // depends on runtime scheduling, but it must be ≥ 1). | |
| 233 | + let first = timeout(Duration::from_millis(200), stream.next()) | |
| 234 | + .await | |
| 235 | + .unwrap() | |
| 236 | + .unwrap(); | |
| 237 | + match first { | |
| 238 | + TestEvent::Import { .. } => {} | |
| 239 | + other => panic!("expected Import event, got {other:?}"), | |
| 240 | + } | |
| 241 | + } | |
| 242 | +} |
A
crates/rustwarden-core/src/notifications/sse.rs
+114
-0
@@ -0,0 +1,114 @@
| 1 | +//! Helper to turn a [`NotificationBus`](super::NotificationBus) subscription | |
| 2 | +//! into an HTTP Server-Sent Events response. | |
| 3 | +//! | |
| 4 | +//! Wraps the usual boilerplate (JSON-serialising each event, attaching a | |
| 5 | +//! keep-alive, converting errors into client-friendly ends of stream) in a | |
| 6 | +//! single call so handlers stay trivial: | |
| 7 | +//! | |
| 8 | +//! ```no_run | |
| 9 | +//! use axum::response::sse::Sse; | |
| 10 | +//! use axum::response::IntoResponse; | |
| 11 | +//! use std::sync::Arc; | |
| 12 | +//! use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response}; | |
| 13 | +//! | |
| 14 | +//! #[derive(Clone, serde::Serialize)] | |
| 15 | +//! struct MyEvent { msg: String } | |
| 16 | +//! | |
| 17 | +//! async fn stream_handler( | |
| 18 | +//! bus: axum::extract::Extension<Arc<dyn NotificationBus<MyEvent>>>, | |
| 19 | +//! ) -> impl IntoResponse { | |
| 20 | +//! let filter = Arc::new(|_: &MyEvent| true); | |
| 21 | +//! sse_response(bus.subscribe(filter)) | |
| 22 | +//! } | |
| 23 | +//! ``` | |
| 24 | + | |
| 25 | +use axum::response::sse::{Event, KeepAlive, Sse}; | |
| 26 | +use futures::stream::{Stream, StreamExt}; | |
| 27 | +use serde::Serialize; | |
| 28 | +use std::convert::Infallible; | |
| 29 | +use std::time::Duration; | |
| 30 | + | |
| 31 | +/// Default keep-alive interval (30 s) — matches most reverse-proxy idle timeouts. | |
| 32 | +pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30); | |
| 33 | + | |
| 34 | +/// Wrap a stream of serializable events into an axum [`Sse`] response with | |
| 35 | +/// JSON payloads and a 30-second keep-alive. | |
| 36 | +/// | |
| 37 | +/// Serialization errors for individual events are logged and the event is | |
| 38 | +/// dropped from the output stream; the connection stays open for subsequent | |
| 39 | +/// events. | |
| 40 | +pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>> | |
| 41 | +where | |
| 42 | + T: Serialize + Send + 'static, | |
| 43 | + S: Stream<Item = T> + Send + 'static, | |
| 44 | +{ | |
| 45 | + let events = stream.filter_map(|event| async move { | |
| 46 | + match Event::default().json_data(&event) { | |
| 47 | + Ok(e) => Some(Ok::<_, Infallible>(e)), | |
| 48 | + Err(err) => { | |
| 49 | + tracing::warn!("Dropping SSE event that failed to serialize: {}", err); | |
| 50 | + None | |
| 51 | + } | |
| 52 | + } | |
| 53 | + }); | |
| 54 | + Sse::new(events).keep_alive(KeepAlive::new().interval(DEFAULT_SSE_KEEPALIVE)) | |
| 55 | +} | |
| 56 | + | |
| 57 | +#[cfg(test)] | |
| 58 | +mod tests { | |
| 59 | + use super::*; | |
| 60 | + use crate::notifications::{EventFilter, InMemoryBus, NotificationBus}; | |
| 61 | + use axum::body::to_bytes; | |
| 62 | + use axum::response::IntoResponse; | |
| 63 | + use serde::{Deserialize, Serialize}; | |
| 64 | + use std::sync::Arc; | |
| 65 | + | |
| 66 | + #[derive(Clone, Serialize, Deserialize, Debug, PartialEq)] | |
| 67 | + struct Event { | |
| 68 | + tag: String, | |
| 69 | + count: u32, | |
| 70 | + } | |
| 71 | + | |
| 72 | + fn pass_all<T>() -> EventFilter<T> { | |
| 73 | + Arc::new(|_| true) | |
| 74 | + } | |
| 75 | + | |
| 76 | + #[tokio::test] | |
| 77 | + async fn sse_response_emits_serialized_events_then_keepalive_stops_stream() { | |
| 78 | + let bus: InMemoryBus<Event> = InMemoryBus::new(4); | |
| 79 | + let stream = bus.subscribe(pass_all()); | |
| 80 | + | |
| 81 | + bus.publish(Event { | |
| 82 | + tag: "a".into(), | |
| 83 | + count: 1, | |
| 84 | + }) | |
| 85 | + .await; | |
| 86 | + bus.publish(Event { | |
| 87 | + tag: "b".into(), | |
| 88 | + count: 2, | |
| 89 | + }) | |
| 90 | + .await; | |
| 91 | + | |
| 92 | + // Close the bus so the subscription stream terminates naturally. | |
| 93 | + drop(bus); | |
| 94 | + | |
| 95 | + let response = sse_response(stream).into_response(); | |
| 96 | + let status = response.status(); | |
| 97 | + assert!(status.is_success(), "expected 2xx, got {status}"); | |
| 98 | + | |
| 99 | + // Read the whole body — bounded because the stream terminates once | |
| 100 | + // the bus is dropped. | |
| 101 | + let body = response.into_body(); | |
| 102 | + let bytes = to_bytes(body, 64 * 1024).await.unwrap(); | |
| 103 | + let text = std::str::from_utf8(&bytes).unwrap(); | |
| 104 | + | |
| 105 | + assert!( | |
| 106 | + text.contains(r#""tag":"a""#), | |
| 107 | + "body must contain first event, got: {text}" | |
| 108 | + ); | |
| 109 | + assert!( | |
| 110 | + text.contains(r#""tag":"b""#), | |
| 111 | + "body must contain second event, got: {text}" | |
| 112 | + ); | |
| 113 | + } | |
| 114 | +} |
M
crates/rustwarden-core/src/services/app_settings_service.rs
+0
-11
@@ -401,14 +401,3 @@ impl AppSettingsService {
| 401 | 401 | Ok(()) |
| 402 | 402 | } |
| 403 | 403 | } |
| 404 | - | |
| 405 | -#[cfg(test)] | |
| 406 | -mod tests { | |
| 407 | - use super::*; | |
| 408 | - | |
| 409 | - #[tokio::test] | |
| 410 | - async fn test_get_setting_not_found() { | |
| 411 | - // This test would require a test database setup | |
| 412 | - // For now, it's a placeholder | |
| 413 | - } | |
| 414 | -} |
M
crates/rustwarden-core/src/services/auth_logging.rs
+104
-6
@@ -1,23 +1,32 @@
| 1 | 1 | use crate::error::AppError; |
| 2 | +use crate::hooks::{AuditEvent, RustwardenHooks}; | |
| 2 | 3 | use crate::models::audit_log; |
| 3 | 4 | use chrono::Utc; |
| 4 | 5 | use sea_orm::{ActiveModelTrait, DatabaseConnection, Set}; |
| 6 | +use std::sync::Arc; | |
| 5 | 7 | use uuid::Uuid; |
| 6 | 8 | |
| 7 | -/// Service de journalisation des tentatives bloquées (auth) | |
| 9 | +/// Logging service for blocked authentication attempts. | |
| 8 | 10 | /// |
| 9 | -/// Objectifs : | |
| 10 | -/// - Tracer les blocages (raison, email, horodatage) | |
| 11 | -/// - Ne jamais stocker de secrets ou de mots de passe | |
| 12 | -/// - Ne pas faire échouer le flux principal si l’écriture échoue | |
| 11 | +/// Goals: | |
| 12 | +/// - Record blocked attempts (reason, email, timestamp) | |
| 13 | +/// - Never store secrets or passwords | |
| 14 | +/// - Never fail the main authentication flow if logging fails | |
| 15 | +/// - Notify consumers via `RustwardenHooks::on_audit_event` | |
| 16 | +/// when they have plugged in a sink (SIEM, Langfuse, etc.) | |
| 13 | 17 | pub struct AuthLoggingService; |
| 14 | 18 | |
| 15 | 19 | impl AuthLoggingService { |
| 16 | - /// Journalise une tentative de connexion bloquée (email non vérifié, etc.) | |
| 20 | + /// Log a blocked login attempt (unverified email, etc.). | |
| 21 | + /// | |
| 22 | + /// When `hooks` is provided, also emits an `AuditEvent { kind: "auth.login_blocked" }` | |
| 23 | + /// so that consumer applications can aggregate all audit data in a | |
| 24 | + /// single pipeline without re-reading the `audit_logs` table. | |
| 17 | 25 | pub async fn log_blocked_login( |
| 18 | 26 | db: &DatabaseConnection, |
| 19 | 27 | email: &str, |
| 20 | 28 | reason: &str, |
| 29 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 21 | 30 | ) -> Result<(), AppError> { |
| 22 | 31 | tracing::warn!("[LOGIN_BLOCKED] email={} reason={}", email, reason); |
| 23 | 32 |
@@ -36,6 +45,95 @@ impl AuthLoggingService {
| 36 | 45 | tracing::warn!("Impossible d’écrire le log d’auth: {}", e); |
| 37 | 46 | } |
| 38 | 47 | |
| 48 | + // Fan-out au hook generique si le consommateur en a branché un. | |
| 49 | + // Un échec du hook ne doit pas casser le flux d'auth (best-effort). | |
| 50 | + if let Some(hooks) = hooks { | |
| 51 | + let metadata = serde_json::json!({ "email": email, "reason": reason }); | |
| 52 | + let event = AuditEvent { | |
| 53 | + kind: "auth.login_blocked", | |
| 54 | + actor_id: None, | |
| 55 | + target: None, | |
| 56 | + metadata: &metadata, | |
| 57 | + }; | |
| 58 | + if let Err(e) = hooks.on_audit_event(db, event).await { | |
| 59 | + tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e); | |
| 60 | + } | |
| 61 | + } | |
| 62 | + | |
| 39 | 63 | Ok(()) |
| 40 | 64 | } |
| 41 | 65 | } |
| 66 | + | |
| 67 | +#[cfg(test)] | |
| 68 | +mod tests { | |
| 69 | + use super::*; | |
| 70 | + use crate::hooks::RustwardenHooks; | |
| 71 | + use async_trait::async_trait; | |
| 72 | + use std::sync::Mutex; | |
| 73 | + | |
| 74 | + /// Hooks impl that forwards every recorded event to an externally-owned | |
| 75 | + /// `Arc<Mutex<Vec<_>>>`. Keeping the sink outside the trait object avoids | |
| 76 | + /// any downcast when the `Arc<dyn RustwardenHooks>` is passed downstream. | |
| 77 | + struct CapturingHooks { | |
| 78 | + sink: Arc<Mutex<Vec<(String, serde_json::Value)>>>, | |
| 79 | + } | |
| 80 | + | |
| 81 | + #[async_trait] | |
| 82 | + impl RustwardenHooks for CapturingHooks { | |
| 83 | + async fn on_audit_event( | |
| 84 | + &self, | |
| 85 | + _db: &DatabaseConnection, | |
| 86 | + event: AuditEvent<'_>, | |
| 87 | + ) -> anyhow::Result<()> { | |
| 88 | + self.sink | |
| 89 | + .lock() | |
| 90 | + .unwrap() | |
| 91 | + .push((event.kind.to_string(), event.metadata.clone())); | |
| 92 | + Ok(()) | |
| 93 | + } | |
| 94 | + } | |
| 95 | + | |
| 96 | + /// Per-test connection — see personal_token_service::tests for rationale. | |
| 97 | + async fn test_db() -> DatabaseConnection { | |
| 98 | + dotenvy::dotenv().ok(); | |
| 99 | + let url = | |
| 100 | + std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_logging tests"); | |
| 101 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 102 | + } | |
| 103 | + | |
| 104 | + #[tokio::test] | |
| 105 | + async fn log_blocked_login_dispatches_audit_event_when_hooks_provided() { | |
| 106 | + let db = test_db().await; | |
| 107 | + let sink: Arc<Mutex<Vec<(String, serde_json::Value)>>> = Arc::new(Mutex::new(Vec::new())); | |
| 108 | + let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks { sink: sink.clone() }); | |
| 109 | + | |
| 110 | + AuthLoggingService::log_blocked_login( | |
| 111 | + &db, | |
| 112 | + "test-audit@example.com", | |
| 113 | + "email_not_verified", | |
| 114 | + Some(&hooks), | |
| 115 | + ) | |
| 116 | + .await | |
| 117 | + .unwrap(); | |
| 118 | + | |
| 119 | + let events = sink.lock().unwrap().clone(); | |
| 120 | + assert_eq!(events.len(), 1); | |
| 121 | + assert_eq!(events[0].0, "auth.login_blocked"); | |
| 122 | + assert_eq!(events[0].1["email"], "test-audit@example.com"); | |
| 123 | + assert_eq!(events[0].1["reason"], "email_not_verified"); | |
| 124 | + } | |
| 125 | + | |
| 126 | + #[tokio::test] | |
| 127 | + async fn log_blocked_login_works_without_hooks() { | |
| 128 | + let db = test_db().await; | |
| 129 | + // None → no hook dispatch, no panic. | |
| 130 | + AuthLoggingService::log_blocked_login( | |
| 131 | + &db, | |
| 132 | + "no-hook@example.com", | |
| 133 | + "email_not_verified", | |
| 134 | + None, | |
| 135 | + ) | |
| 136 | + .await | |
| 137 | + .unwrap(); | |
| 138 | + } | |
| 139 | +} |
M
crates/rustwarden-core/src/services/auth_service.rs
+34
-15
@@ -39,47 +39,66 @@ pub struct AuthService;
| 39 | 39 | impl AuthService { |
| 40 | 40 | /// Verify user credentials and return the user if valid |
| 41 | 41 | /// |
| 42 | + /// The `identifier` parameter accepts either a username or an email address. | |
| 43 | + /// If it contains `@`, lookup is done by email; otherwise by username. | |
| 44 | + /// | |
| 42 | 45 | /// # Security |
| 43 | 46 | /// |
| 44 | 47 | /// - SEC-C1: Always runs bcrypt verify (even for non-existent users) to prevent timing attacks |
| 45 | - /// - SEC-L5: Usernames are partially masked in log output | |
| 46 | - /// - Returns generic error message (never reveals if username or password is wrong) | |
| 48 | + /// - SEC-L5: Identifiers are partially masked in log output | |
| 49 | + /// - Returns generic error message (never reveals if identifier or password is wrong) | |
| 47 | 50 | pub async fn verify_credentials( |
| 48 | 51 | db: &DatabaseConnection, |
| 49 | - username: &str, | |
| 52 | + identifier: &str, | |
| 50 | 53 | password: &str, |
| 51 | 54 | ) -> Result<user::Model, AppError> { |
| 52 | - let user = user::Entity::find() | |
| 53 | - .filter(user::Column::Username.eq(username)) | |
| 54 | - .one(db) | |
| 55 | - .await?; | |
| 55 | + let user = if identifier.contains('@') { | |
| 56 | + user::Entity::find() | |
| 57 | + .filter(user::Column::Email.eq(identifier)) | |
| 58 | + .one(db) | |
| 59 | + .await? | |
| 60 | + } else { | |
| 61 | + user::Entity::find() | |
| 62 | + .filter(user::Column::Username.eq(identifier)) | |
| 63 | + .one(db) | |
| 64 | + .await? | |
| 65 | + }; | |
| 56 | 66 | |
| 57 | 67 | let user = match user { |
| 58 | 68 | Some(u) => u, |
| 59 | 69 | None => { |
| 60 | 70 | // SEC-C1: always run bcrypt to prevent timing-based user enumeration |
| 61 | 71 | let _ = PasswordService::verify_password("invalid", &DUMMY_HASH).await; |
| 62 | - tracing::warn!("Login attempt for unknown user: {}", mask_username(username)); | |
| 72 | + tracing::warn!( | |
| 73 | + "Login attempt for unknown identifier: {}", | |
| 74 | + mask_username(identifier) | |
| 75 | + ); | |
| 63 | 76 | return Err(AppError::InvalidCredentials); |
| 64 | 77 | } |
| 65 | 78 | }; |
| 66 | 79 | |
| 67 | - let password_valid = PasswordService::verify_password(password, &user.password_hash).await?; | |
| 80 | + let password_valid = | |
| 81 | + PasswordService::verify_password(password, &user.password_hash).await?; | |
| 68 | 82 | |
| 69 | 83 | if !password_valid { |
| 70 | 84 | tracing::warn!( |
| 71 | - "Login attempt with invalid password for user: {}", | |
| 72 | - mask_username(username) | |
| 85 | + "Login attempt with invalid password for identifier: {}", | |
| 86 | + mask_username(identifier) | |
| 73 | 87 | ); |
| 74 | 88 | return Err(AppError::InvalidCredentials); |
| 75 | 89 | } |
| 76 | 90 | |
| 77 | - tracing::info!("User {} authenticated successfully", mask_username(username)); | |
| 91 | + tracing::info!( | |
| 92 | + "User {} authenticated successfully", | |
| 93 | + mask_username(identifier) | |
| 94 | + ); | |
| 78 | 95 | Ok(user) |
| 79 | 96 | } |
| 80 | 97 | |
| 81 | 98 | /// Authenticate user and return the model with must_change_password flag |
| 82 | 99 | /// |
| 100 | + /// The `identifier` parameter accepts either a username or an email address. | |
| 101 | + /// | |
| 83 | 102 | /// This is a higher-level authentication method that: |
| 84 | 103 | /// 1. Verifies credentials |
| 85 | 104 | /// 2. Logs if user must change password (but does NOT block login) |
@@ -89,13 +108,13 @@ impl AuthService {
| 89 | 108 | /// redirect the user to a password-change page. |
| 90 | 109 | pub async fn authenticate_user( |
| 91 | 110 | db: &DatabaseConnection, |
| 92 | - username: &str, | |
| 111 | + identifier: &str, | |
| 93 | 112 | password: &str, |
| 94 | 113 | ) -> Result<user::Model, AppError> { |
| 95 | - let user = Self::verify_credentials(db, username, password).await?; | |
| 114 | + let user = Self::verify_credentials(db, identifier, password).await?; | |
| 96 | 115 | |
| 97 | 116 | if user.must_change_password { |
| 98 | - tracing::info!("User {} should change password (flag set)", username); | |
| 117 | + tracing::info!("User {} should change password (flag set)", user.username); | |
| 99 | 118 | } |
| 100 | 119 | |
| 101 | 120 | Ok(user) |
M
crates/rustwarden-core/src/services/crypto_service.rs
+17
-18
@@ -1,33 +1,32 @@
| 1 | 1 | use crate::error::AppError; |
| 2 | 2 | use aes_gcm::{ |
| 3 | 3 | aead::{Aead, KeyInit, OsRng}, |
| 4 | - Aes256Gcm, AeadCore, Nonce, | |
| 4 | + AeadCore, Aes256Gcm, Nonce, | |
| 5 | 5 | }; |
| 6 | 6 | use base64::{engine::general_purpose::STANDARD as B64, Engine}; |
| 7 | 7 | use std::env; |
| 8 | 8 | |
| 9 | 9 | const ENCRYPTED_PREFIX: &str = "enc:"; |
| 10 | 10 | |
| 11 | +/// Service providing AES-256-GCM encryption and decryption for sensitive settings. | |
| 11 | 12 | pub struct CryptoService; |
| 12 | 13 | |
| 13 | 14 | impl CryptoService { |
| 14 | 15 | fn get_key() -> Result<[u8; 32], AppError> { |
| 15 | - let key_hex = env::var("OAUTH_ENCRYPTION_KEY").map_err(|_| { | |
| 16 | + let key_hex = env::var("ENCRYPTION_KEY") | |
| 17 | + .or_else(|_| env::var("OAUTH_ENCRYPTION_KEY")) | |
| 18 | + .map_err(|_| { | |
| 16 | 19 | AppError::Internal( |
| 17 | - "OAUTH_ENCRYPTION_KEY is not set. A 64-character hex key is required to store OAuth secrets.".to_string(), | |
| 20 | + "ENCRYPTION_KEY (or OAUTH_ENCRYPTION_KEY) is not set. A 64-character hex key is required for encryption.".to_string(), | |
| 18 | 21 | ) |
| 19 | 22 | })?; |
| 20 | 23 | |
| 21 | - let bytes = hex::decode(key_hex.trim()).map_err(|e| { | |
| 22 | - AppError::Internal(format!( | |
| 23 | - "OAUTH_ENCRYPTION_KEY is not valid hex: {}", | |
| 24 | - e | |
| 25 | - )) | |
| 26 | - })?; | |
| 24 | + let bytes = hex::decode(key_hex.trim()) | |
| 25 | + .map_err(|e| AppError::Internal(format!("ENCRYPTION_KEY is not valid hex: {}", e)))?; | |
| 27 | 26 | |
| 28 | 27 | if bytes.len() != 32 { |
| 29 | 28 | return Err(AppError::Internal(format!( |
| 30 | - "OAUTH_ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes", | |
| 29 | + "ENCRYPTION_KEY must be exactly 64 hex characters (32 bytes), got {} bytes", | |
| 31 | 30 | bytes.len() |
| 32 | 31 | ))); |
| 33 | 32 | } |
@@ -66,9 +65,9 @@ impl CryptoService {
| 66 | 65 | let cipher = Aes256Gcm::new_from_slice(&key_bytes) |
| 67 | 66 | .map_err(|e| AppError::Internal(format!("Failed to create cipher: {}", e)))?; |
| 68 | 67 | |
| 69 | - let combined = B64.decode(encoded).map_err(|e| { | |
| 70 | - AppError::Internal(format!("Failed to decode encrypted value: {}", e)) | |
| 71 | - })?; | |
| 68 | + let combined = B64 | |
| 69 | + .decode(encoded) | |
| 70 | + .map_err(|e| AppError::Internal(format!("Failed to decode encrypted value: {}", e)))?; | |
| 72 | 71 | |
| 73 | 72 | if combined.len() < 12 { |
| 74 | 73 | return Err(AppError::Internal("Encrypted value too short".to_string())); |
@@ -77,17 +76,17 @@ impl CryptoService {
| 77 | 76 | let (nonce_bytes, ciphertext) = combined.split_at(12); |
| 78 | 77 | let nonce = Nonce::from_slice(nonce_bytes); |
| 79 | 78 | |
| 80 | - let plaintext = cipher | |
| 81 | - .decrypt(nonce, ciphertext) | |
| 82 | - .map_err(|_| AppError::Internal("Decryption failed – wrong key or corrupted data".to_string()))?; | |
| 79 | + let plaintext = cipher.decrypt(nonce, ciphertext).map_err(|_| { | |
| 80 | + AppError::Internal("Decryption failed – wrong key or corrupted data".to_string()) | |
| 81 | + })?; | |
| 83 | 82 | |
| 84 | 83 | String::from_utf8(plaintext) |
| 85 | 84 | .map_err(|e| AppError::Internal(format!("Decrypted value is not valid UTF-8: {}", e))) |
| 86 | 85 | } |
| 87 | 86 | |
| 88 | - /// Returns true if `OAUTH_ENCRYPTION_KEY` is configured. | |
| 87 | + /// Returns true if an encryption key is configured. | |
| 89 | 88 | pub fn is_available() -> bool { |
| 90 | - env::var("OAUTH_ENCRYPTION_KEY").is_ok() | |
| 89 | + env::var("ENCRYPTION_KEY").is_ok() || env::var("OAUTH_ENCRYPTION_KEY").is_ok() | |
| 91 | 90 | } |
| 92 | 91 | |
| 93 | 92 | /// Returns true if the value is already encrypted (has the `enc:` prefix). |
M
crates/rustwarden-core/src/services/email_validation_service.rs
+17
-52
@@ -10,24 +10,25 @@ use crate::config::email::EmailConfig;
| 10 | 10 | use crate::error::AppError; |
| 11 | 11 | use crate::models::{email_validation_token, user}; |
| 12 | 12 | use crate::services::email_queue_service::EmailQueueService; |
| 13 | +use crate::services::rate_limit_service::RateLimitService; | |
| 13 | 14 | use crate::services::user_service::UserService; |
| 14 | 15 | use chrono::{Duration, Utc}; |
| 15 | 16 | use sea_orm::{ |
| 16 | 17 | ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, |
| 17 | 18 | }; |
| 18 | 19 | use sha2::{Digest, Sha256}; |
| 19 | -use std::collections::HashMap; | |
| 20 | -use std::sync::Arc; | |
| 21 | -use std::time::{SystemTime, UNIX_EPOCH}; | |
| 22 | -use tokio::sync::Mutex; | |
| 23 | 20 | use uuid::Uuid; |
| 24 | 21 | |
| 22 | +/// Default: 3 verification email requests per address per hour. | |
| 23 | +const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3; | |
| 24 | +const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: std::time::Duration = | |
| 25 | + std::time::Duration::from_secs(3600); | |
| 26 | + | |
| 25 | 27 | /// Email validation service |
| 26 | 28 | pub struct EmailValidationService { |
| 27 | 29 | email_config: EmailConfig, |
| 28 | - /// Rate limiting: track requests per email (email -> timestamp of last request) | |
| 29 | - /// Cleaned up periodically to prevent memory leak | |
| 30 | - rate_limit_store: Arc<Mutex<HashMap<String, Vec<SystemTime>>>>, | |
| 30 | + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). | |
| 31 | + rate_limiter: RateLimitService, | |
| 31 | 32 | } |
| 32 | 33 | |
| 33 | 34 | impl EmailValidationService { |
@@ -35,7 +36,7 @@ impl EmailValidationService {
| 35 | 36 | pub fn new(email_config: EmailConfig) -> Self { |
| 36 | 37 | Self { |
| 37 | 38 | email_config, |
| 38 | - rate_limit_store: Arc::new(Mutex::new(HashMap::new())), | |
| 39 | + rate_limiter: RateLimitService::new(), | |
| 39 | 40 | } |
| 40 | 41 | } |
| 41 | 42 |
@@ -75,7 +76,8 @@ impl EmailValidationService {
| 75 | 76 | // Hash token with SHA-256 |
| 76 | 77 | let token_hash = Self::hash_token(&token); |
| 77 | 78 | |
| 78 | - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours()); | |
| 79 | + let expires_at = | |
| 80 | + Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours()); | |
| 79 | 81 | |
| 80 | 82 | let validation_token = email_validation_token::ActiveModel { |
| 81 | 83 | id: Set(Uuid::new_v4()), |
@@ -295,8 +297,12 @@ impl EmailValidationService {
| 295 | 297 | db: &DatabaseConnection, |
| 296 | 298 | email: &str, |
| 297 | 299 | ) -> Result<bool, AppError> { |
| 298 | - // Check rate limiting (3 requests per hour per email) | |
| 299 | - self.check_rate_limit(email).await?; | |
| 300 | + // Check rate limiting (3 requests per hour per email). Atomic check+record. | |
| 301 | + self.rate_limiter.check_sliding_window( | |
| 302 | + &format!("email_verify:{email}"), | |
| 303 | + EMAIL_VALIDATION_RATE_LIMIT_MAX, | |
| 304 | + EMAIL_VALIDATION_RATE_LIMIT_WINDOW, | |
| 305 | + )?; | |
| 300 | 306 | |
| 301 | 307 | // Find user by email |
| 302 | 308 | let user = match UserService::get_user_by_email(db, email).await? { |
@@ -315,9 +321,6 @@ impl EmailValidationService {
| 315 | 321 | // Send validation email |
| 316 | 322 | self.send_validation_email(db, user.id).await?; |
| 317 | 323 | |
| 318 | - // Update rate limit | |
| 319 | - self.update_rate_limit(email).await; | |
| 320 | - | |
| 321 | 324 | Ok(true) |
| 322 | 325 | } |
| 323 | 326 |
@@ -350,42 +353,4 @@ impl EmailValidationService {
| 350 | 353 | |
| 351 | 354 | Ok(()) |
| 352 | 355 | } |
| 353 | - | |
| 354 | - /// Check rate limiting (3 requests per hour per email) | |
| 355 | - async fn check_rate_limit(&self, email: &str) -> Result<(), AppError> { | |
| 356 | - let mut store = self.rate_limit_store.lock().await; | |
| 357 | - | |
| 358 | - // Clean up old entries (older than 1 hour) | |
| 359 | - let one_hour_ago = SystemTime::now() | |
| 360 | - .duration_since(UNIX_EPOCH) | |
| 361 | - .unwrap_or_default() | |
| 362 | - .as_secs() | |
| 363 | - .saturating_sub(3600); | |
| 364 | - | |
| 365 | - if let Some(requests) = store.get_mut(email) { | |
| 366 | - requests.retain(|&time| { | |
| 367 | - time.duration_since(UNIX_EPOCH) | |
| 368 | - .unwrap_or_default() | |
| 369 | - .as_secs() | |
| 370 | - > one_hour_ago | |
| 371 | - }); | |
| 372 | - } | |
| 373 | - | |
| 374 | - // Check current requests | |
| 375 | - let requests = store.entry(email.to_string()).or_insert_with(Vec::new); | |
| 376 | - if requests.len() >= 3 { | |
| 377 | - return Err(AppError::Validation( | |
| 378 | - "Too many verification email requests. Please try again later.".to_string(), | |
| 379 | - )); | |
| 380 | - } | |
| 381 | - | |
| 382 | - Ok(()) | |
| 383 | - } | |
| 384 | - | |
| 385 | - /// Update rate limit after successful request | |
| 386 | - async fn update_rate_limit(&self, email: &str) { | |
| 387 | - let mut store = self.rate_limit_store.lock().await; | |
| 388 | - let requests = store.entry(email.to_string()).or_insert_with(Vec::new); | |
| 389 | - requests.push(SystemTime::now()); | |
| 390 | - } | |
| 391 | 356 | } |
M
crates/rustwarden-core/src/services/mod.rs
+32
-3
@@ -2,25 +2,54 @@
| 2 | 2 | //! |
| 3 | 3 | //! Business logic services for the Rustwarden framework. |
| 4 | 4 | |
| 5 | +/// Service for reading and updating application-wide settings. | |
| 5 | 6 | pub mod app_settings_service; |
| 7 | +/// Logging helpers for authentication events. | |
| 6 | 8 | pub mod auth_logging; |
| 9 | +/// Core authentication service (login, logout, token refresh). | |
| 10 | +pub mod auth_service; | |
| 11 | +/// AES-256-GCM encryption and hashing utilities. | |
| 7 | 12 | pub mod crypto_service; |
| 13 | +/// Ensures a default admin account exists on first boot. | |
| 8 | 14 | pub mod default_admin_service; |
| 9 | -pub mod auth_service; | |
| 15 | +/// Background service that drains the email queue. | |
| 10 | 16 | pub mod email_queue_service; |
| 17 | +/// Email address sanitization helpers. | |
| 11 | 18 | pub mod email_sanitization; |
| 19 | +/// SMTP email sending service. | |
| 12 | 20 | pub mod email_service; |
| 21 | +/// Email address validation workflow service. | |
| 13 | 22 | pub mod email_validation_service; |
| 23 | +/// IMAP bounce processing service. | |
| 14 | 24 | pub mod imap_bounce_service; |
| 25 | +/// JWT blacklist service for token revocation. | |
| 15 | 26 | pub mod jwt_blacklist_service; |
| 27 | +/// OAuth provider integration services. | |
| 28 | +#[cfg(feature = "oauth")] | |
| 29 | +pub mod oauth; | |
| 30 | +/// Password change with email confirmation service. | |
| 16 | 31 | pub mod password_change_service; |
| 32 | +/// Password reset via email token service. | |
| 17 | 33 | pub mod password_reset_service; |
| 34 | +/// Password hashing and verification utilities. | |
| 18 | 35 | pub mod password_service; |
| 36 | +/// RBAC permission checking service. | |
| 19 | 37 | pub mod permission_service; |
| 38 | +/// Personal access token (PAT) management service. | |
| 39 | +pub mod personal_token_service; | |
| 40 | +/// Token-bucket rate limiting service. | |
| 41 | +pub mod rate_limit_service; | |
| 42 | +/// Refresh token rotation and revocation service. | |
| 20 | 43 | pub mod refresh_token_service; |
| 44 | +/// Generic resource registration and sharing service. | |
| 21 | 45 | pub mod resource_service; |
| 46 | +/// Role assignment and lookup service. | |
| 22 | 47 | pub mod role_service; |
| 48 | +/// Team CRUD and membership management service. | |
| 49 | +pub mod team_service; | |
| 50 | +/// TOTP two-factor authentication service. | |
| 51 | +pub mod totp_service; | |
| 52 | +/// User preferences (theme, language) service. | |
| 23 | 53 | pub mod user_preferences_service; |
| 54 | +/// User account management service. | |
| 24 | 55 | pub mod user_service; |
| 25 | -#[cfg(feature = "oauth")] | |
| 26 | -pub mod oauth; |
M
crates/rustwarden-core/src/services/oauth/mod.rs
+3
-0
@@ -1,5 +1,8 @@
| 1 | +/// Trait definition for OAuth/OIDC providers and associated data types. | |
| 1 | 2 | pub mod provider_trait; |
| 3 | +/// Registry and concrete implementations of OAuth providers. | |
| 2 | 4 | pub mod providers; |
| 5 | +/// High-level OAuth service: account linking and user provisioning. | |
| 3 | 6 | pub mod service; |
| 4 | 7 | |
| 5 | 8 | pub use provider_trait::{OAuthProvider, ProviderUserInfo}; |
M
crates/rustwarden-core/src/services/oauth/provider_trait.rs
+10
-1
@@ -1,21 +1,30 @@
| 1 | -use async_trait::async_trait; | |
| 2 | 1 | use crate::error::AppError; |
| 2 | +use async_trait::async_trait; | |
| 3 | 3 | |
| 4 | 4 | /// Information about a user obtained from an OAuth/OIDC provider. |
| 5 | 5 | #[derive(Debug, Clone)] |
| 6 | 6 | pub struct ProviderUserInfo { |
| 7 | + /// Unique identifier for the user as assigned by the OAuth provider. | |
| 7 | 8 | pub provider_user_id: String, |
| 9 | + /// Email address reported by the provider (may be absent). | |
| 8 | 10 | pub email: Option<String>, |
| 11 | + /// Whether the provider has verified the email address. | |
| 9 | 12 | pub email_verified: bool, |
| 13 | + /// Display name reported by the provider. | |
| 10 | 14 | pub display_name: Option<String>, |
| 15 | + /// URL of the user's avatar image reported by the provider. | |
| 11 | 16 | pub avatar_url: Option<String>, |
| 12 | 17 | } |
| 13 | 18 | |
| 14 | 19 | /// Authorization URL and associated security state. |
| 15 | 20 | pub struct AuthorizationUrl { |
| 21 | + /// The full authorization URL to redirect the user to. | |
| 16 | 22 | pub url: String, |
| 23 | + /// CSRF state token to validate on callback. | |
| 17 | 24 | pub csrf_state: String, |
| 25 | + /// OIDC nonce value stored in the session for replay protection. | |
| 18 | 26 | pub nonce: Option<String>, |
| 27 | + /// PKCE code verifier stored in the session (S256 challenge sent to provider). | |
| 19 | 28 | pub pkce_verifier: Option<String>, |
| 20 | 29 | } |
| 21 | 30 |
M
crates/rustwarden-core/src/services/oauth/providers/discord.rs
+16
-5
@@ -1,18 +1,23 @@
| 1 | +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 2 | +use crate::error::AppError; | |
| 1 | 3 | use async_trait::async_trait; |
| 2 | 4 | use openidconnect::{CsrfToken, PkceCodeChallenge}; |
| 3 | 5 | use reqwest; |
| 4 | 6 | use serde::Deserialize; |
| 5 | -use crate::error::AppError; | |
| 6 | -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 7 | 7 | |
| 8 | +/// OAuth 2.0 provider implementation for Discord. | |
| 8 | 9 | pub struct DiscordProvider { |
| 9 | 10 | client_id: String, |
| 10 | 11 | client_secret: String, |
| 11 | 12 | } |
| 12 | 13 | |
| 13 | 14 | impl DiscordProvider { |
| 15 | + /// Create a new `DiscordProvider` with the given client credentials. | |
| 14 | 16 | pub fn new(client_id: String, client_secret: String) -> Self { |
| 15 | - Self { client_id, client_secret } | |
| 17 | + Self { | |
| 18 | + client_id, | |
| 19 | + client_secret, | |
| 20 | + } | |
| 16 | 21 | } |
| 17 | 22 | } |
| 18 | 23 |
@@ -96,7 +101,10 @@ impl OAuthProvider for DiscordProvider {
| 96 | 101 | |
| 97 | 102 | let user: DiscordUser = http |
| 98 | 103 | .get("https://discord.com/api/v10/users/@me") |
| 99 | - .header("Authorization", format!("Bearer {}", token_resp.access_token)) | |
| 104 | + .header( | |
| 105 | + "Authorization", | |
| 106 | + format!("Bearer {}", token_resp.access_token), | |
| 107 | + ) | |
| 100 | 108 | .send() |
| 101 | 109 | .await |
| 102 | 110 | .map_err(|e| AppError::Internal(format!("Discord user fetch failed: {}", e)))? |
@@ -105,7 +113,10 @@ impl OAuthProvider for DiscordProvider {
| 105 | 113 | .map_err(|e| AppError::Internal(format!("Discord user parse failed: {}", e)))?; |
| 106 | 114 | |
| 107 | 115 | let avatar_url = user.avatar.as_ref().map(|hash| { |
| 108 | - format!("https://cdn.discordapp.com/avatars/{}/{}.png", user.id, hash) | |
| 116 | + format!( | |
| 117 | + "https://cdn.discordapp.com/avatars/{}/{}.png", | |
| 118 | + user.id, hash | |
| 119 | + ) | |
| 109 | 120 | }); |
| 110 | 121 | |
| 111 | 122 | Ok(ProviderUserInfo { |
M
crates/rustwarden-core/src/services/oauth/providers/github.rs
+17
-8
@@ -1,17 +1,22 @@
| 1 | +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 2 | +use crate::error::AppError; | |
| 1 | 3 | use async_trait::async_trait; |
| 2 | 4 | use openidconnect::{CsrfToken, PkceCodeChallenge}; |
| 3 | 5 | use serde::Deserialize; |
| 4 | -use crate::error::AppError; | |
| 5 | -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 6 | 6 | |
| 7 | +/// OAuth 2.0 provider implementation for GitHub. | |
| 7 | 8 | pub struct GitHubProvider { |
| 8 | 9 | client_id: String, |
| 9 | 10 | client_secret: String, |
| 10 | 11 | } |
| 11 | 12 | |
| 12 | 13 | impl GitHubProvider { |
| 14 | + /// Create a new `GitHubProvider` with the given client credentials. | |
| 13 | 15 | pub fn new(client_id: String, client_secret: String) -> Self { |
| 14 | - Self { client_id, client_secret } | |
| 16 | + Self { | |
| 17 | + client_id, | |
| 18 | + client_secret, | |
| 19 | + } | |
| 15 | 20 | } |
| 16 | 21 | } |
| 17 | 22 |
@@ -99,7 +104,10 @@ impl OAuthProvider for GitHubProvider {
| 99 | 104 | |
| 100 | 105 | let user: GitHubUser = http |
| 101 | 106 | .get("https://api.github.com/user") |
| 102 | - .header("Authorization", format!("Bearer {}", token_resp.access_token)) | |
| 107 | + .header( | |
| 108 | + "Authorization", | |
| 109 | + format!("Bearer {}", token_resp.access_token), | |
| 110 | + ) | |
| 103 | 111 | .header("User-Agent", "Rustwarden") |
| 104 | 112 | .send() |
| 105 | 113 | .await |
@@ -113,7 +121,10 @@ impl OAuthProvider for GitHubProvider {
| 113 | 121 | } else { |
| 114 | 122 | let emails: Vec<GitHubEmail> = http |
| 115 | 123 | .get("https://api.github.com/user/emails") |
| 116 | - .header("Authorization", format!("Bearer {}", token_resp.access_token)) | |
| 124 | + .header( | |
| 125 | + "Authorization", | |
| 126 | + format!("Bearer {}", token_resp.access_token), | |
| 127 | + ) | |
| 117 | 128 | .header("User-Agent", "Rustwarden") |
| 118 | 129 | .send() |
| 119 | 130 | .await |
@@ -122,9 +133,7 @@ impl OAuthProvider for GitHubProvider {
| 122 | 133 | .await |
| 123 | 134 | .unwrap_or_default(); |
| 124 | 135 | |
| 125 | - let primary = emails | |
| 126 | - .into_iter() | |
| 127 | - .find(|e| e.primary && e.verified); | |
| 136 | + let primary = emails.into_iter().find(|e| e.primary && e.verified); | |
| 128 | 137 | match primary { |
| 129 | 138 | Some(e) => (Some(e.email), true), |
| 130 | 139 | None => (None, false), |
M
crates/rustwarden-core/src/services/oauth/providers/google.rs
+20
-12
@@ -1,20 +1,24 @@
| 1 | +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 2 | +use crate::error::AppError; | |
| 1 | 3 | use async_trait::async_trait; |
| 2 | 4 | use openidconnect::{ |
| 3 | - core::CoreProviderMetadata, | |
| 4 | - AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, | |
| 5 | - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, | |
| 5 | + core::CoreProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, | |
| 6 | + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, | |
| 6 | 7 | }; |
| 7 | -use crate::error::AppError; | |
| 8 | -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 9 | 8 | |
| 9 | +/// OIDC provider implementation for Google. | |
| 10 | 10 | pub struct GoogleProvider { |
| 11 | 11 | client_id: String, |
| 12 | 12 | client_secret: String, |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | impl GoogleProvider { |
| 16 | + /// Create a new `GoogleProvider` with the given client credentials. | |
| 16 | 17 | pub fn new(client_id: String, client_secret: String) -> Self { |
| 17 | - Self { client_id, client_secret } | |
| 18 | + Self { | |
| 19 | + client_id, | |
| 20 | + client_secret, | |
| 21 | + } | |
| 18 | 22 | } |
| 19 | 23 | } |
| 20 | 24 |
@@ -73,10 +77,9 @@ impl OAuthProvider for GoogleProvider {
| 73 | 77 | let issuer_url = IssuerUrl::new("https://accounts.google.com".to_string()) |
| 74 | 78 | .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?; |
| 75 | 79 | |
| 76 | - let provider_metadata = | |
| 77 | - CoreProviderMetadata::discover_async(issuer_url, &http_client) | |
| 78 | - .await | |
| 79 | - .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?; | |
| 80 | + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) | |
| 81 | + .await | |
| 82 | + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?; | |
| 80 | 83 | |
| 81 | 84 | let client = openidconnect::core::CoreClient::from_provider_metadata( |
| 82 | 85 | provider_metadata, |
@@ -109,7 +112,9 @@ impl OAuthProvider for GoogleProvider {
| 109 | 112 | |
| 110 | 113 | // SEC-H8: Reject missing nonce — empty string would bypass replay protection |
| 111 | 114 | let nonce_value = nonce_str.ok_or_else(|| { |
| 112 | - AppError::Internal("OIDC nonce missing from session — possible replay attack".to_string()) | |
| 115 | + AppError::Internal( | |
| 116 | + "OIDC nonce missing from session — possible replay attack".to_string(), | |
| 117 | + ) | |
| 113 | 118 | })?; |
| 114 | 119 | let nonce = Nonce::new(nonce_value.to_string()); |
| 115 | 120 | let claims = id_token |
@@ -123,7 +128,10 @@ impl OAuthProvider for GoogleProvider {
| 123 | 128 | .name() |
| 124 | 129 | .and_then(|n| n.get(None)) |
| 125 | 130 | .map(|n| n.to_string()); |
| 126 | - let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string()); | |
| 131 | + let picture = claims | |
| 132 | + .picture() | |
| 133 | + .and_then(|p| p.get(None)) | |
| 134 | + .map(|p| p.to_string()); | |
| 127 | 135 | |
| 128 | 136 | Ok(ProviderUserInfo { |
| 129 | 137 | provider_user_id: subject, |
M
crates/rustwarden-core/src/services/oauth/providers/microsoft.rs
+20
-14
@@ -1,12 +1,12 @@
| 1 | +use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 2 | +use crate::error::AppError; | |
| 1 | 3 | use async_trait::async_trait; |
| 2 | 4 | use openidconnect::{ |
| 3 | - core::CoreProviderMetadata, | |
| 4 | - AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, Nonce, | |
| 5 | - PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, | |
| 5 | + core::CoreProviderMetadata, AuthorizationCode, ClientId, ClientSecret, CsrfToken, IssuerUrl, | |
| 6 | + Nonce, PkceCodeChallenge, PkceCodeVerifier, RedirectUrl, | |
| 6 | 7 | }; |
| 7 | -use crate::error::AppError; | |
| 8 | -use super::super::provider_trait::{AuthorizationUrl, OAuthProvider, ProviderUserInfo}; | |
| 9 | 8 | |
| 9 | +/// OIDC provider implementation for Microsoft (Azure AD / Entra ID). | |
| 10 | 10 | pub struct MicrosoftProvider { |
| 11 | 11 | client_id: String, |
| 12 | 12 | client_secret: String, |
@@ -14,6 +14,7 @@ pub struct MicrosoftProvider {
| 14 | 14 | } |
| 15 | 15 | |
| 16 | 16 | impl MicrosoftProvider { |
| 17 | + /// Create a new `MicrosoftProvider`. Defaults to the `"common"` tenant when none is specified. | |
| 17 | 18 | pub fn new(client_id: String, client_secret: String, tenant: Option<String>) -> Self { |
| 18 | 19 | Self { |
| 19 | 20 | client_id, |
@@ -76,15 +77,15 @@ impl OAuthProvider for MicrosoftProvider {
| 76 | 77 | .build() |
| 77 | 78 | .map_err(|e| AppError::Internal(format!("HTTP client error: {}", e)))?; |
| 78 | 79 | |
| 79 | - let issuer_url = IssuerUrl::new( | |
| 80 | - format!("https://login.microsoftonline.com/{}/v2.0", self.tenant), | |
| 81 | - ) | |
| 80 | + let issuer_url = IssuerUrl::new(format!( | |
| 81 | + "https://login.microsoftonline.com/{}/v2.0", | |
| 82 | + self.tenant | |
| 83 | + )) | |
| 82 | 84 | .map_err(|e| AppError::Internal(format!("Invalid issuer URL: {}", e)))?; |
| 83 | 85 | |
| 84 | - let provider_metadata = | |
| 85 | - CoreProviderMetadata::discover_async(issuer_url, &http_client) | |
| 86 | - .await | |
| 87 | - .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?; | |
| 86 | + let provider_metadata = CoreProviderMetadata::discover_async(issuer_url, &http_client) | |
| 87 | + .await | |
| 88 | + .map_err(|e| AppError::Internal(format!("OIDC discovery failed: {}", e)))?; | |
| 88 | 89 | |
| 89 | 90 | let client = openidconnect::core::CoreClient::from_provider_metadata( |
| 90 | 91 | provider_metadata, |
@@ -117,7 +118,9 @@ impl OAuthProvider for MicrosoftProvider {
| 117 | 118 | |
| 118 | 119 | // SEC-H8: Reject missing nonce — empty string would bypass replay protection |
| 119 | 120 | let nonce_value = nonce_str.ok_or_else(|| { |
| 120 | - AppError::Internal("OIDC nonce missing from session — possible replay attack".to_string()) | |
| 121 | + AppError::Internal( | |
| 122 | + "OIDC nonce missing from session — possible replay attack".to_string(), | |
| 123 | + ) | |
| 121 | 124 | })?; |
| 122 | 125 | let nonce = Nonce::new(nonce_value.to_string()); |
| 123 | 126 | let claims = id_token |
@@ -131,7 +134,10 @@ impl OAuthProvider for MicrosoftProvider {
| 131 | 134 | .name() |
| 132 | 135 | .and_then(|n| n.get(None)) |
| 133 | 136 | .map(|n| n.to_string()); |
| 134 | - let picture = claims.picture().and_then(|p| p.get(None)).map(|p| p.to_string()); | |
| 137 | + let picture = claims | |
| 138 | + .picture() | |
| 139 | + .and_then(|p| p.get(None)) | |
| 140 | + .map(|p| p.to_string()); | |
| 135 | 141 | |
| 136 | 142 | Ok(ProviderUserInfo { |
| 137 | 143 | provider_user_id: subject, |
M
crates/rustwarden-core/src/services/oauth/providers/mod.rs
+12
-3
@@ -1,11 +1,15 @@
| 1 | -pub mod google; | |
| 2 | -pub mod github; | |
| 1 | +/// Discord OAuth provider implementation. | |
| 3 | 2 | pub mod discord; |
| 3 | +/// GitHub OAuth provider implementation. | |
| 4 | +pub mod github; | |
| 5 | +/// Google OIDC provider implementation. | |
| 6 | +pub mod google; | |
| 7 | +/// Microsoft OIDC provider implementation. | |
| 4 | 8 | pub mod microsoft; |
| 5 | 9 | |
| 10 | +use super::provider_trait::OAuthProvider; | |
| 6 | 11 | use std::collections::HashMap; |
| 7 | 12 | use std::sync::Arc; |
| 8 | -use super::provider_trait::OAuthProvider; | |
| 9 | 13 | |
| 10 | 14 | /// Registry of available OAuth providers, indexed by name. |
| 11 | 15 | pub struct ProviderRegistry { |
@@ -13,20 +17,24 @@ pub struct ProviderRegistry {
| 13 | 17 | } |
| 14 | 18 | |
| 15 | 19 | impl ProviderRegistry { |
| 20 | + /// Create an empty provider registry. | |
| 16 | 21 | pub fn new() -> Self { |
| 17 | 22 | Self { |
| 18 | 23 | providers: HashMap::new(), |
| 19 | 24 | } |
| 20 | 25 | } |
| 21 | 26 | |
| 27 | + /// Register an OAuth provider, replacing any existing registration with the same name. | |
| 22 | 28 | pub fn register(&mut self, provider: Arc<dyn OAuthProvider>) { |
| 23 | 29 | self.providers.insert(provider.name().to_string(), provider); |
| 24 | 30 | } |
| 25 | 31 | |
| 32 | + /// Look up a provider by its name identifier. | |
| 26 | 33 | pub fn get(&self, name: &str) -> Option<&Arc<dyn OAuthProvider>> { |
| 27 | 34 | self.providers.get(name) |
| 28 | 35 | } |
| 29 | 36 | |
| 37 | + /// Return a list of `(name, display_name)` pairs for all registered providers. | |
| 30 | 38 | pub fn list(&self) -> Vec<(&str, &str)> { |
| 31 | 39 | self.providers |
| 32 | 40 | .values() |
@@ -34,6 +42,7 @@ impl ProviderRegistry {
| 34 | 42 | .collect() |
| 35 | 43 | } |
| 36 | 44 | |
| 45 | + /// Return `true` if no providers are registered. | |
| 37 | 46 | pub fn is_empty(&self) -> bool { |
| 38 | 47 | self.providers.is_empty() |
| 39 | 48 | } |
M
crates/rustwarden-core/src/services/oauth/service.rs
+22
-11
@@ -2,12 +2,13 @@ use chrono::Utc;
| 2 | 2 | use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; |
| 3 | 3 | use uuid::Uuid; |
| 4 | 4 | |
| 5 | +use super::provider_trait::ProviderUserInfo; | |
| 5 | 6 | use crate::error::AppError; |
| 6 | 7 | use crate::models::oauth_account; |
| 7 | -use crate::services::user_service::UserService; | |
| 8 | 8 | use crate::services::role_service::RoleService; |
| 9 | -use super::provider_trait::ProviderUserInfo; | |
| 9 | +use crate::services::user_service::UserService; | |
| 10 | 10 | |
| 11 | +/// High-level service for OAuth account linking and user provisioning. | |
| 11 | 12 | pub struct OAuthService; |
| 12 | 13 | |
| 13 | 14 | impl OAuthService { |
@@ -123,7 +124,9 @@ impl OAuthService {
| 123 | 124 | info: &ProviderUserInfo, |
| 124 | 125 | ) -> Result<(crate::models::user::Model, bool), AppError> { |
| 125 | 126 | // 1. Check existing link |
| 126 | - if let Some(account) = Self::find_oauth_account(db, provider, &info.provider_user_id).await? { | |
| 127 | + if let Some(account) = | |
| 128 | + Self::find_oauth_account(db, provider, &info.provider_user_id).await? | |
| 129 | + { | |
| 127 | 130 | let user = UserService::get_user_by_id(db, account.user_id) |
| 128 | 131 | .await? |
| 129 | 132 | .ok_or_else(|| AppError::Internal("Linked user not found".to_string()))?; |
@@ -149,11 +152,9 @@ impl OAuthService {
| 149 | 152 | } else { |
| 150 | 153 | // 3. Create new user |
| 151 | 154 | is_new_user = true; |
| 152 | - let username = Self::generate_unique_username( | |
| 153 | - db, | |
| 154 | - info.display_name.as_deref().unwrap_or("user"), | |
| 155 | - ) | |
| 156 | - .await?; | |
| 155 | + let username = | |
| 156 | + Self::generate_unique_username(db, info.display_name.as_deref().unwrap_or("user")) | |
| 157 | + .await?; | |
| 157 | 158 | |
| 158 | 159 | let user = UserService::create_user( |
| 159 | 160 | db, |
@@ -203,17 +204,27 @@ impl OAuthService {
| 203 | 204 | sanitized |
| 204 | 205 | }; |
| 205 | 206 | |
| 206 | - if UserService::get_user_by_username(db, &sanitized).await?.is_none() { | |
| 207 | + if UserService::get_user_by_username(db, &sanitized) | |
| 208 | + .await? | |
| 209 | + .is_none() | |
| 210 | + { | |
| 207 | 211 | return Ok(sanitized); |
| 208 | 212 | } |
| 209 | 213 | |
| 210 | 214 | for i in 1..100 { |
| 211 | 215 | let candidate = format!("{}_{}", sanitized, i); |
| 212 | - if UserService::get_user_by_username(db, &candidate).await?.is_none() { | |
| 216 | + if UserService::get_user_by_username(db, &candidate) | |
| 217 | + .await? | |
| 218 | + .is_none() | |
| 219 | + { | |
| 213 | 220 | return Ok(candidate); |
| 214 | 221 | } |
| 215 | 222 | } |
| 216 | 223 | |
| 217 | - Ok(format!("{}_{}", sanitized, &Uuid::new_v4().to_string()[..8])) | |
| 224 | + Ok(format!( | |
| 225 | + "{}_{}", | |
| 226 | + sanitized, | |
| 227 | + &Uuid::new_v4().to_string()[..8] | |
| 228 | + )) | |
| 218 | 229 | } |
| 219 | 230 | } |
M
crates/rustwarden-core/src/services/password_change_service.rs
+16
-72
@@ -12,6 +12,7 @@
| 12 | 12 | use crate::error::AppError; |
| 13 | 13 | use crate::models::password_change_request; |
| 14 | 14 | use crate::services::password_service::PasswordService; |
| 15 | +use crate::services::rate_limit_service::RateLimitService; | |
| 15 | 16 | use crate::services::user_service::UserService; |
| 16 | 17 | use chrono::{Duration, Utc}; |
| 17 | 18 | use sea_orm::{ |
@@ -19,17 +20,12 @@ use sea_orm::{
| 19 | 20 | Set, |
| 20 | 21 | }; |
| 21 | 22 | use sha2::{Digest, Sha256}; |
| 22 | -use std::collections::HashMap; | |
| 23 | -use std::sync::Arc; | |
| 24 | -use std::time::{SystemTime, UNIX_EPOCH}; | |
| 25 | -use tokio::sync::Mutex; | |
| 26 | 23 | use uuid::Uuid; |
| 27 | 24 | |
| 28 | 25 | /// Password change service |
| 29 | 26 | pub struct PasswordChangeService { |
| 30 | - /// Rate limiting: track requests per user_id (user_id -> timestamps of requests) | |
| 31 | - /// Cleaned up periodically to prevent memory leak | |
| 32 | - rate_limit_store: Arc<Mutex<HashMap<Uuid, Vec<SystemTime>>>>, | |
| 27 | + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). | |
| 28 | + rate_limiter: RateLimitService, | |
| 33 | 29 | } |
| 34 | 30 | |
| 35 | 31 | impl Default for PasswordChangeService { |
@@ -42,7 +38,7 @@ impl PasswordChangeService {
| 42 | 38 | /// Create a new PasswordChangeService |
| 43 | 39 | pub fn new() -> Self { |
| 44 | 40 | Self { |
| 45 | - rate_limit_store: Arc::new(Mutex::new(HashMap::new())), | |
| 41 | + rate_limiter: RateLimitService::new(), | |
| 46 | 42 | } |
| 47 | 43 | } |
| 48 | 44 |
@@ -78,8 +74,16 @@ impl PasswordChangeService {
| 78 | 74 | user_id: Uuid, |
| 79 | 75 | new_password: &str, |
| 80 | 76 | ) -> Result<(Uuid, String), AppError> { |
| 81 | - // Check rate limiting (3 requests per hour per user) | |
| 82 | - self.check_rate_limit(user_id).await?; | |
| 77 | + // Check rate limiting (configurable, default 3 requests per hour per user). | |
| 78 | + // Atomic check+record via the shared RateLimitService — a failed downstream | |
| 79 | + // op still consumes a slot, which hardens the flow against spam of invalid | |
| 80 | + // requests. | |
| 81 | + let max = crate::config::tasks::password_change_rate_limit_max() as u32; | |
| 82 | + let window = std::time::Duration::from_secs( | |
| 83 | + crate::config::tasks::password_change_rate_limit_window_secs(), | |
| 84 | + ); | |
| 85 | + self.rate_limiter | |
| 86 | + .check_sliding_window(&format!("pwd_change:{user_id}"), max, window)?; | |
| 83 | 87 | |
| 84 | 88 | // Invalidate any existing pending request for this user |
| 85 | 89 | self.cancel_pending_request(db, user_id).await?; |
@@ -93,7 +97,8 @@ impl PasswordChangeService {
| 93 | 97 | // Hash token with SHA-256 |
| 94 | 98 | let token_hash = Self::hash_token(&token); |
| 95 | 99 | |
| 96 | - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_change_expiration_hours()); | |
| 100 | + let expires_at = | |
| 101 | + Utc::now() + Duration::hours(crate::config::tasks::password_change_expiration_hours()); | |
| 97 | 102 | |
| 98 | 103 | let request = password_change_request::ActiveModel { |
| 99 | 104 | id: Set(Uuid::new_v4()), |
@@ -109,9 +114,6 @@ impl PasswordChangeService {
| 109 | 114 | |
| 110 | 115 | let inserted = request.insert(db).await?; |
| 111 | 116 | |
| 112 | - // Update rate limit | |
| 113 | - self.update_rate_limit(user_id).await; | |
| 114 | - | |
| 115 | 117 | // Feature 026 - T066, T071: Audit logging for password change request creation |
| 116 | 118 | tracing::info!( |
| 117 | 119 | action = "PASSWORD_CHANGE_REQUESTED", |
@@ -467,64 +469,6 @@ impl PasswordChangeService {
| 467 | 469 | hasher.update(token.as_bytes()); |
| 468 | 470 | format!("{:x}", hasher.finalize()) |
| 469 | 471 | } |
| 470 | - | |
| 471 | - /// Check rate limiting for password change requests | |
| 472 | - /// | |
| 473 | - /// Limits: 3 requests per hour per user | |
| 474 | - /// | |
| 475 | - /// # Arguments | |
| 476 | - /// | |
| 477 | - /// * `user_id` - User ID to check | |
| 478 | - /// | |
| 479 | - /// # Returns | |
| 480 | - /// | |
| 481 | - /// Ok(()) if within rate limit | |
| 482 | - /// | |
| 483 | - /// # Errors | |
| 484 | - /// | |
| 485 | - /// Returns `AppError::TooManyRequests` if rate limit exceeded | |
| 486 | - async fn check_rate_limit(&self, user_id: Uuid) -> Result<(), AppError> { | |
| 487 | - let mut store = self.rate_limit_store.lock().await; | |
| 488 | - | |
| 489 | - // Get current time | |
| 490 | - let now = SystemTime::now() | |
| 491 | - .duration_since(UNIX_EPOCH) | |
| 492 | - .unwrap_or_default() | |
| 493 | - .as_secs(); | |
| 494 | - | |
| 495 | - // Get user's request history | |
| 496 | - let requests = store.entry(user_id).or_insert_with(Vec::new); | |
| 497 | - | |
| 498 | - let window_secs = crate::config::tasks::password_change_rate_limit_window_secs(); | |
| 499 | - let cutoff = now.saturating_sub(window_secs); | |
| 500 | - requests.retain(|×tamp| { | |
| 501 | - timestamp | |
| 502 | - .duration_since(UNIX_EPOCH) | |
| 503 | - .unwrap_or_default() | |
| 504 | - .as_secs() | |
| 505 | - > cutoff | |
| 506 | - }); | |
| 507 | - | |
| 508 | - let max_requests = crate::config::tasks::password_change_rate_limit_max(); | |
| 509 | - if requests.len() >= max_requests { | |
| 510 | - return Err(AppError::Validation( | |
| 511 | - "Too many password change requests. Please wait before trying again.".to_string(), | |
| 512 | - )); | |
| 513 | - } | |
| 514 | - | |
| 515 | - Ok(()) | |
| 516 | - } | |
| 517 | - | |
| 518 | - /// Update rate limit tracking after a request | |
| 519 | - /// | |
| 520 | - /// # Arguments | |
| 521 | - /// | |
| 522 | - /// * `user_id` - User ID who made the request | |
| 523 | - async fn update_rate_limit(&self, user_id: Uuid) { | |
| 524 | - let mut store = self.rate_limit_store.lock().await; | |
| 525 | - let requests = store.entry(user_id).or_insert_with(Vec::new); | |
| 526 | - requests.push(SystemTime::now()); | |
| 527 | - } | |
| 528 | 472 | } |
| 529 | 473 | |
| 530 | 474 | #[cfg(test)] |
M
crates/rustwarden-core/src/services/password_reset_service.rs
+18
-52
@@ -11,6 +11,7 @@ use crate::error::AppError;
| 11 | 11 | use crate::models::{password_reset_token, user}; |
| 12 | 12 | use crate::services::email_queue_service::EmailQueueService; |
| 13 | 13 | use crate::services::password_service::PasswordService; |
| 14 | +use crate::services::rate_limit_service::RateLimitService; | |
| 14 | 15 | use crate::services::user_service::UserService; |
| 15 | 16 | use crate::templates::email_templates::PasswordResetEmailTemplate; |
| 16 | 17 | use chrono::{Duration, Utc}; |
@@ -18,18 +19,17 @@ use sea_orm::{
| 18 | 19 | ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, |
| 19 | 20 | }; |
| 20 | 21 | use sha2::{Digest, Sha256}; |
| 21 | -use std::collections::HashMap; | |
| 22 | -use std::sync::Arc; | |
| 23 | -use std::time::{SystemTime, UNIX_EPOCH}; | |
| 24 | -use tokio::sync::Mutex; | |
| 25 | 22 | use uuid::Uuid; |
| 26 | 23 | |
| 24 | +/// Default: 3 reset requests per email per hour. | |
| 25 | +const RESET_RATE_LIMIT_MAX: u32 = 3; | |
| 26 | +const RESET_RATE_LIMIT_WINDOW: std::time::Duration = std::time::Duration::from_secs(3600); | |
| 27 | + | |
| 27 | 28 | /// Password reset service |
| 28 | 29 | pub struct PasswordResetService { |
| 29 | 30 | email_config: EmailConfig, |
| 30 | - /// Rate limiting: track requests per email (email -> timestamp of last request) | |
| 31 | - /// Cleaned up periodically to prevent memory leak | |
| 32 | - rate_limit_store: Arc<Mutex<HashMap<String, Vec<SystemTime>>>>, | |
| 31 | + /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). | |
| 32 | + rate_limiter: RateLimitService, | |
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | impl PasswordResetService { |
@@ -37,7 +37,7 @@ impl PasswordResetService {
| 37 | 37 | pub fn new(email_config: EmailConfig) -> Self { |
| 38 | 38 | Self { |
| 39 | 39 | email_config, |
| 40 | - rate_limit_store: Arc::new(Mutex::new(HashMap::new())), | |
| 40 | + rate_limiter: RateLimitService::new(), | |
| 41 | 41 | } |
| 42 | 42 | } |
| 43 | 43 |
@@ -63,8 +63,14 @@ impl PasswordResetService {
| 63 | 63 | db: &DatabaseConnection, |
| 64 | 64 | email: &str, |
| 65 | 65 | ) -> Result<bool, AppError> { |
| 66 | - // Check rate limiting (3 requests per hour per email) | |
| 67 | - self.check_rate_limit(email).await?; | |
| 66 | + // Check rate limiting (3 requests per hour per email). Atomic check+record: | |
| 67 | + // a failed downstream op still consumes the slot to block abuse of the | |
| 68 | + // reset flow against non-existent accounts. | |
| 69 | + self.rate_limiter.check_sliding_window( | |
| 70 | + &format!("pwd_reset:{email}"), | |
| 71 | + RESET_RATE_LIMIT_MAX, | |
| 72 | + RESET_RATE_LIMIT_WINDOW, | |
| 73 | + )?; | |
| 68 | 74 | |
| 69 | 75 | // Find user by email |
| 70 | 76 | let user = match UserService::get_user_by_email(db, email).await? { |
@@ -84,7 +90,8 @@ impl PasswordResetService {
| 84 | 90 | // Hash token with SHA-256 |
| 85 | 91 | let token_hash = Self::hash_token(&token); |
| 86 | 92 | |
| 87 | - let expires_at = Utc::now() + Duration::hours(crate::config::tasks::password_reset_expiration_hours()); | |
| 93 | + let expires_at = | |
| 94 | + Utc::now() + Duration::hours(crate::config::tasks::password_reset_expiration_hours()); | |
| 88 | 95 | |
| 89 | 96 | let reset_token = password_reset_token::ActiveModel { |
| 90 | 97 | id: Set(Uuid::new_v4()), |
@@ -146,9 +153,6 @@ impl PasswordResetService {
| 146 | 153 | ) |
| 147 | 154 | .await?; |
| 148 | 155 | |
| 149 | - // Update rate limit | |
| 150 | - self.update_rate_limit(email).await; | |
| 151 | - | |
| 152 | 156 | Ok(true) |
| 153 | 157 | } |
| 154 | 158 |
@@ -298,42 +302,4 @@ impl PasswordResetService {
| 298 | 302 | |
| 299 | 303 | Ok(()) |
| 300 | 304 | } |
| 301 | - | |
| 302 | - /// Check rate limiting (3 requests per hour per email) | |
| 303 | - async fn check_rate_limit(&self, email: &str) -> Result<(), AppError> { | |
| 304 | - let mut store = self.rate_limit_store.lock().await; | |
| 305 | - | |
| 306 | - // Clean up old entries (older than 1 hour) | |
| 307 | - let one_hour_ago = SystemTime::now() | |
| 308 | - .duration_since(UNIX_EPOCH) | |
| 309 | - .unwrap_or_default() | |
| 310 | - .as_secs() | |
| 311 | - .saturating_sub(3600); | |
| 312 | - | |
| 313 | - if let Some(requests) = store.get_mut(email) { | |
| 314 | - requests.retain(|&time| { | |
| 315 | - time.duration_since(UNIX_EPOCH) | |
| 316 | - .unwrap_or_default() | |
| 317 | - .as_secs() | |
| 318 | - > one_hour_ago | |
| 319 | - }); | |
| 320 | - } | |
| 321 | - | |
| 322 | - // Check current requests | |
| 323 | - let requests = store.entry(email.to_string()).or_insert_with(Vec::new); | |
| 324 | - if requests.len() >= 3 { | |
| 325 | - return Err(AppError::Validation( | |
| 326 | - "Too many password reset requests. Please try again later.".to_string(), | |
| 327 | - )); | |
| 328 | - } | |
| 329 | - | |
| 330 | - Ok(()) | |
| 331 | - } | |
| 332 | - | |
| 333 | - /// Update rate limit after successful request | |
| 334 | - async fn update_rate_limit(&self, email: &str) { | |
| 335 | - let mut store = self.rate_limit_store.lock().await; | |
| 336 | - let requests = store.entry(email.to_string()).or_insert_with(Vec::new); | |
| 337 | - requests.push(SystemTime::now()); | |
| 338 | - } | |
| 339 | 305 | } |
M
crates/rustwarden-core/src/services/password_service.rs
+7
-3
@@ -1,5 +1,5 @@
| 1 | 1 | use crate::error::AppError; |
| 2 | -use bcrypt::{DEFAULT_COST}; | |
| 2 | +use bcrypt::DEFAULT_COST; | |
| 3 | 3 | use rand::seq::SliceRandom; |
| 4 | 4 | use rand::Rng; |
| 5 | 5 | use std::sync::LazyLock; |
@@ -210,10 +210,14 @@ mod tests {
| 210 | 210 | let hash = PasswordService::hash_password(password).await.unwrap(); |
| 211 | 211 | |
| 212 | 212 | // Verify correct password |
| 213 | - assert!(PasswordService::verify_password(password, &hash).await.unwrap()); | |
| 213 | + assert!(PasswordService::verify_password(password, &hash) | |
| 214 | + .await | |
| 215 | + .unwrap()); | |
| 214 | 216 | |
| 215 | 217 | // Verify incorrect password |
| 216 | - assert!(!PasswordService::verify_password("wrong_password", &hash).await.unwrap()); | |
| 218 | + assert!(!PasswordService::verify_password("wrong_password", &hash) | |
| 219 | + .await | |
| 220 | + .unwrap()); | |
| 217 | 221 | } |
| 218 | 222 | |
| 219 | 223 | #[test] |
M
crates/rustwarden-core/src/services/permission_service.rs
+10
-13
@@ -1,21 +1,20 @@
| 1 | 1 | use crate::error::AppError; |
| 2 | 2 | use crate::models::{permission, role_permission, user_role}; |
| 3 | 3 | use chrono::Utc; |
| 4 | -use sea_orm::{ | |
| 5 | - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set, | |
| 6 | -}; | |
| 4 | +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; | |
| 7 | 5 | use uuid::Uuid; |
| 8 | 6 | |
| 7 | +/// Service for managing permissions and role-permission assignments. | |
| 9 | 8 | pub struct PermissionService; |
| 10 | 9 | |
| 11 | 10 | impl PermissionService { |
| 12 | - pub async fn list_all( | |
| 13 | - db: &DatabaseConnection, | |
| 14 | - ) -> Result<Vec<permission::Model>, AppError> { | |
| 11 | + /// Return all permissions stored in the database. | |
| 12 | + pub async fn list_all(db: &DatabaseConnection) -> Result<Vec<permission::Model>, AppError> { | |
| 15 | 13 | let perms = permission::Entity::find().all(db).await?; |
| 16 | 14 | Ok(perms) |
| 17 | 15 | } |
| 18 | 16 | |
| 17 | + /// Return a single permission by its UUID, or `None` if not found. | |
| 19 | 18 | pub async fn get_by_id( |
| 20 | 19 | db: &DatabaseConnection, |
| 21 | 20 | id: Uuid, |
@@ -24,6 +23,7 @@ impl PermissionService {
| 24 | 23 | Ok(perm) |
| 25 | 24 | } |
| 26 | 25 | |
| 26 | + /// Return a single permission by its unique name, or `None` if not found. | |
| 27 | 27 | pub async fn get_by_name( |
| 28 | 28 | db: &DatabaseConnection, |
| 29 | 29 | name: &str, |
@@ -35,6 +35,7 @@ impl PermissionService {
| 35 | 35 | Ok(perm) |
| 36 | 36 | } |
| 37 | 37 | |
| 38 | + /// Create and persist a new permission. | |
| 38 | 39 | pub async fn create( |
| 39 | 40 | db: &DatabaseConnection, |
| 40 | 41 | name: String, |
@@ -52,10 +53,8 @@ impl PermissionService {
| 52 | 53 | Ok(result) |
| 53 | 54 | } |
| 54 | 55 | |
| 55 | - pub async fn delete( | |
| 56 | - db: &DatabaseConnection, | |
| 57 | - id: Uuid, | |
| 58 | - ) -> Result<(), AppError> { | |
| 56 | + /// Delete a permission by its UUID. | |
| 57 | + pub async fn delete(db: &DatabaseConnection, id: Uuid) -> Result<(), AppError> { | |
| 59 | 58 | permission::Entity::delete_by_id(id).exec(db).await?; |
| 60 | 59 | Ok(()) |
| 61 | 60 | } |
@@ -142,9 +141,7 @@ impl PermissionService {
| 142 | 141 | } |
| 143 | 142 | |
| 144 | 143 | /// Seed default permissions (idempotent). |
| 145 | - pub async fn initialize_default_permissions( | |
| 146 | - db: &DatabaseConnection, | |
| 147 | - ) -> Result<(), AppError> { | |
| 144 | + pub async fn initialize_default_permissions(db: &DatabaseConnection) -> Result<(), AppError> { | |
| 148 | 145 | let defaults = vec![ |
| 149 | 146 | ("users.list", "View the list of users", "users"), |
| 150 | 147 | ("users.create", "Create new users", "users"), |
A
crates/rustwarden-core/src/services/personal_token_service.rs
+582
-0
@@ -0,0 +1,582 @@
| 1 | +//! Personal access token service. | |
| 2 | +//! | |
| 3 | +//! Generic over a user-defined [`TokenScope`] so each consumer app keeps a | |
| 4 | +//! type-safe vocabulary (e.g. gitrust → `RepoScope`, PasterMan → `WallScope`) | |
| 5 | +//! while sharing the hash/verify/revoke/cleanup plumbing. | |
| 6 | +//! | |
| 7 | +//! # Security | |
| 8 | +//! | |
| 9 | +//! - Token plaintext is returned once from [`PersonalTokenService::create`] | |
| 10 | +//! and never persisted. Only the SHA-256 digest is stored. | |
| 11 | +//! - [`PersonalTokenService::verify`] uses a constant-time comparison on the | |
| 12 | +//! hash (via [`subtle`]) to reject timing oracles. | |
| 13 | +//! - Expired and revoked tokens refuse authentication without leaking the | |
| 14 | +//! reason (uniform `AppError::InvalidToken`). | |
| 15 | +//! | |
| 16 | +//! # Example | |
| 17 | +//! | |
| 18 | +//! ```rust,ignore | |
| 19 | +//! use rustwarden_core::services::personal_token_service::{ | |
| 20 | +//! PersonalTokenService, TokenScope, | |
| 21 | +//! }; | |
| 22 | +//! | |
| 23 | +//! #[derive(Clone, Debug)] | |
| 24 | +//! enum RepoScope { | |
| 25 | +//! Read, | |
| 26 | +//! Write, | |
| 27 | +//! } | |
| 28 | +//! | |
| 29 | +//! impl TokenScope for RepoScope { | |
| 30 | +//! fn as_str(&self) -> &str { | |
| 31 | +//! match self { | |
| 32 | +//! RepoScope::Read => "read:repo", | |
| 33 | +//! RepoScope::Write => "write:repo", | |
| 34 | +//! } | |
| 35 | +//! } | |
| 36 | +//! fn parse(s: &str) -> Option<Self> { | |
| 37 | +//! match s { | |
| 38 | +//! "read:repo" => Some(Self::Read), | |
| 39 | +//! "write:repo" => Some(Self::Write), | |
| 40 | +//! _ => None, | |
| 41 | +//! } | |
| 42 | +//! } | |
| 43 | +//! } | |
| 44 | +//! ``` | |
| 45 | + | |
| 46 | +use crate::error::AppError; | |
| 47 | +use crate::models::personal_access_token; | |
| 48 | +use chrono::{DateTime, Utc}; | |
| 49 | +use rand::RngCore; | |
| 50 | +use sea_orm::{ | |
| 51 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, | |
| 52 | +}; | |
| 53 | +use sha2::{Digest, Sha256}; | |
| 54 | +use std::marker::PhantomData; | |
| 55 | +use subtle::ConstantTimeEq; | |
| 56 | +use uuid::Uuid; | |
| 57 | + | |
| 58 | +/// Scope vocabulary implemented by each consumer app. | |
| 59 | +/// | |
| 60 | +/// Scopes are stored as their `as_str()` representation in the DB; a token | |
| 61 | +/// created with an unknown-to-the-app scope string is silently dropped from | |
| 62 | +/// the parsed list returned by [`PersonalTokenService::verify`], so rolling | |
| 63 | +/// back a scope definition does not invalidate prior tokens outright — they | |
| 64 | +/// just lose the removed capability. | |
| 65 | +pub trait TokenScope: Sized + Clone + Send + Sync + 'static { | |
| 66 | + /// Canonical string representation (e.g. `"read:repo"`). | |
| 67 | + fn as_str(&self) -> &str; | |
| 68 | + /// Parse back from the canonical string. Returns `None` for unknown values. | |
| 69 | + fn parse(s: &str) -> Option<Self>; | |
| 70 | +} | |
| 71 | + | |
| 72 | +/// Personal access token service parameterised by a scope vocabulary. | |
| 73 | +/// | |
| 74 | +/// All methods are associated fns (no state) — the `PhantomData<S>` only | |
| 75 | +/// binds the scope type for the trait impl. Construct as | |
| 76 | +/// `PersonalTokenService::<MyScope>::create(...)` or keep a type alias. | |
| 77 | +pub struct PersonalTokenService<S: TokenScope> { | |
| 78 | + _phantom: PhantomData<S>, | |
| 79 | +} | |
| 80 | + | |
| 81 | +impl<S: TokenScope> PersonalTokenService<S> { | |
| 82 | + /// Create a new PAT and return the (model, plaintext) pair. | |
| 83 | + /// | |
| 84 | + /// The plaintext is shown once to the user and MUST NOT be persisted | |
| 85 | + /// elsewhere by the caller — only its SHA-256 digest lives in the DB. | |
| 86 | + /// | |
| 87 | + /// # Errors | |
| 88 | + /// | |
| 89 | + /// - `AppError::Validation` if `name` is empty. | |
| 90 | + /// - `AppError::Database` on insertion failure (FK violation, unique | |
| 91 | + /// constraint on `(user_id, name)` or `token_hash`). | |
| 92 | + pub async fn create( | |
| 93 | + db: &DatabaseConnection, | |
| 94 | + user_id: Uuid, | |
| 95 | + name: &str, | |
| 96 | + scopes: &[S], | |
| 97 | + expires_at: Option<DateTime<Utc>>, | |
| 98 | + ) -> Result<(personal_access_token::Model, String), AppError> { | |
| 99 | + if name.trim().is_empty() { | |
| 100 | + return Err(AppError::Validation( | |
| 101 | + "Token name cannot be empty".to_string(), | |
| 102 | + )); | |
| 103 | + } | |
| 104 | + | |
| 105 | + let plain = generate_token_plain(); | |
| 106 | + let token_hash = hash_token(&plain); | |
| 107 | + let scope_strings: Vec<String> = scopes.iter().map(|s| s.as_str().to_string()).collect(); | |
| 108 | + | |
| 109 | + let active = personal_access_token::ActiveModel { | |
| 110 | + id: Set(Uuid::new_v4()), | |
| 111 | + user_id: Set(user_id), | |
| 112 | + name: Set(name.to_string()), | |
| 113 | + token_hash: Set(token_hash), | |
| 114 | + scopes: Set(scope_strings), | |
| 115 | + expires_at: Set(expires_at), | |
| 116 | + last_used_at: NotSet, | |
| 117 | + revoked_at: NotSet, | |
| 118 | + created_at: Set(Utc::now()), | |
| 119 | + }; | |
| 120 | + | |
| 121 | + let model = active.insert(db).await?; | |
| 122 | + Ok((model, plain)) | |
| 123 | + } | |
| 124 | + | |
| 125 | + /// Verify a plaintext token and return the record plus parsed scopes. | |
| 126 | + /// | |
| 127 | + /// Touches `last_used_at` on success (best-effort; update failure only | |
| 128 | + /// logs a warning). Rejects tokens that are revoked, expired, or whose | |
| 129 | + /// hash does not match anything in the DB — always with | |
| 130 | + /// [`AppError::InvalidToken`] to avoid leaking which condition failed. | |
| 131 | + pub async fn verify( | |
| 132 | + db: &DatabaseConnection, | |
| 133 | + token_clear: &str, | |
| 134 | + ) -> Result<(personal_access_token::Model, Vec<S>), AppError> { | |
| 135 | + if token_clear.is_empty() { | |
| 136 | + return Err(AppError::InvalidToken); | |
| 137 | + } | |
| 138 | + | |
| 139 | + let candidate_hash = hash_token(token_clear); | |
| 140 | + let row = personal_access_token::Entity::find() | |
| 141 | + .filter(personal_access_token::Column::TokenHash.eq(&candidate_hash)) | |
| 142 | + .one(db) | |
| 143 | + .await? | |
| 144 | + .ok_or(AppError::InvalidToken)?; | |
| 145 | + | |
| 146 | + // Constant-time re-check — the SQL lookup already matched on the hash, | |
| 147 | + // but this guards against DB-side comparison being variable-time. | |
| 148 | + if !bool::from(row.token_hash.as_bytes().ct_eq(candidate_hash.as_bytes())) { | |
| 149 | + return Err(AppError::InvalidToken); | |
| 150 | + } | |
| 151 | + | |
| 152 | + if !row.is_active() { | |
| 153 | + return Err(AppError::InvalidToken); | |
| 154 | + } | |
| 155 | + | |
| 156 | + // Best-effort last_used_at update (don't block auth on slow write). | |
| 157 | + let id = row.id; | |
| 158 | + let mut touched: personal_access_token::ActiveModel = row.clone().into(); | |
| 159 | + touched.last_used_at = Set(Some(Utc::now())); | |
| 160 | + if let Err(e) = touched.update(db).await { | |
| 161 | + tracing::warn!(token_id = %id, "PAT last_used_at update failed: {}", e); | |
| 162 | + } | |
| 163 | + | |
| 164 | + let scopes: Vec<S> = row.scopes.iter().filter_map(|s| S::parse(s)).collect(); | |
| 165 | + Ok((row, scopes)) | |
| 166 | + } | |
| 167 | + | |
| 168 | + /// List all non-revoked PATs for a user (most recent first). | |
| 169 | + pub async fn list_for_user( | |
| 170 | + db: &DatabaseConnection, | |
| 171 | + user_id: Uuid, | |
| 172 | + ) -> Result<Vec<personal_access_token::Model>, AppError> { | |
| 173 | + use sea_orm::QueryOrder; | |
| 174 | + let rows = personal_access_token::Entity::find() | |
| 175 | + .filter(personal_access_token::Column::UserId.eq(user_id)) | |
| 176 | + .filter(personal_access_token::Column::RevokedAt.is_null()) | |
| 177 | + .order_by_desc(personal_access_token::Column::CreatedAt) | |
| 178 | + .all(db) | |
| 179 | + .await?; | |
| 180 | + Ok(rows) | |
| 181 | + } | |
| 182 | + | |
| 183 | + /// Revoke a PAT by id. Idempotent: revoking an already-revoked token is | |
| 184 | + /// a no-op. Refuses if the token does not belong to `user_id` (ownership | |
| 185 | + /// enforcement — otherwise an attacker knowing the UUID could revoke | |
| 186 | + /// another user's token). | |
| 187 | + pub async fn revoke( | |
| 188 | + db: &DatabaseConnection, | |
| 189 | + token_id: Uuid, | |
| 190 | + user_id: Uuid, | |
| 191 | + ) -> Result<(), AppError> { | |
| 192 | + let row = personal_access_token::Entity::find_by_id(token_id) | |
| 193 | + .one(db) | |
| 194 | + .await? | |
| 195 | + .ok_or_else(|| AppError::NotFound(format!("PAT {token_id}")))?; | |
| 196 | + | |
| 197 | + if row.user_id != user_id { | |
| 198 | + return Err(AppError::Forbidden); | |
| 199 | + } | |
| 200 | + | |
| 201 | + if row.revoked_at.is_some() { | |
| 202 | + return Ok(()); // idempotent | |
| 203 | + } | |
| 204 | + | |
| 205 | + let mut active: personal_access_token::ActiveModel = row.into(); | |
| 206 | + active.revoked_at = Set(Some(Utc::now())); | |
| 207 | + active.update(db).await?; | |
| 208 | + Ok(()) | |
| 209 | + } | |
| 210 | + | |
| 211 | + /// Background cleanup: hard-delete tokens that are both expired AND | |
| 212 | + /// revoked (either condition alone is kept for audit window). Returns | |
| 213 | + /// the number of rows removed. | |
| 214 | + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64, AppError> { | |
| 215 | + let now = Utc::now(); | |
| 216 | + let result = personal_access_token::Entity::delete_many() | |
| 217 | + .filter( | |
| 218 | + sea_orm::Condition::all() | |
| 219 | + .add(personal_access_token::Column::ExpiresAt.is_not_null()) | |
| 220 | + .add(personal_access_token::Column::ExpiresAt.lt(now)) | |
| 221 | + .add(personal_access_token::Column::RevokedAt.is_not_null()), | |
| 222 | + ) | |
| 223 | + .exec(db) | |
| 224 | + .await?; | |
| 225 | + Ok(result.rows_affected) | |
| 226 | + } | |
| 227 | +} | |
| 228 | + | |
| 229 | +/// Generate a fresh 32-byte token encoded as 64 hex chars (URL-safe). | |
| 230 | +fn generate_token_plain() -> String { | |
| 231 | + let mut bytes = [0u8; 32]; | |
| 232 | + rand::rng().fill_bytes(&mut bytes); | |
| 233 | + hex::encode(bytes) | |
| 234 | +} | |
| 235 | + | |
| 236 | +/// SHA-256 hex of the plaintext token — deterministic indexing key. | |
| 237 | +fn hash_token(plain: &str) -> String { | |
| 238 | + let mut hasher = Sha256::new(); | |
| 239 | + hasher.update(plain.as_bytes()); | |
| 240 | + hex::encode(hasher.finalize()) | |
| 241 | +} | |
| 242 | + | |
| 243 | +#[cfg(test)] | |
| 244 | +mod tests { | |
| 245 | + use super::*; | |
| 246 | + use crate::services::password_service::PasswordService; | |
| 247 | + use crate::services::user_service::UserService; | |
| 248 | + use chrono::Duration; | |
| 249 | + | |
| 250 | + /// Minimal scope type exercising the generic bounds. | |
| 251 | + #[derive(Clone, Debug, PartialEq, Eq)] | |
| 252 | + enum TestScope { | |
| 253 | + Read, | |
| 254 | + Write, | |
| 255 | + } | |
| 256 | + | |
| 257 | + impl TokenScope for TestScope { | |
| 258 | + fn as_str(&self) -> &str { | |
| 259 | + match self { | |
| 260 | + Self::Read => "test:read", | |
| 261 | + Self::Write => "test:write", | |
| 262 | + } | |
| 263 | + } | |
| 264 | + fn parse(s: &str) -> Option<Self> { | |
| 265 | + match s { | |
| 266 | + "test:read" => Some(Self::Read), | |
| 267 | + "test:write" => Some(Self::Write), | |
| 268 | + _ => None, | |
| 269 | + } | |
| 270 | + } | |
| 271 | + } | |
| 272 | + | |
| 273 | + /// Per-test connection. A shared `OnceCell<DatabaseConnection>` cannot be | |
| 274 | + /// reused across `#[tokio::test]` calls because each creates its own | |
| 275 | + /// runtime; the pool captured by the first test outlives it and later | |
| 276 | + /// tests see "Tokio 1.x context being shutdown". Re-connecting per test | |
| 277 | + /// costs ~10ms on localhost Postgres — worth the isolation. | |
| 278 | + async fn test_db() -> DatabaseConnection { | |
| 279 | + dotenvy::dotenv().ok(); | |
| 280 | + let url = std::env::var("DATABASE_URL") | |
| 281 | + .expect("DATABASE_URL required for personal_token_service tests"); | |
| 282 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 283 | + } | |
| 284 | + | |
| 285 | + /// Isolates each test with a unique user. Avoids polluting the dev DB. | |
| 286 | + async fn fresh_user(db: &DatabaseConnection) -> Uuid { | |
| 287 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 288 | + let hash = PasswordService::hash_password("NotReallyUsed123!") | |
| 289 | + .await | |
| 290 | + .unwrap(); | |
| 291 | + let user = UserService::create_user( | |
| 292 | + db, | |
| 293 | + format!("pat_test_{}", &unique[..12]), | |
| 294 | + format!("pat_test_{}@example.com", &unique[..12]), | |
| 295 | + hash, | |
| 296 | + false, | |
| 297 | + ) | |
| 298 | + .await | |
| 299 | + .unwrap(); | |
| 300 | + user.id | |
| 301 | + } | |
| 302 | + | |
| 303 | + #[tokio::test] | |
| 304 | + async fn create_returns_plaintext_and_persists_hash_only() { | |
| 305 | + let db = test_db().await; | |
| 306 | + let user_id = fresh_user(&db).await; | |
| 307 | + | |
| 308 | + let (record, plain) = PersonalTokenService::<TestScope>::create( | |
| 309 | + &db, | |
| 310 | + user_id, | |
| 311 | + "ci-bot", | |
| 312 | + &[TestScope::Read], | |
| 313 | + None, | |
| 314 | + ) | |
| 315 | + .await | |
| 316 | + .unwrap(); | |
| 317 | + | |
| 318 | + assert_eq!(plain.len(), 64, "token should be 32 bytes hex"); | |
| 319 | + assert_ne!( | |
| 320 | + record.token_hash, plain, | |
| 321 | + "DB must store hash, not plaintext" | |
| 322 | + ); | |
| 323 | + assert_eq!(record.token_hash.len(), 64, "SHA-256 hex is 64 chars"); | |
| 324 | + assert_eq!(record.scopes, vec!["test:read".to_string()]); | |
| 325 | + assert!(record.expires_at.is_none()); | |
| 326 | + assert!(record.last_used_at.is_none()); | |
| 327 | + assert!(record.revoked_at.is_none()); | |
| 328 | + } | |
| 329 | + | |
| 330 | + #[tokio::test] | |
| 331 | + async fn create_rejects_empty_name() { | |
| 332 | + let db = test_db().await; | |
| 333 | + let user_id = fresh_user(&db).await; | |
| 334 | + let err = PersonalTokenService::<TestScope>::create(&db, user_id, " ", &[], None) | |
| 335 | + .await | |
| 336 | + .unwrap_err(); | |
| 337 | + matches!(err, AppError::Validation(_)); | |
| 338 | + } | |
| 339 | + | |
| 340 | + #[tokio::test] | |
| 341 | + async fn verify_accepts_valid_token_and_updates_last_used_at() { | |
| 342 | + let db = test_db().await; | |
| 343 | + let user_id = fresh_user(&db).await; | |
| 344 | + let (record, plain) = PersonalTokenService::<TestScope>::create( | |
| 345 | + &db, | |
| 346 | + user_id, | |
| 347 | + "ci-verify", | |
| 348 | + &[TestScope::Read, TestScope::Write], | |
| 349 | + None, | |
| 350 | + ) | |
| 351 | + .await | |
| 352 | + .unwrap(); | |
| 353 | + | |
| 354 | + let (verified, scopes) = PersonalTokenService::<TestScope>::verify(&db, &plain) | |
| 355 | + .await | |
| 356 | + .unwrap(); | |
| 357 | + | |
| 358 | + assert_eq!(verified.id, record.id); | |
| 359 | + assert_eq!( | |
| 360 | + scopes, | |
| 361 | + vec![TestScope::Read, TestScope::Write], | |
| 362 | + "scopes must round-trip through parse()" | |
| 363 | + ); | |
| 364 | + | |
| 365 | + // Reload to assert last_used_at was actually persisted. | |
| 366 | + let fresh = personal_access_token::Entity::find_by_id(record.id) | |
| 367 | + .one(&db) | |
| 368 | + .await | |
| 369 | + .unwrap() | |
| 370 | + .unwrap(); | |
| 371 | + assert!( | |
| 372 | + fresh.last_used_at.is_some(), | |
| 373 | + "last_used_at must be set after verify()" | |
| 374 | + ); | |
| 375 | + } | |
| 376 | + | |
| 377 | + #[tokio::test] | |
| 378 | + async fn verify_rejects_unknown_token() { | |
| 379 | + let db = test_db().await; | |
| 380 | + let err = | |
| 381 | + PersonalTokenService::<TestScope>::verify(&db, "deadbeefdeadbeefdeadbeefdeadbeef") | |
| 382 | + .await | |
| 383 | + .unwrap_err(); | |
| 384 | + assert!(matches!(err, AppError::InvalidToken)); | |
| 385 | + } | |
| 386 | + | |
| 387 | + #[tokio::test] | |
| 388 | + async fn verify_rejects_revoked_token() { | |
| 389 | + let db = test_db().await; | |
| 390 | + let user_id = fresh_user(&db).await; | |
| 391 | + let (record, plain) = PersonalTokenService::<TestScope>::create( | |
| 392 | + &db, | |
| 393 | + user_id, | |
| 394 | + "ci-revoke", | |
| 395 | + &[TestScope::Read], | |
| 396 | + None, | |
| 397 | + ) | |
| 398 | + .await | |
| 399 | + .unwrap(); | |
| 400 | + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) | |
| 401 | + .await | |
| 402 | + .unwrap(); | |
| 403 | + | |
| 404 | + let err = PersonalTokenService::<TestScope>::verify(&db, &plain) | |
| 405 | + .await | |
| 406 | + .unwrap_err(); | |
| 407 | + assert!(matches!(err, AppError::InvalidToken)); | |
| 408 | + } | |
| 409 | + | |
| 410 | + #[tokio::test] | |
| 411 | + async fn verify_rejects_expired_token() { | |
| 412 | + let db = test_db().await; | |
| 413 | + let user_id = fresh_user(&db).await; | |
| 414 | + let (_record, plain) = PersonalTokenService::<TestScope>::create( | |
| 415 | + &db, | |
| 416 | + user_id, | |
| 417 | + "ci-expired", | |
| 418 | + &[TestScope::Read], | |
| 419 | + Some(Utc::now() - Duration::seconds(1)), | |
| 420 | + ) | |
| 421 | + .await | |
| 422 | + .unwrap(); | |
| 423 | + | |
| 424 | + let err = PersonalTokenService::<TestScope>::verify(&db, &plain) | |
| 425 | + .await | |
| 426 | + .unwrap_err(); | |
| 427 | + assert!(matches!(err, AppError::InvalidToken)); | |
| 428 | + } | |
| 429 | + | |
| 430 | + #[tokio::test] | |
| 431 | + async fn list_for_user_returns_only_active_tokens() { | |
| 432 | + let db = test_db().await; | |
| 433 | + let user_id = fresh_user(&db).await; | |
| 434 | + | |
| 435 | + let (t1, _) = | |
| 436 | + PersonalTokenService::<TestScope>::create(&db, user_id, "t1", &[TestScope::Read], None) | |
| 437 | + .await | |
| 438 | + .unwrap(); | |
| 439 | + let (t2, _) = PersonalTokenService::<TestScope>::create( | |
| 440 | + &db, | |
| 441 | + user_id, | |
| 442 | + "t2", | |
| 443 | + &[TestScope::Write], | |
| 444 | + None, | |
| 445 | + ) | |
| 446 | + .await | |
| 447 | + .unwrap(); | |
| 448 | + | |
| 449 | + PersonalTokenService::<TestScope>::revoke(&db, t2.id, user_id) | |
| 450 | + .await | |
| 451 | + .unwrap(); | |
| 452 | + | |
| 453 | + let listed = PersonalTokenService::<TestScope>::list_for_user(&db, user_id) | |
| 454 | + .await | |
| 455 | + .unwrap(); | |
| 456 | + assert_eq!(listed.len(), 1, "revoked token must not appear in list"); | |
| 457 | + assert_eq!(listed[0].id, t1.id); | |
| 458 | + } | |
| 459 | + | |
| 460 | + #[tokio::test] | |
| 461 | + async fn revoke_refuses_other_users_tokens() { | |
| 462 | + let db = test_db().await; | |
| 463 | + let owner = fresh_user(&db).await; | |
| 464 | + let attacker = fresh_user(&db).await; | |
| 465 | + let (record, _) = PersonalTokenService::<TestScope>::create( | |
| 466 | + &db, | |
| 467 | + owner, | |
| 468 | + "owned", | |
| 469 | + &[TestScope::Read], | |
| 470 | + None, | |
| 471 | + ) | |
| 472 | + .await | |
| 473 | + .unwrap(); | |
| 474 | + | |
| 475 | + let err = PersonalTokenService::<TestScope>::revoke(&db, record.id, attacker) | |
| 476 | + .await | |
| 477 | + .unwrap_err(); | |
| 478 | + assert!(matches!(err, AppError::Forbidden)); | |
| 479 | + | |
| 480 | + // Token must still be usable — cross-user revoke must not have fired. | |
| 481 | + let reloaded = personal_access_token::Entity::find_by_id(record.id) | |
| 482 | + .one(&db) | |
| 483 | + .await | |
| 484 | + .unwrap() | |
| 485 | + .unwrap(); | |
| 486 | + assert!(reloaded.revoked_at.is_none()); | |
| 487 | + } | |
| 488 | + | |
| 489 | + #[tokio::test] | |
| 490 | + async fn revoke_is_idempotent() { | |
| 491 | + let db = test_db().await; | |
| 492 | + let user_id = fresh_user(&db).await; | |
| 493 | + let (record, _) = PersonalTokenService::<TestScope>::create( | |
| 494 | + &db, | |
| 495 | + user_id, | |
| 496 | + "idempotent", | |
| 497 | + &[TestScope::Read], | |
| 498 | + None, | |
| 499 | + ) | |
| 500 | + .await | |
| 501 | + .unwrap(); | |
| 502 | + | |
| 503 | + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) | |
| 504 | + .await | |
| 505 | + .unwrap(); | |
| 506 | + PersonalTokenService::<TestScope>::revoke(&db, record.id, user_id) | |
| 507 | + .await | |
| 508 | + .expect("second revoke must be a no-op"); | |
| 509 | + } | |
| 510 | + | |
| 511 | + #[tokio::test] | |
| 512 | + async fn cleanup_expired_removes_only_expired_and_revoked_rows() { | |
| 513 | + let db = test_db().await; | |
| 514 | + let user_id = fresh_user(&db).await; | |
| 515 | + | |
| 516 | + // 1. Expired + revoked → should be deleted. | |
| 517 | + let (expired_revoked, _) = PersonalTokenService::<TestScope>::create( | |
| 518 | + &db, | |
| 519 | + user_id, | |
| 520 | + "expired-revoked", | |
| 521 | + &[TestScope::Read], | |
| 522 | + Some(Utc::now() - Duration::hours(1)), | |
| 523 | + ) | |
| 524 | + .await | |
| 525 | + .unwrap(); | |
| 526 | + PersonalTokenService::<TestScope>::revoke(&db, expired_revoked.id, user_id) | |
| 527 | + .await | |
| 528 | + .unwrap(); | |
| 529 | + | |
| 530 | + // 2. Expired only (no revoke) → keep for audit. | |
| 531 | + let (expired_only, _) = PersonalTokenService::<TestScope>::create( | |
| 532 | + &db, | |
| 533 | + user_id, | |
| 534 | + "expired-only", | |
| 535 | + &[TestScope::Read], | |
| 536 | + Some(Utc::now() - Duration::hours(1)), | |
| 537 | + ) | |
| 538 | + .await | |
| 539 | + .unwrap(); | |
| 540 | + | |
| 541 | + // 3. Active → must stay. | |
| 542 | + let (active, _) = PersonalTokenService::<TestScope>::create( | |
| 543 | + &db, | |
| 544 | + user_id, | |
| 545 | + "active", | |
| 546 | + &[TestScope::Read], | |
| 547 | + None, | |
| 548 | + ) | |
| 549 | + .await | |
| 550 | + .unwrap(); | |
| 551 | + | |
| 552 | + let removed = PersonalTokenService::<TestScope>::cleanup_expired(&db) | |
| 553 | + .await | |
| 554 | + .unwrap(); | |
| 555 | + assert!(removed >= 1); | |
| 556 | + | |
| 557 | + assert!( | |
| 558 | + personal_access_token::Entity::find_by_id(expired_revoked.id) | |
| 559 | + .one(&db) | |
| 560 | + .await | |
| 561 | + .unwrap() | |
| 562 | + .is_none(), | |
| 563 | + "expired+revoked must be deleted" | |
| 564 | + ); | |
| 565 | + assert!( | |
| 566 | + personal_access_token::Entity::find_by_id(expired_only.id) | |
| 567 | + .one(&db) | |
| 568 | + .await | |
| 569 | + .unwrap() | |
| 570 | + .is_some(), | |
| 571 | + "expired-only must be kept for audit" | |
| 572 | + ); | |
| 573 | + assert!( | |
| 574 | + personal_access_token::Entity::find_by_id(active.id) | |
| 575 | + .one(&db) | |
| 576 | + .await | |
| 577 | + .unwrap() | |
| 578 | + .is_some(), | |
| 579 | + "active token must not be touched" | |
| 580 | + ); | |
| 581 | + } | |
| 582 | +} |
A
crates/rustwarden-core/src/services/rate_limit_service.rs
+317
-0
@@ -0,0 +1,317 @@
| 1 | +//! Generic in-process rate limiter. | |
| 2 | +//! | |
| 3 | +//! Tracks request counts per arbitrary key over a sliding window. Used | |
| 4 | +//! internally by authentication, public share, and password flow rate | |
| 5 | +//! limiters, and exposed publicly for apps that need to rate-limit their | |
| 6 | +//! own endpoints (imports, webhooks, etc.). | |
| 7 | +//! | |
| 8 | +//! # Example | |
| 9 | +//! | |
| 10 | +//! ```no_run | |
| 11 | +//! use std::sync::Arc; | |
| 12 | +//! use rustwarden_core::services::rate_limit_service::RateLimitService; | |
| 13 | +//! | |
| 14 | +//! let limiter = Arc::new(RateLimitService::new()); | |
| 15 | +//! // Reject after 10 calls / minute per IP: | |
| 16 | +//! limiter.check(&format!("import:{}", "1.2.3.4"), 10).unwrap(); | |
| 17 | +//! ``` | |
| 18 | +//! | |
| 19 | +//! # Limitations | |
| 20 | +//! | |
| 21 | +//! - In-process only. Multi-instance deployments need a shared backend | |
| 22 | +//! (Redis) — out of scope for v1. | |
| 23 | +//! - Memory-bounded via `MAX_ENTRIES` (dropped if the map grows past the | |
| 24 | +//! cap and the incoming key is unknown). | |
| 25 | + | |
| 26 | +use std::{ | |
| 27 | + collections::HashMap, | |
| 28 | + sync::{Arc, Mutex}, | |
| 29 | + time::{Duration, Instant}, | |
| 30 | +}; | |
| 31 | + | |
| 32 | +use crate::error::AppError; | |
| 33 | + | |
| 34 | +/// Maximum distinct keys tracked before refusing new ones (SEC-M2: memory DoS). | |
| 35 | +const MAX_ENTRIES: usize = 100_000; | |
| 36 | + | |
| 37 | +/// Per-key counter plus the instant at which the current window opened. | |
| 38 | +#[derive(Debug, Clone, Copy)] | |
| 39 | +struct WindowCounter { | |
| 40 | + count: u32, | |
| 41 | + window_start: Instant, | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Thread-safe rate limiter keyed by arbitrary strings. | |
| 45 | +/// | |
| 46 | +/// Supports two semantics: | |
| 47 | +/// | |
| 48 | +/// - [`check`]/[`check_window`]: fixed-window counter (resets at each window | |
| 49 | +/// boundary). Cheap, single integer per key. | |
| 50 | +/// - [`check_sliding_window`]: true sliding window (stores each attempt | |
| 51 | +/// timestamp). Stricter across boundaries; preferred for security-sensitive | |
| 52 | +/// flows like password reset throttling. | |
| 53 | +/// | |
| 54 | +/// Uses a single `Mutex<HashMap>`; contention is acceptable because each | |
| 55 | +/// check is a handful of microseconds. A sharded `DashMap` backend can be | |
| 56 | +/// swapped in later without API change. | |
| 57 | +/// | |
| 58 | +/// [`check`]: Self::check | |
| 59 | +/// [`check_window`]: Self::check_window | |
| 60 | +/// [`check_sliding_window`]: Self::check_sliding_window | |
| 61 | +#[derive(Clone, Default)] | |
| 62 | +pub struct RateLimitService { | |
| 63 | + buckets: Arc<Mutex<HashMap<String, WindowCounter>>>, | |
| 64 | + sliding: Arc<Mutex<HashMap<String, Vec<Instant>>>>, | |
| 65 | +} | |
| 66 | + | |
| 67 | +impl RateLimitService { | |
| 68 | + /// Create a new `RateLimitService` with empty tracking maps. | |
| 69 | + pub fn new() -> Self { | |
| 70 | + Self::default() | |
| 71 | + } | |
| 72 | + | |
| 73 | + /// Check and increment the counter for `key`. Returns `Err(TooManyRequests)` | |
| 74 | + /// with a `Retry-After` hint when `max_per_minute` is exceeded inside the | |
| 75 | + /// rolling 60-second window. | |
| 76 | + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError> { | |
| 77 | + self.check_window(key, max_per_minute, Duration::from_secs(60)) | |
| 78 | + } | |
| 79 | + | |
| 80 | + /// Check and increment the counter for `key` with a custom window size. | |
| 81 | + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError> { | |
| 82 | + let now = Instant::now(); | |
| 83 | + let mut buckets = self | |
| 84 | + .buckets | |
| 85 | + .lock() | |
| 86 | + .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?; | |
| 87 | + | |
| 88 | + // Drop expired entries so the map stays bounded on low-traffic keys. | |
| 89 | + buckets.retain(|_, w| now.duration_since(w.window_start) < window); | |
| 90 | + | |
| 91 | + if buckets.len() >= MAX_ENTRIES && !buckets.contains_key(key) { | |
| 92 | + tracing::warn!( | |
| 93 | + entries = buckets.len(), | |
| 94 | + cap = MAX_ENTRIES, | |
| 95 | + "rate limiter map full, rejecting new key" | |
| 96 | + ); | |
| 97 | + return Err(AppError::TooManyRequests { | |
| 98 | + retry_after_secs: window.as_secs(), | |
| 99 | + }); | |
| 100 | + } | |
| 101 | + | |
| 102 | + let w = buckets.entry(key.to_string()).or_insert(WindowCounter { | |
| 103 | + count: 0, | |
| 104 | + window_start: now, | |
| 105 | + }); | |
| 106 | + | |
| 107 | + if now.duration_since(w.window_start) >= window { | |
| 108 | + w.count = 0; | |
| 109 | + w.window_start = now; | |
| 110 | + } | |
| 111 | + | |
| 112 | + w.count += 1; | |
| 113 | + if w.count > max { | |
| 114 | + let elapsed = now.duration_since(w.window_start); | |
| 115 | + let remaining = window.saturating_sub(elapsed).as_secs().max(1); | |
| 116 | + tracing::warn!( | |
| 117 | + key = %key, | |
| 118 | + count = w.count, | |
| 119 | + max, | |
| 120 | + retry_after_secs = remaining, | |
| 121 | + "rate limit exceeded" | |
| 122 | + ); | |
| 123 | + return Err(AppError::TooManyRequests { | |
| 124 | + retry_after_secs: remaining, | |
| 125 | + }); | |
| 126 | + } | |
| 127 | + | |
| 128 | + Ok(()) | |
| 129 | + } | |
| 130 | + | |
| 131 | + /// True sliding-window check: rejects if more than `max` attempts occurred | |
| 132 | + /// in the last `window` duration, counted attempt-by-attempt. | |
| 133 | + /// | |
| 134 | + /// Stricter than [`check_window`] because it never allows a burst across a | |
| 135 | + /// window boundary. Use for security-sensitive throttling (password reset, | |
| 136 | + /// email verification resend, etc.). | |
| 137 | + /// | |
| 138 | + /// [`check_window`]: Self::check_window | |
| 139 | + pub fn check_sliding_window( | |
| 140 | + &self, | |
| 141 | + key: &str, | |
| 142 | + max: u32, | |
| 143 | + window: Duration, | |
| 144 | + ) -> Result<(), AppError> { | |
| 145 | + let now = Instant::now(); | |
| 146 | + let mut sliding = self | |
| 147 | + .sliding | |
| 148 | + .lock() | |
| 149 | + .map_err(|_| AppError::Internal("rate limiter mutex poisoned".into()))?; | |
| 150 | + | |
| 151 | + // Size guard before insertion of a new key. | |
| 152 | + if sliding.len() >= MAX_ENTRIES && !sliding.contains_key(key) { | |
| 153 | + tracing::warn!( | |
| 154 | + entries = sliding.len(), | |
| 155 | + cap = MAX_ENTRIES, | |
| 156 | + "sliding rate limiter map full, rejecting new key" | |
| 157 | + ); | |
| 158 | + return Err(AppError::TooManyRequests { | |
| 159 | + retry_after_secs: window.as_secs(), | |
| 160 | + }); | |
| 161 | + } | |
| 162 | + | |
| 163 | + let attempts = sliding.entry(key.to_string()).or_default(); | |
| 164 | + attempts.retain(|t| now.duration_since(*t) < window); | |
| 165 | + | |
| 166 | + if attempts.len() as u32 >= max { | |
| 167 | + let oldest = attempts.first().copied().unwrap_or(now); | |
| 168 | + let elapsed = now.duration_since(oldest); | |
| 169 | + let retry = window.saturating_sub(elapsed).as_secs().max(1); | |
| 170 | + tracing::warn!( | |
| 171 | + key = %key, | |
| 172 | + attempts = attempts.len(), | |
| 173 | + max, | |
| 174 | + retry_after_secs = retry, | |
| 175 | + "sliding rate limit exceeded" | |
| 176 | + ); | |
| 177 | + return Err(AppError::TooManyRequests { | |
| 178 | + retry_after_secs: retry, | |
| 179 | + }); | |
| 180 | + } | |
| 181 | + | |
| 182 | + attempts.push(now); | |
| 183 | + Ok(()) | |
| 184 | + } | |
| 185 | + | |
| 186 | + /// Background task that periodically drops expired buckets. | |
| 187 | + /// | |
| 188 | + /// Not strictly required (every `check` already evicts stale entries for | |
| 189 | + /// its own window), but useful when most keys stop being accessed yet the | |
| 190 | + /// map should not keep growing through bursty traffic. | |
| 191 | + pub async fn cleanup_loop(self, interval: Duration, window: Duration) { | |
| 192 | + let mut ticker = tokio::time::interval(interval); | |
| 193 | + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 194 | + loop { | |
| 195 | + ticker.tick().await; | |
| 196 | + let now = Instant::now(); | |
| 197 | + if let Ok(mut buckets) = self.buckets.lock() { | |
| 198 | + buckets.retain(|_, w| now.duration_since(w.window_start) < window); | |
| 199 | + } | |
| 200 | + if let Ok(mut sliding) = self.sliding.lock() { | |
| 201 | + sliding.retain(|_, attempts| { | |
| 202 | + attempts.retain(|t| now.duration_since(*t) < window); | |
| 203 | + !attempts.is_empty() | |
| 204 | + }); | |
| 205 | + } | |
| 206 | + } | |
| 207 | + } | |
| 208 | + | |
| 209 | + /// Number of distinct keys currently tracked in the fixed-window map. | |
| 210 | + pub fn len(&self) -> usize { | |
| 211 | + self.buckets.lock().map(|b| b.len()).unwrap_or(0) | |
| 212 | + } | |
| 213 | + | |
| 214 | + /// Return `true` if no keys are currently tracked in the fixed-window map. | |
| 215 | + pub fn is_empty(&self) -> bool { | |
| 216 | + self.len() == 0 | |
| 217 | + } | |
| 218 | + | |
| 219 | + /// Number of distinct keys currently tracked in the sliding-window map. | |
| 220 | + pub fn sliding_len(&self) -> usize { | |
| 221 | + self.sliding.lock().map(|s| s.len()).unwrap_or(0) | |
| 222 | + } | |
| 223 | +} | |
| 224 | + | |
| 225 | +#[cfg(test)] | |
| 226 | +mod tests { | |
| 227 | + use super::*; | |
| 228 | + | |
| 229 | + #[test] | |
| 230 | + fn under_limit_always_allowed() { | |
| 231 | + let rl = RateLimitService::new(); | |
| 232 | + for _ in 0..5 { | |
| 233 | + rl.check("k", 10).unwrap(); | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + #[test] | |
| 238 | + fn exceeding_limit_returns_too_many_requests() { | |
| 239 | + let rl = RateLimitService::new(); | |
| 240 | + for _ in 0..3 { | |
| 241 | + rl.check("k", 3).unwrap(); | |
| 242 | + } | |
| 243 | + let err = rl.check("k", 3).unwrap_err(); | |
| 244 | + match err { | |
| 245 | + AppError::TooManyRequests { retry_after_secs } => { | |
| 246 | + assert!((1..=60).contains(&retry_after_secs)); | |
| 247 | + } | |
| 248 | + other => panic!("expected TooManyRequests, got {other:?}"), | |
| 249 | + } | |
| 250 | + } | |
| 251 | + | |
| 252 | + #[test] | |
| 253 | + fn distinct_keys_have_independent_budgets() { | |
| 254 | + let rl = RateLimitService::new(); | |
| 255 | + for _ in 0..3 { | |
| 256 | + rl.check("a", 3).unwrap(); | |
| 257 | + rl.check("b", 3).unwrap(); | |
| 258 | + } | |
| 259 | + assert!(rl.check("a", 3).is_err()); | |
| 260 | + assert!(rl.check("b", 3).is_err()); | |
| 261 | + } | |
| 262 | + | |
| 263 | + #[test] | |
| 264 | + fn window_expiry_releases_budget() { | |
| 265 | + let rl = RateLimitService::new(); | |
| 266 | + let window = Duration::from_millis(50); | |
| 267 | + rl.check_window("k", 1, window).unwrap(); | |
| 268 | + assert!(rl.check_window("k", 1, window).is_err()); | |
| 269 | + | |
| 270 | + std::thread::sleep(Duration::from_millis(80)); | |
| 271 | + rl.check_window("k", 1, window) | |
| 272 | + .expect("window should reset after expiry"); | |
| 273 | + } | |
| 274 | + | |
| 275 | + #[test] | |
| 276 | + fn sliding_window_rejects_burst_across_boundary() { | |
| 277 | + let rl = RateLimitService::new(); | |
| 278 | + let window = Duration::from_millis(100); | |
| 279 | + | |
| 280 | + // 3 attempts inside the window — OK. | |
| 281 | + rl.check_sliding_window("k", 3, window).unwrap(); | |
| 282 | + rl.check_sliding_window("k", 3, window).unwrap(); | |
| 283 | + rl.check_sliding_window("k", 3, window).unwrap(); | |
| 284 | + | |
| 285 | + // 4th attempt inside the same window — rejected. | |
| 286 | + assert!(rl.check_sliding_window("k", 3, window).is_err()); | |
| 287 | + | |
| 288 | + // After the oldest attempt expires, one slot is released. | |
| 289 | + std::thread::sleep(Duration::from_millis(120)); | |
| 290 | + rl.check_sliding_window("k", 3, window) | |
| 291 | + .expect("slot should free after oldest attempt ages out"); | |
| 292 | + } | |
| 293 | + | |
| 294 | + #[test] | |
| 295 | + fn sliding_and_fixed_window_are_independent() { | |
| 296 | + let rl = RateLimitService::new(); | |
| 297 | + rl.check("k", 1).unwrap(); | |
| 298 | + // Fixed window exhausted. | |
| 299 | + assert!(rl.check("k", 1).is_err()); | |
| 300 | + // Sliding window is a separate keyspace. | |
| 301 | + rl.check_sliding_window("k", 1, Duration::from_secs(60)) | |
| 302 | + .expect("sliding map is independent of fixed map"); | |
| 303 | + } | |
| 304 | + | |
| 305 | + #[test] | |
| 306 | + fn check_window_respects_max_per_minute_equivalence() { | |
| 307 | + // check(k, N) must behave identically to check_window(k, N, 60s). | |
| 308 | + let a = RateLimitService::new(); | |
| 309 | + let b = RateLimitService::new(); | |
| 310 | + for _ in 0..4 { | |
| 311 | + a.check("k", 4).unwrap(); | |
| 312 | + b.check_window("k", 4, Duration::from_secs(60)).unwrap(); | |
| 313 | + } | |
| 314 | + assert!(a.check("k", 4).is_err()); | |
| 315 | + assert!(b.check_window("k", 4, Duration::from_secs(60)).is_err()); | |
| 316 | + } | |
| 317 | +} |
M
crates/rustwarden-core/src/services/refresh_token_service.rs
+8
-0
@@ -21,18 +21,26 @@ use uuid::Uuid;
| 21 | 21 | /// Active session information for display |
| 22 | 22 | #[derive(Debug, Clone, serde::Serialize)] |
| 23 | 23 | pub struct ActiveSession { |
| 24 | + /// Unique identifier of the refresh token / session. | |
| 24 | 25 | pub id: Uuid, |
| 26 | + /// Optional device or user-agent description. | |
| 25 | 27 | pub device_info: Option<String>, |
| 28 | + /// IP address from which the session was created. | |
| 26 | 29 | pub ip_address: Option<String>, |
| 30 | + /// Timestamp of the last activity associated with this session. | |
| 27 | 31 | pub last_activity: DateTime<Utc>, |
| 32 | + /// Timestamp when this session was originally created. | |
| 28 | 33 | pub created_at: DateTime<Utc>, |
| 34 | + /// Whether this session corresponds to the currently authenticated request. | |
| 29 | 35 | #[serde(default)] |
| 30 | 36 | pub is_current: bool, |
| 31 | 37 | } |
| 32 | 38 | |
| 33 | 39 | /// Result of creating a refresh token: the DB model (with hash) + plain token for the client. |
| 34 | 40 | pub struct CreatedRefreshToken { |
| 41 | + /// The persisted refresh token model (stores the SHA-256 hash, not the plain token). | |
| 35 | 42 | pub model: refresh_token::Model, |
| 43 | + /// The plaintext token to be sent to the client (never stored directly). | |
| 36 | 44 | pub plain_token: String, |
| 37 | 45 | } |
| 38 | 46 |
M
crates/rustwarden-core/src/services/resource_service.rs
+421
-7
@@ -1,14 +1,28 @@
| 1 | 1 | use crate::error::AppError; |
| 2 | 2 | use crate::models::{resource, resource_share}; |
| 3 | +use crate::services::team_service::TeamService; | |
| 3 | 4 | use chrono::Utc; |
| 4 | 5 | use sea_orm::{ |
| 5 | - ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, | |
| 6 | + ActiveModelTrait, ColumnTrait, Condition, DatabaseConnection, EntityTrait, QueryFilter, | |
| 7 | + QueryOrder, Set, | |
| 6 | 8 | }; |
| 7 | 9 | use uuid::Uuid; |
| 8 | 10 | |
| 9 | 11 | /// Permission levels for resource sharing (ordered by privilege). |
| 10 | 12 | const PERMISSION_HIERARCHY: &[&str] = &["read", "write", "admin"]; |
| 11 | 13 | |
| 14 | +/// A share target. Either an individual user or a team — permission | |
| 15 | +/// resolution walks user-direct shares + every team the user belongs to and | |
| 16 | +/// keeps the maximum level. | |
| 17 | +#[derive(Debug, Clone, Copy, Eq, PartialEq)] | |
| 18 | +pub enum Principal { | |
| 19 | + /// An individual user identified by their UUID. | |
| 20 | + User(Uuid), | |
| 21 | + /// A team identified by its UUID. | |
| 22 | + Team(Uuid), | |
| 23 | +} | |
| 24 | + | |
| 25 | +/// Service for registering, querying, and sharing application resources. | |
| 12 | 26 | pub struct ResourceService; |
| 13 | 27 | |
| 14 | 28 | impl ResourceService { |
@@ -151,7 +165,8 @@ impl ResourceService {
| 151 | 165 | let share = resource_share::ActiveModel { |
| 152 | 166 | id: Set(Uuid::new_v4()), |
| 153 | 167 | resource_id: Set(entry.id), |
| 154 | - shared_with_user_id: Set(shared_with_user_id), | |
| 168 | + shared_with_user_id: Set(Some(shared_with_user_id)), | |
| 169 | + shared_with_team_id: Set(None), | |
| 155 | 170 | permission_level: Set(permission_level.to_string()), |
| 156 | 171 | shared_by_user_id: Set(shared_by_user_id), |
| 157 | 172 | created_at: Set(Utc::now()), |
@@ -160,11 +175,70 @@ impl ResourceService {
| 160 | 175 | Ok(result) |
| 161 | 176 | } |
| 162 | 177 | |
| 163 | - /// Revoke a share by its ID. | |
| 164 | - pub async fn revoke_share( | |
| 178 | + /// Share a resource with an arbitrary [`Principal`] (user or team). | |
| 179 | + /// | |
| 180 | + /// Generalises [`Self::share`] — the legacy helper stays as a thin wrapper | |
| 181 | + /// so existing callers keep working. Uniqueness is enforced per | |
| 182 | + /// `(resource_id, principal)` via the DB partial unique indexes: | |
| 183 | + /// a second call updates the existing row's permission level. | |
| 184 | + pub async fn share_with( | |
| 165 | 185 | db: &DatabaseConnection, |
| 166 | - share_id: Uuid, | |
| 167 | - ) -> Result<(), AppError> { | |
| 186 | + resource_type: &str, | |
| 187 | + resource_id: Uuid, | |
| 188 | + principal: Principal, | |
| 189 | + permission_level: &str, | |
| 190 | + shared_by_user_id: Uuid, | |
| 191 | + ) -> Result<resource_share::Model, AppError> { | |
| 192 | + let entry = Self::find_by_type_and_id(db, resource_type, resource_id) | |
| 193 | + .await? | |
| 194 | + .ok_or_else(|| AppError::NotFound("Resource not found".into()))?; | |
| 195 | + | |
| 196 | + if let Principal::User(uid) = principal { | |
| 197 | + if entry.owner_id == uid { | |
| 198 | + return Err(AppError::Validation( | |
| 199 | + "Cannot share a resource with its owner".into(), | |
| 200 | + )); | |
| 201 | + } | |
| 202 | + } | |
| 203 | + | |
| 204 | + Self::validate_permission_level(permission_level)?; | |
| 205 | + | |
| 206 | + let (user_filter, team_filter): (Option<Uuid>, Option<Uuid>) = match principal { | |
| 207 | + Principal::User(u) => (Some(u), None), | |
| 208 | + Principal::Team(t) => (None, Some(t)), | |
| 209 | + }; | |
| 210 | + | |
| 211 | + let existing = resource_share::Entity::find() | |
| 212 | + .filter(resource_share::Column::ResourceId.eq(entry.id)) | |
| 213 | + .filter( | |
| 214 | + Condition::all() | |
| 215 | + .add(resource_share::Column::SharedWithUserId.eq(user_filter)) | |
| 216 | + .add(resource_share::Column::SharedWithTeamId.eq(team_filter)), | |
| 217 | + ) | |
| 218 | + .one(db) | |
| 219 | + .await?; | |
| 220 | + | |
| 221 | + if let Some(existing) = existing { | |
| 222 | + let mut active: resource_share::ActiveModel = existing.into(); | |
| 223 | + active.permission_level = Set(permission_level.to_string()); | |
| 224 | + active.shared_by_user_id = Set(shared_by_user_id); | |
| 225 | + return Ok(active.update(db).await?); | |
| 226 | + } | |
| 227 | + | |
| 228 | + let share = resource_share::ActiveModel { | |
| 229 | + id: Set(Uuid::new_v4()), | |
| 230 | + resource_id: Set(entry.id), | |
| 231 | + shared_with_user_id: Set(user_filter), | |
| 232 | + shared_with_team_id: Set(team_filter), | |
| 233 | + permission_level: Set(permission_level.to_string()), | |
| 234 | + shared_by_user_id: Set(shared_by_user_id), | |
| 235 | + created_at: Set(Utc::now()), | |
| 236 | + }; | |
| 237 | + Ok(share.insert(db).await?) | |
| 238 | + } | |
| 239 | + | |
| 240 | + /// Revoke a share by its ID. | |
| 241 | + pub async fn revoke_share(db: &DatabaseConnection, share_id: Uuid) -> Result<(), AppError> { | |
| 168 | 242 | resource_share::Entity::delete_by_id(share_id) |
| 169 | 243 | .exec(db) |
| 170 | 244 | .await?; |
@@ -285,6 +359,63 @@ impl ResourceService {
| 285 | 359 | Ok(None) |
| 286 | 360 | } |
| 287 | 361 | |
| 362 | + /// Compute the effective permission a user has on a resource, walking: | |
| 363 | + /// 1. Ownership → `"owner"`. | |
| 364 | + /// 2. Direct user share → that share's level. | |
| 365 | + /// 3. Every team the user belongs to whose team-share targets the | |
| 366 | + /// resource → take the maximum level across those. | |
| 367 | + /// 4. Public flag → `"read"`. | |
| 368 | + /// | |
| 369 | + /// Returns `None` when the user has no access at all. | |
| 370 | + /// | |
| 371 | + /// Supersedes [`Self::effective_permission`]; the legacy fn remains for | |
| 372 | + /// API compatibility but ignores team memberships. | |
| 373 | + pub async fn effective_permission_for_user( | |
| 374 | + db: &DatabaseConnection, | |
| 375 | + user_id: Uuid, | |
| 376 | + resource_type: &str, | |
| 377 | + resource_id: Uuid, | |
| 378 | + ) -> Result<Option<String>, AppError> { | |
| 379 | + let entry = match Self::find_by_type_and_id(db, resource_type, resource_id).await? { | |
| 380 | + Some(e) => e, | |
| 381 | + None => return Ok(None), | |
| 382 | + }; | |
| 383 | + | |
| 384 | + if entry.owner_id == user_id { | |
| 385 | + return Ok(Some("owner".to_string())); | |
| 386 | + } | |
| 387 | + | |
| 388 | + // Single query pulls both "direct user" and "via team" shares in one | |
| 389 | + // round-trip. The DB CHECK guarantees each row is exactly one flavour. | |
| 390 | + let team_ids = TeamService::team_ids_of_user(db, user_id).await?; | |
| 391 | + let mut condition = | |
| 392 | + Condition::any().add(resource_share::Column::SharedWithUserId.eq(Some(user_id))); | |
| 393 | + if !team_ids.is_empty() { | |
| 394 | + condition = condition.add(resource_share::Column::SharedWithTeamId.is_in(team_ids)); | |
| 395 | + } | |
| 396 | + | |
| 397 | + let shares = resource_share::Entity::find() | |
| 398 | + .filter(resource_share::Column::ResourceId.eq(entry.id)) | |
| 399 | + .filter(condition) | |
| 400 | + .all(db) | |
| 401 | + .await?; | |
| 402 | + | |
| 403 | + let best = shares | |
| 404 | + .iter() | |
| 405 | + .map(|s| Self::level_rank(&s.permission_level)) | |
| 406 | + .max(); | |
| 407 | + | |
| 408 | + if let Some(rank) = best { | |
| 409 | + return Ok(Some(PERMISSION_HIERARCHY[rank].to_string())); | |
| 410 | + } | |
| 411 | + | |
| 412 | + if entry.is_public { | |
| 413 | + return Ok(Some("read".to_string())); | |
| 414 | + } | |
| 415 | + | |
| 416 | + Ok(None) | |
| 417 | + } | |
| 418 | + | |
| 288 | 419 | /// List all resources of a given type that are shared with a user. |
| 289 | 420 | pub async fn shared_with_user( |
| 290 | 421 | db: &DatabaseConnection, |
@@ -292,7 +423,7 @@ impl ResourceService {
| 292 | 423 | resource_type: &str, |
| 293 | 424 | ) -> Result<Vec<(resource::Model, resource_share::Model)>, AppError> { |
| 294 | 425 | let shares = resource_share::Entity::find() |
| 295 | - .filter(resource_share::Column::SharedWithUserId.eq(user_id)) | |
| 426 | + .filter(resource_share::Column::SharedWithUserId.eq(Some(user_id))) | |
| 296 | 427 | .all(db) |
| 297 | 428 | .await?; |
| 298 | 429 |
@@ -375,11 +506,294 @@ impl ResourceService {
| 375 | 506 | .unwrap_or(usize::MAX); |
| 376 | 507 | granted_idx >= required_idx |
| 377 | 508 | } |
| 509 | + | |
| 510 | + /// Numeric rank of a permission level (higher = more privileged). Unknown | |
| 511 | + /// levels fall back to 0 so they are never preferred over a known one. | |
| 512 | + fn level_rank(level: &str) -> usize { | |
| 513 | + PERMISSION_HIERARCHY | |
| 514 | + .iter() | |
| 515 | + .position(|&l| l == level) | |
| 516 | + .unwrap_or(0) | |
| 517 | + } | |
| 378 | 518 | } |
| 379 | 519 | |
| 380 | 520 | #[cfg(test)] |
| 381 | 521 | mod tests { |
| 382 | 522 | use super::*; |
| 523 | + use crate::services::password_service::PasswordService; | |
| 524 | + use crate::services::team_service::{TeamRole, TeamService}; | |
| 525 | + use crate::services::user_service::UserService; | |
| 526 | + use crate::types::TeamSlug; | |
| 527 | + | |
| 528 | + async fn test_db() -> DatabaseConnection { | |
| 529 | + dotenvy::dotenv().ok(); | |
| 530 | + let url = std::env::var("DATABASE_URL") | |
| 531 | + .expect("DATABASE_URL required for resource_service tests"); | |
| 532 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 533 | + } | |
| 534 | + | |
| 535 | + async fn fresh_user(db: &DatabaseConnection) -> Uuid { | |
| 536 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 537 | + let hash = PasswordService::hash_password("IrrelevantForTests1!") | |
| 538 | + .await | |
| 539 | + .unwrap(); | |
| 540 | + UserService::create_user( | |
| 541 | + db, | |
| 542 | + format!("res_test_{}", &unique[..12]), | |
| 543 | + format!("res_test_{}@example.com", &unique[..12]), | |
| 544 | + hash, | |
| 545 | + false, | |
| 546 | + ) | |
| 547 | + .await | |
| 548 | + .unwrap() | |
| 549 | + .id | |
| 550 | + } | |
| 551 | + | |
| 552 | + fn fresh_slug() -> TeamSlug { | |
| 553 | + let raw = format!("rs-{}", &Uuid::new_v4().simple().to_string()[..10]); | |
| 554 | + TeamSlug::parse(&raw).unwrap() | |
| 555 | + } | |
| 556 | + | |
| 557 | + async fn register_resource(db: &DatabaseConnection, owner: Uuid) -> Uuid { | |
| 558 | + let rid = Uuid::new_v4(); | |
| 559 | + ResourceService::register(db, "test_resource", rid, owner) | |
| 560 | + .await | |
| 561 | + .unwrap(); | |
| 562 | + rid | |
| 563 | + } | |
| 564 | + | |
| 565 | + #[tokio::test] | |
| 566 | + async fn share_with_user_principal_is_backwards_compatible() { | |
| 567 | + let db = test_db().await; | |
| 568 | + let owner = fresh_user(&db).await; | |
| 569 | + let alice = fresh_user(&db).await; | |
| 570 | + let rid = register_resource(&db, owner).await; | |
| 571 | + | |
| 572 | + ResourceService::share_with( | |
| 573 | + &db, | |
| 574 | + "test_resource", | |
| 575 | + rid, | |
| 576 | + Principal::User(alice), | |
| 577 | + "write", | |
| 578 | + owner, | |
| 579 | + ) | |
| 580 | + .await | |
| 581 | + .unwrap(); | |
| 582 | + | |
| 583 | + let perm = ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid) | |
| 584 | + .await | |
| 585 | + .unwrap(); | |
| 586 | + assert_eq!(perm.as_deref(), Some("write")); | |
| 587 | + } | |
| 588 | + | |
| 589 | + #[tokio::test] | |
| 590 | + async fn share_with_user_refuses_sharing_back_to_owner() { | |
| 591 | + let db = test_db().await; | |
| 592 | + let owner = fresh_user(&db).await; | |
| 593 | + let rid = register_resource(&db, owner).await; | |
| 594 | + let err = ResourceService::share_with( | |
| 595 | + &db, | |
| 596 | + "test_resource", | |
| 597 | + rid, | |
| 598 | + Principal::User(owner), | |
| 599 | + "read", | |
| 600 | + owner, | |
| 601 | + ) | |
| 602 | + .await | |
| 603 | + .unwrap_err(); | |
| 604 | + assert!(matches!(err, AppError::Validation(_))); | |
| 605 | + } | |
| 606 | + | |
| 607 | + #[tokio::test] | |
| 608 | + async fn share_with_team_grants_access_to_every_member() { | |
| 609 | + let db = test_db().await; | |
| 610 | + let owner = fresh_user(&db).await; | |
| 611 | + let alice = fresh_user(&db).await; | |
| 612 | + let bob = fresh_user(&db).await; | |
| 613 | + let team = TeamService::create(&db, owner, fresh_slug(), "Devs", None) | |
| 614 | + .await | |
| 615 | + .unwrap(); | |
| 616 | + TeamService::add_member(&db, team.id, alice, TeamRole::Member, None) | |
| 617 | + .await | |
| 618 | + .unwrap(); | |
| 619 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) | |
| 620 | + .await | |
| 621 | + .unwrap(); | |
| 622 | + | |
| 623 | + let rid = register_resource(&db, owner).await; | |
| 624 | + ResourceService::share_with( | |
| 625 | + &db, | |
| 626 | + "test_resource", | |
| 627 | + rid, | |
| 628 | + Principal::Team(team.id), | |
| 629 | + "read", | |
| 630 | + owner, | |
| 631 | + ) | |
| 632 | + .await | |
| 633 | + .unwrap(); | |
| 634 | + | |
| 635 | + let alice_perm = | |
| 636 | + ResourceService::effective_permission_for_user(&db, alice, "test_resource", rid) | |
| 637 | + .await | |
| 638 | + .unwrap(); | |
| 639 | + let bob_perm = | |
| 640 | + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) | |
| 641 | + .await | |
| 642 | + .unwrap(); | |
| 643 | + assert_eq!(alice_perm.as_deref(), Some("read")); | |
| 644 | + assert_eq!(bob_perm.as_deref(), Some("read")); | |
| 645 | + } | |
| 646 | + | |
| 647 | + #[tokio::test] | |
| 648 | + async fn effective_permission_takes_max_across_user_and_teams() { | |
| 649 | + let db = test_db().await; | |
| 650 | + let owner = fresh_user(&db).await; | |
| 651 | + let bob = fresh_user(&db).await; | |
| 652 | + let team_read = TeamService::create(&db, owner, fresh_slug(), "Read", None) | |
| 653 | + .await | |
| 654 | + .unwrap(); | |
| 655 | + let team_admin = TeamService::create(&db, owner, fresh_slug(), "Admin", None) | |
| 656 | + .await | |
| 657 | + .unwrap(); | |
| 658 | + TeamService::add_member(&db, team_read.id, bob, TeamRole::Member, None) | |
| 659 | + .await | |
| 660 | + .unwrap(); | |
| 661 | + TeamService::add_member(&db, team_admin.id, bob, TeamRole::Member, None) | |
| 662 | + .await | |
| 663 | + .unwrap(); | |
| 664 | + | |
| 665 | + let rid = register_resource(&db, owner).await; | |
| 666 | + // Bob gets "write" directly. | |
| 667 | + ResourceService::share_with( | |
| 668 | + &db, | |
| 669 | + "test_resource", | |
| 670 | + rid, | |
| 671 | + Principal::User(bob), | |
| 672 | + "write", | |
| 673 | + owner, | |
| 674 | + ) | |
| 675 | + .await | |
| 676 | + .unwrap(); | |
| 677 | + // "Read" team only has read. | |
| 678 | + ResourceService::share_with( | |
| 679 | + &db, | |
| 680 | + "test_resource", | |
| 681 | + rid, | |
| 682 | + Principal::Team(team_read.id), | |
| 683 | + "read", | |
| 684 | + owner, | |
| 685 | + ) | |
| 686 | + .await | |
| 687 | + .unwrap(); | |
| 688 | + // "Admin" team has admin → should win. | |
| 689 | + ResourceService::share_with( | |
| 690 | + &db, | |
| 691 | + "test_resource", | |
| 692 | + rid, | |
| 693 | + Principal::Team(team_admin.id), | |
| 694 | + "admin", | |
| 695 | + owner, | |
| 696 | + ) | |
| 697 | + .await | |
| 698 | + .unwrap(); | |
| 699 | + | |
| 700 | + let perm = ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) | |
| 701 | + .await | |
| 702 | + .unwrap(); | |
| 703 | + assert_eq!(perm.as_deref(), Some("admin")); | |
| 704 | + } | |
| 705 | + | |
| 706 | + #[tokio::test] | |
| 707 | + async fn removing_user_from_team_drops_team_based_access() { | |
| 708 | + let db = test_db().await; | |
| 709 | + let owner = fresh_user(&db).await; | |
| 710 | + let bob = fresh_user(&db).await; | |
| 711 | + let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None) | |
| 712 | + .await | |
| 713 | + .unwrap(); | |
| 714 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) | |
| 715 | + .await | |
| 716 | + .unwrap(); | |
| 717 | + | |
| 718 | + let rid = register_resource(&db, owner).await; | |
| 719 | + ResourceService::share_with( | |
| 720 | + &db, | |
| 721 | + "test_resource", | |
| 722 | + rid, | |
| 723 | + Principal::Team(team.id), | |
| 724 | + "write", | |
| 725 | + owner, | |
| 726 | + ) | |
| 727 | + .await | |
| 728 | + .unwrap(); | |
| 729 | + | |
| 730 | + // Sanity: bob has write via team. | |
| 731 | + assert_eq!( | |
| 732 | + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) | |
| 733 | + .await | |
| 734 | + .unwrap() | |
| 735 | + .as_deref(), | |
| 736 | + Some("write") | |
| 737 | + ); | |
| 738 | + | |
| 739 | + TeamService::remove_member(&db, team.id, bob, None) | |
| 740 | + .await | |
| 741 | + .unwrap(); | |
| 742 | + | |
| 743 | + // After removal bob has no access. | |
| 744 | + assert_eq!( | |
| 745 | + ResourceService::effective_permission_for_user(&db, bob, "test_resource", rid) | |
| 746 | + .await | |
| 747 | + .unwrap(), | |
| 748 | + None | |
| 749 | + ); | |
| 750 | + } | |
| 751 | + | |
| 752 | + #[tokio::test] | |
| 753 | + async fn share_with_team_is_idempotent_and_updates_level() { | |
| 754 | + let db = test_db().await; | |
| 755 | + let owner = fresh_user(&db).await; | |
| 756 | + let bob = fresh_user(&db).await; | |
| 757 | + let team = TeamService::create(&db, owner, fresh_slug(), "T", None) | |
| 758 | + .await | |
| 759 | + .unwrap(); | |
| 760 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) | |
| 761 | + .await | |
| 762 | + .unwrap(); | |
| 763 | + let rid = register_resource(&db, owner).await; | |
| 764 | + | |
| 765 | + ResourceService::share_with( | |
| 766 | + &db, | |
| 767 | + "test_resource", | |
| 768 | + rid, | |
| 769 | + Principal::Team(team.id), | |
| 770 | + "read", | |
| 771 | + owner, | |
| 772 | + ) | |
| 773 | + .await | |
| 774 | + .unwrap(); | |
| 775 | + ResourceService::share_with( | |
| 776 | + &db, | |
| 777 | + "test_resource", | |
| 778 | + rid, | |
| 779 | + Principal::Team(team.id), | |
| 780 | + "write", | |
| 781 | + owner, | |
| 782 | + ) | |
| 783 | + .await | |
| 784 | + .unwrap(); | |
| 785 | + | |
| 786 | + // Exactly one share row — second call upserted. | |
| 787 | + let shares = ResourceService::list_shares(&db, "test_resource", rid) | |
| 788 | + .await | |
| 789 | + .unwrap(); | |
| 790 | + let team_shares: Vec<_> = shares | |
| 791 | + .iter() | |
| 792 | + .filter(|s| s.shared_with_team_id == Some(team.id)) | |
| 793 | + .collect(); | |
| 794 | + assert_eq!(team_shares.len(), 1); | |
| 795 | + assert_eq!(team_shares[0].permission_level, "write"); | |
| 796 | + } | |
| 383 | 797 | |
| 384 | 798 | #[test] |
| 385 | 799 | fn test_level_ge() { |
M
crates/rustwarden-core/src/services/role_service.rs
+4
-0
@@ -139,11 +139,13 @@ impl RoleService {
| 139 | 139 | Ok((admin_role, user_role, app_manager_role)) |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | + /// Return all roles stored in the database. | |
| 142 | 143 | pub async fn list_all_roles(db: &DatabaseConnection) -> Result<Vec<role::Model>, AppError> { |
| 143 | 144 | let roles = role::Entity::find().all(db).await?; |
| 144 | 145 | Ok(roles) |
| 145 | 146 | } |
| 146 | 147 | |
| 148 | + /// Return a single role by its UUID, or `None` if not found. | |
| 147 | 149 | pub async fn get_role_by_id( |
| 148 | 150 | db: &DatabaseConnection, |
| 149 | 151 | id: Uuid, |
@@ -152,6 +154,7 @@ impl RoleService {
| 152 | 154 | Ok(role) |
| 153 | 155 | } |
| 154 | 156 | |
| 157 | + /// Update the name and/or description of an existing role. | |
| 155 | 158 | pub async fn update_role( |
| 156 | 159 | db: &DatabaseConnection, |
| 157 | 160 | id: Uuid, |
@@ -174,6 +177,7 @@ impl RoleService {
| 174 | 177 | Ok(updated) |
| 175 | 178 | } |
| 176 | 179 | |
| 180 | + /// Delete a role by its UUID. Returns an error if the role is protected (`admin` or `user`). | |
| 177 | 181 | pub async fn delete_role(db: &DatabaseConnection, id: Uuid) -> Result<(), AppError> { |
| 178 | 182 | let existing = Self::get_role_by_id(db, id) |
| 179 | 183 | .await? |
A
crates/rustwarden-core/src/services/team_service.rs
+522
-0
@@ -0,0 +1,522 @@
| 1 | +//! Team service. | |
| 2 | +//! | |
| 3 | +//! CRUD + membership management for [`teams`](crate::models::team) and | |
| 4 | +//! [`team_members`](crate::models::team_member). Integrates with | |
| 5 | +//! [`RustwardenHooks`](crate::hooks::RustwardenHooks) (on_team_* events) so | |
| 6 | +//! consumer apps can mirror team state into their own indices. | |
| 7 | +//! | |
| 8 | +//! Teams are the non-user principal accepted by | |
| 9 | +//! [`ResourceService::share_with`](crate::services::resource_service::ResourceService::share_with). | |
| 10 | +//! Resolving effective permissions across users + teams is handled by | |
| 11 | +//! [`ResourceService::effective_permission_for_user`](crate::services::resource_service::ResourceService::effective_permission_for_user). | |
| 12 | + | |
| 13 | +use crate::error::AppError; | |
| 14 | +use crate::hooks::RustwardenHooks; | |
| 15 | +use crate::models::{team, team_member}; | |
| 16 | +use crate::types::TeamSlug; | |
| 17 | +use chrono::Utc; | |
| 18 | +use sea_orm::{ | |
| 19 | + ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, QueryOrder, Set, | |
| 20 | +}; | |
| 21 | +use std::sync::Arc; | |
| 22 | +use uuid::Uuid; | |
| 23 | + | |
| 24 | +/// Allowed membership roles inside a team. | |
| 25 | +/// | |
| 26 | +/// `Owner` duplicates the `team.owner_id` column for convenience: the creator | |
| 27 | +/// is inserted as an owner member at creation time so `teams_of_user` works | |
| 28 | +/// without a `UNION` join. | |
| 29 | +#[derive(Debug, Clone, Copy, Eq, PartialEq)] | |
| 30 | +pub enum TeamRole { | |
| 31 | + /// The user who created the team; has full control. | |
| 32 | + Owner, | |
| 33 | + /// A member with elevated privileges who can manage membership. | |
| 34 | + Admin, | |
| 35 | + /// A regular team member with read-only access to team resources. | |
| 36 | + Member, | |
| 37 | +} | |
| 38 | + | |
| 39 | +impl TeamRole { | |
| 40 | + /// Return the lowercase string representation of this role. | |
| 41 | + pub fn as_str(&self) -> &'static str { | |
| 42 | + match self { | |
| 43 | + TeamRole::Owner => "owner", | |
| 44 | + TeamRole::Admin => "admin", | |
| 45 | + TeamRole::Member => "member", | |
| 46 | + } | |
| 47 | + } | |
| 48 | + | |
| 49 | + /// Parse a raw string into a `TeamRole`, returning `None` for unrecognised values. | |
| 50 | + pub fn parse(raw: &str) -> Option<Self> { | |
| 51 | + match raw { | |
| 52 | + "owner" => Some(Self::Owner), | |
| 53 | + "admin" => Some(Self::Admin), | |
| 54 | + "member" => Some(Self::Member), | |
| 55 | + _ => None, | |
| 56 | + } | |
| 57 | + } | |
| 58 | +} | |
| 59 | + | |
| 60 | +/// Service for creating and managing teams and their memberships. | |
| 61 | +pub struct TeamService; | |
| 62 | + | |
| 63 | +impl TeamService { | |
| 64 | + /// Create a team and insert the owner as the first (owner-role) member. | |
| 65 | + /// | |
| 66 | + /// Errors: | |
| 67 | + /// - `AppError::Validation` if `name` is empty. | |
| 68 | + /// - `AppError::Database` on unique constraint violation (slug collision). | |
| 69 | + pub async fn create( | |
| 70 | + db: &DatabaseConnection, | |
| 71 | + owner_id: Uuid, | |
| 72 | + slug: TeamSlug, | |
| 73 | + name: &str, | |
| 74 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 75 | + ) -> Result<team::Model, AppError> { | |
| 76 | + let name = name.trim(); | |
| 77 | + if name.is_empty() || name.len() > 128 { | |
| 78 | + return Err(AppError::Validation( | |
| 79 | + "Team name must be 1-128 characters".into(), | |
| 80 | + )); | |
| 81 | + } | |
| 82 | + | |
| 83 | + let team_id = Uuid::new_v4(); | |
| 84 | + let record = team::ActiveModel { | |
| 85 | + id: Set(team_id), | |
| 86 | + slug: Set(slug.into_inner()), | |
| 87 | + name: Set(name.to_string()), | |
| 88 | + owner_id: Set(owner_id), | |
| 89 | + created_at: Set(Utc::now()), | |
| 90 | + } | |
| 91 | + .insert(db) | |
| 92 | + .await?; | |
| 93 | + | |
| 94 | + team_member::ActiveModel { | |
| 95 | + team_id: Set(team_id), | |
| 96 | + user_id: Set(owner_id), | |
| 97 | + role: Set(TeamRole::Owner.as_str().to_string()), | |
| 98 | + joined_at: Set(Utc::now()), | |
| 99 | + } | |
| 100 | + .insert(db) | |
| 101 | + .await?; | |
| 102 | + | |
| 103 | + if let Some(hooks) = hooks { | |
| 104 | + if let Err(e) = hooks.on_team_created(db, team_id, owner_id).await { | |
| 105 | + tracing::warn!(team_id = %team_id, "on_team_created hook failed: {}", e); | |
| 106 | + } | |
| 107 | + } | |
| 108 | + | |
| 109 | + Ok(record) | |
| 110 | + } | |
| 111 | + | |
| 112 | + /// Look up a team by its id. | |
| 113 | + pub async fn find_by_id( | |
| 114 | + db: &DatabaseConnection, | |
| 115 | + team_id: Uuid, | |
| 116 | + ) -> Result<Option<team::Model>, AppError> { | |
| 117 | + Ok(team::Entity::find_by_id(team_id).one(db).await?) | |
| 118 | + } | |
| 119 | + | |
| 120 | + /// Look up a team by its unique slug. | |
| 121 | + pub async fn find_by_slug( | |
| 122 | + db: &DatabaseConnection, | |
| 123 | + slug: &TeamSlug, | |
| 124 | + ) -> Result<Option<team::Model>, AppError> { | |
| 125 | + Ok(team::Entity::find() | |
| 126 | + .filter(team::Column::Slug.eq(slug.as_str())) | |
| 127 | + .one(db) | |
| 128 | + .await?) | |
| 129 | + } | |
| 130 | + | |
| 131 | + /// Add a member to a team. Idempotent on `(team_id, user_id)` — updates | |
| 132 | + /// the role if the edge already exists. | |
| 133 | + pub async fn add_member( | |
| 134 | + db: &DatabaseConnection, | |
| 135 | + team_id: Uuid, | |
| 136 | + user_id: Uuid, | |
| 137 | + role: TeamRole, | |
| 138 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 139 | + ) -> Result<team_member::Model, AppError> { | |
| 140 | + // Upsert via find-then-update/insert (composite PK makes | |
| 141 | + // ActiveModel::insert conflict-safe to catch here). | |
| 142 | + let existing = team_member::Entity::find_by_id((team_id, user_id)) | |
| 143 | + .one(db) | |
| 144 | + .await?; | |
| 145 | + | |
| 146 | + let record = if let Some(existing) = existing { | |
| 147 | + let mut active: team_member::ActiveModel = existing.into(); | |
| 148 | + active.role = Set(role.as_str().to_string()); | |
| 149 | + active.update(db).await? | |
| 150 | + } else { | |
| 151 | + team_member::ActiveModel { | |
| 152 | + team_id: Set(team_id), | |
| 153 | + user_id: Set(user_id), | |
| 154 | + role: Set(role.as_str().to_string()), | |
| 155 | + joined_at: Set(Utc::now()), | |
| 156 | + } | |
| 157 | + .insert(db) | |
| 158 | + .await? | |
| 159 | + }; | |
| 160 | + | |
| 161 | + if let Some(hooks) = hooks { | |
| 162 | + if let Err(e) = hooks.on_team_member_added(db, team_id, user_id).await { | |
| 163 | + tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_added hook failed: {}", e); | |
| 164 | + } | |
| 165 | + } | |
| 166 | + | |
| 167 | + Ok(record) | |
| 168 | + } | |
| 169 | + | |
| 170 | + /// Remove a member from a team. Refuses to remove the team owner — use | |
| 171 | + /// `delete` to drop the whole team instead. | |
| 172 | + pub async fn remove_member( | |
| 173 | + db: &DatabaseConnection, | |
| 174 | + team_id: Uuid, | |
| 175 | + user_id: Uuid, | |
| 176 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 177 | + ) -> Result<(), AppError> { | |
| 178 | + let team = Self::find_by_id(db, team_id) | |
| 179 | + .await? | |
| 180 | + .ok_or_else(|| AppError::NotFound(format!("Team {team_id}")))?; | |
| 181 | + if team.owner_id == user_id { | |
| 182 | + return Err(AppError::Validation( | |
| 183 | + "Team owner cannot be removed; delete the team instead".into(), | |
| 184 | + )); | |
| 185 | + } | |
| 186 | + | |
| 187 | + team_member::Entity::delete_by_id((team_id, user_id)) | |
| 188 | + .exec(db) | |
| 189 | + .await?; | |
| 190 | + | |
| 191 | + if let Some(hooks) = hooks { | |
| 192 | + if let Err(e) = hooks.on_team_member_removed(db, team_id, user_id).await { | |
| 193 | + tracing::warn!(team_id = %team_id, user_id = %user_id, "on_team_member_removed hook failed: {}", e); | |
| 194 | + } | |
| 195 | + } | |
| 196 | + | |
| 197 | + Ok(()) | |
| 198 | + } | |
| 199 | + | |
| 200 | + /// List every (user, role) pair for the given team, most-recent-join first. | |
| 201 | + pub async fn list_members( | |
| 202 | + db: &DatabaseConnection, | |
| 203 | + team_id: Uuid, | |
| 204 | + ) -> Result<Vec<team_member::Model>, AppError> { | |
| 205 | + Ok(team_member::Entity::find() | |
| 206 | + .filter(team_member::Column::TeamId.eq(team_id)) | |
| 207 | + .order_by_desc(team_member::Column::JoinedAt) | |
| 208 | + .all(db) | |
| 209 | + .await?) | |
| 210 | + } | |
| 211 | + | |
| 212 | + /// List every team the user belongs to. | |
| 213 | + pub async fn teams_of_user( | |
| 214 | + db: &DatabaseConnection, | |
| 215 | + user_id: Uuid, | |
| 216 | + ) -> Result<Vec<team::Model>, AppError> { | |
| 217 | + use sea_orm::{JoinType, QuerySelect, RelationTrait}; | |
| 218 | + let teams = team::Entity::find() | |
| 219 | + .join_rev(JoinType::InnerJoin, team_member::Relation::Team.def()) | |
| 220 | + .filter(team_member::Column::UserId.eq(user_id)) | |
| 221 | + .order_by_desc(team::Column::CreatedAt) | |
| 222 | + .all(db) | |
| 223 | + .await?; | |
| 224 | + Ok(teams) | |
| 225 | + } | |
| 226 | + | |
| 227 | + /// Return every `team_id` the user belongs to. Cheap lookup used by | |
| 228 | + /// permission resolution. | |
| 229 | + pub async fn team_ids_of_user( | |
| 230 | + db: &DatabaseConnection, | |
| 231 | + user_id: Uuid, | |
| 232 | + ) -> Result<Vec<Uuid>, AppError> { | |
| 233 | + let rows = team_member::Entity::find() | |
| 234 | + .filter(team_member::Column::UserId.eq(user_id)) | |
| 235 | + .all(db) | |
| 236 | + .await?; | |
| 237 | + Ok(rows.into_iter().map(|r| r.team_id).collect()) | |
| 238 | + } | |
| 239 | + | |
| 240 | + /// Hard-delete a team. Cascade drops `team_members` (composite FK) and | |
| 241 | + /// `resource_shares` where the team was the principal (FK on | |
| 242 | + /// `shared_with_team_id`). | |
| 243 | + pub async fn delete( | |
| 244 | + db: &DatabaseConnection, | |
| 245 | + team_id: Uuid, | |
| 246 | + hooks: Option<&Arc<dyn RustwardenHooks>>, | |
| 247 | + ) -> Result<(), AppError> { | |
| 248 | + let existed = team::Entity::find_by_id(team_id).one(db).await?.is_some(); | |
| 249 | + team::Entity::delete_by_id(team_id).exec(db).await?; | |
| 250 | + | |
| 251 | + if existed { | |
| 252 | + if let Some(hooks) = hooks { | |
| 253 | + if let Err(e) = hooks.on_team_deleted(db, team_id).await { | |
| 254 | + tracing::warn!(team_id = %team_id, "on_team_deleted hook failed: {}", e); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + } | |
| 258 | + | |
| 259 | + Ok(()) | |
| 260 | + } | |
| 261 | +} | |
| 262 | + | |
| 263 | +#[cfg(test)] | |
| 264 | +mod tests { | |
| 265 | + use super::*; | |
| 266 | + use crate::hooks::{AuditEvent, RustwardenHooks}; | |
| 267 | + use crate::services::password_service::PasswordService; | |
| 268 | + use crate::services::user_service::UserService; | |
| 269 | + use async_trait::async_trait; | |
| 270 | + use std::sync::Mutex; | |
| 271 | + | |
| 272 | + async fn test_db() -> DatabaseConnection { | |
| 273 | + dotenvy::dotenv().ok(); | |
| 274 | + let url = | |
| 275 | + std::env::var("DATABASE_URL").expect("DATABASE_URL required for team_service tests"); | |
| 276 | + sea_orm::Database::connect(&url).await.unwrap() | |
| 277 | + } | |
| 278 | + | |
| 279 | + async fn fresh_user(db: &DatabaseConnection) -> Uuid { | |
| 280 | + let unique = Uuid::new_v4().simple().to_string(); | |
| 281 | + let hash = PasswordService::hash_password("IrrelevantForTests1!") | |
| 282 | + .await | |
| 283 | + .unwrap(); | |
| 284 | + UserService::create_user( | |
| 285 | + db, | |
| 286 | + format!("team_test_{}", &unique[..12]), | |
| 287 | + format!("team_test_{}@example.com", &unique[..12]), | |
| 288 | + hash, | |
| 289 | + false, | |
| 290 | + ) | |
| 291 | + .await | |
| 292 | + .unwrap() | |
| 293 | + .id | |
| 294 | + } | |
| 295 | + | |
| 296 | + fn fresh_slug() -> TeamSlug { | |
| 297 | + let raw = format!("t-{}", &Uuid::new_v4().simple().to_string()[..10]); | |
| 298 | + TeamSlug::parse(&raw).unwrap() | |
| 299 | + } | |
| 300 | + | |
| 301 | + #[derive(Default, Clone)] | |
| 302 | + struct RecordingHooks(Arc<Mutex<Vec<String>>>); | |
| 303 | + | |
| 304 | + #[async_trait] | |
| 305 | + impl RustwardenHooks for RecordingHooks { | |
| 306 | + async fn on_team_created( | |
| 307 | + &self, | |
| 308 | + _db: &DatabaseConnection, | |
| 309 | + team_id: Uuid, | |
| 310 | + owner_id: Uuid, | |
| 311 | + ) -> anyhow::Result<()> { | |
| 312 | + self.0 | |
| 313 | + .lock() | |
| 314 | + .unwrap() | |
| 315 | + .push(format!("created:{team_id}:{owner_id}")); | |
| 316 | + Ok(()) | |
| 317 | + } | |
| 318 | + async fn on_team_deleted( | |
| 319 | + &self, | |
| 320 | + _db: &DatabaseConnection, | |
| 321 | + team_id: Uuid, | |
| 322 | + ) -> anyhow::Result<()> { | |
| 323 | + self.0.lock().unwrap().push(format!("deleted:{team_id}")); | |
| 324 | + Ok(()) | |
| 325 | + } | |
| 326 | + async fn on_team_member_added( | |
| 327 | + &self, | |
| 328 | + _db: &DatabaseConnection, | |
| 329 | + team_id: Uuid, | |
| 330 | + user_id: Uuid, | |
| 331 | + ) -> anyhow::Result<()> { | |
| 332 | + self.0 | |
| 333 | + .lock() | |
| 334 | + .unwrap() | |
| 335 | + .push(format!("added:{team_id}:{user_id}")); | |
| 336 | + Ok(()) | |
| 337 | + } | |
| 338 | + async fn on_team_member_removed( | |
| 339 | + &self, | |
| 340 | + _db: &DatabaseConnection, | |
| 341 | + team_id: Uuid, | |
| 342 | + user_id: Uuid, | |
| 343 | + ) -> anyhow::Result<()> { | |
| 344 | + self.0 | |
| 345 | + .lock() | |
| 346 | + .unwrap() | |
| 347 | + .push(format!("removed:{team_id}:{user_id}")); | |
| 348 | + Ok(()) | |
| 349 | + } | |
| 350 | + async fn on_audit_event( | |
| 351 | + &self, | |
| 352 | + _db: &DatabaseConnection, | |
| 353 | + _event: AuditEvent<'_>, | |
| 354 | + ) -> anyhow::Result<()> { | |
| 355 | + Ok(()) | |
| 356 | + } | |
| 357 | + } | |
| 358 | + | |
| 359 | + #[tokio::test] | |
| 360 | + async fn create_team_inserts_owner_as_first_member() { | |
| 361 | + let db = test_db().await; | |
| 362 | + let owner = fresh_user(&db).await; | |
| 363 | + let team = TeamService::create(&db, owner, fresh_slug(), "Backend", None) | |
| 364 | + .await | |
| 365 | + .unwrap(); | |
| 366 | + | |
| 367 | + let members = TeamService::list_members(&db, team.id).await.unwrap(); | |
| 368 | + assert_eq!(members.len(), 1); | |
| 369 | + assert_eq!(members[0].user_id, owner); | |
| 370 | + assert_eq!(members[0].role, "owner"); | |
| 371 | + } | |
| 372 | + | |
| 373 | + #[tokio::test] | |
| 374 | + async fn create_rejects_empty_name() { | |
| 375 | + let db = test_db().await; | |
| 376 | + let owner = fresh_user(&db).await; | |
| 377 | + let err = TeamService::create(&db, owner, fresh_slug(), " ", None) | |
| 378 | + .await | |
| 379 | + .unwrap_err(); | |
| 380 | + assert!(matches!(err, AppError::Validation(_))); | |
| 381 | + } | |
| 382 | + | |
| 383 | + #[tokio::test] | |
| 384 | + async fn add_member_is_idempotent_and_updates_role() { | |
| 385 | + let db = test_db().await; | |
| 386 | + let owner = fresh_user(&db).await; | |
| 387 | + let alice = fresh_user(&db).await; | |
| 388 | + let team = TeamService::create(&db, owner, fresh_slug(), "T", None) | |
| 389 | + .await | |
| 390 | + .unwrap(); | |
| 391 | + | |
| 392 | + TeamService::add_member(&db, team.id, alice, TeamRole::Member, None) | |
| 393 | + .await | |
| 394 | + .unwrap(); | |
| 395 | + TeamService::add_member(&db, team.id, alice, TeamRole::Admin, None) | |
| 396 | + .await | |
| 397 | + .unwrap(); | |
| 398 | + | |
| 399 | + let members = TeamService::list_members(&db, team.id).await.unwrap(); | |
| 400 | + let alice_row = members.iter().find(|m| m.user_id == alice).unwrap(); | |
| 401 | + assert_eq!(alice_row.role, "admin"); | |
| 402 | + // No duplicate. | |
| 403 | + assert_eq!( | |
| 404 | + members.iter().filter(|m| m.user_id == alice).count(), | |
| 405 | + 1, | |
| 406 | + "add_member must not create duplicates" | |
| 407 | + ); | |
| 408 | + } | |
| 409 | + | |
| 410 | + #[tokio::test] | |
| 411 | + async fn remove_member_refuses_to_remove_owner() { | |
| 412 | + let db = test_db().await; | |
| 413 | + let owner = fresh_user(&db).await; | |
| 414 | + let team = TeamService::create(&db, owner, fresh_slug(), "T", None) | |
| 415 | + .await | |
| 416 | + .unwrap(); | |
| 417 | + let err = TeamService::remove_member(&db, team.id, owner, None) | |
| 418 | + .await | |
| 419 | + .unwrap_err(); | |
| 420 | + assert!(matches!(err, AppError::Validation(_))); | |
| 421 | + } | |
| 422 | + | |
| 423 | + #[tokio::test] | |
| 424 | + async fn remove_member_drops_the_edge() { | |
| 425 | + let db = test_db().await; | |
| 426 | + let owner = fresh_user(&db).await; | |
| 427 | + let bob = fresh_user(&db).await; | |
| 428 | + let team = TeamService::create(&db, owner, fresh_slug(), "T", None) | |
| 429 | + .await | |
| 430 | + .unwrap(); | |
| 431 | + | |
| 432 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) | |
| 433 | + .await | |
| 434 | + .unwrap(); | |
| 435 | + TeamService::remove_member(&db, team.id, bob, None) | |
| 436 | + .await | |
| 437 | + .unwrap(); | |
| 438 | + | |
| 439 | + let members = TeamService::list_members(&db, team.id).await.unwrap(); | |
| 440 | + assert!(members.iter().all(|m| m.user_id != bob)); | |
| 441 | + } | |
| 442 | + | |
| 443 | + #[tokio::test] | |
| 444 | + async fn teams_of_user_returns_membership_teams() { | |
| 445 | + let db = test_db().await; | |
| 446 | + let owner = fresh_user(&db).await; | |
| 447 | + let bob = fresh_user(&db).await; | |
| 448 | + let t1 = TeamService::create(&db, owner, fresh_slug(), "A", None) | |
| 449 | + .await | |
| 450 | + .unwrap(); | |
| 451 | + let t2 = TeamService::create(&db, owner, fresh_slug(), "B", None) | |
| 452 | + .await | |
| 453 | + .unwrap(); | |
| 454 | + TeamService::add_member(&db, t1.id, bob, TeamRole::Member, None) | |
| 455 | + .await | |
| 456 | + .unwrap(); | |
| 457 | + | |
| 458 | + let bobs_teams = TeamService::teams_of_user(&db, bob).await.unwrap(); | |
| 459 | + assert_eq!(bobs_teams.len(), 1); | |
| 460 | + assert_eq!(bobs_teams[0].id, t1.id); | |
| 461 | + | |
| 462 | + let owners_teams = TeamService::teams_of_user(&db, owner).await.unwrap(); | |
| 463 | + let ids: Vec<Uuid> = owners_teams.iter().map(|t| t.id).collect(); | |
| 464 | + assert!(ids.contains(&t1.id)); | |
| 465 | + assert!(ids.contains(&t2.id)); | |
| 466 | + } | |
| 467 | + | |
| 468 | + #[tokio::test] | |
| 469 | + async fn delete_cascades_members() { | |
| 470 | + let db = test_db().await; | |
| 471 | + let owner = fresh_user(&db).await; | |
| 472 | + let bob = fresh_user(&db).await; | |
| 473 | + let team = TeamService::create(&db, owner, fresh_slug(), "T", None) | |
| 474 | + .await | |
| 475 | + .unwrap(); | |
| 476 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, None) | |
| 477 | + .await | |
| 478 | + .unwrap(); | |
| 479 | + | |
| 480 | + TeamService::delete(&db, team.id, None).await.unwrap(); | |
| 481 | + | |
| 482 | + assert!(TeamService::find_by_id(&db, team.id) | |
| 483 | + .await | |
| 484 | + .unwrap() | |
| 485 | + .is_none()); | |
| 486 | + let remaining = team_member::Entity::find() | |
| 487 | + .filter(team_member::Column::TeamId.eq(team.id)) | |
| 488 | + .all(&db) | |
| 489 | + .await | |
| 490 | + .unwrap(); | |
| 491 | + assert!(remaining.is_empty(), "cascade must clear team_members"); | |
| 492 | + } | |
| 493 | + | |
| 494 | + #[tokio::test] | |
| 495 | + async fn hooks_fire_for_create_add_remove_delete() { | |
| 496 | + let db = test_db().await; | |
| 497 | + let owner = fresh_user(&db).await; | |
| 498 | + let bob = fresh_user(&db).await; | |
| 499 | + let recording = RecordingHooks::default(); | |
| 500 | + let hooks: Arc<dyn RustwardenHooks> = Arc::new(recording.clone()); | |
| 501 | + | |
| 502 | + let team = TeamService::create(&db, owner, fresh_slug(), "Observed", Some(&hooks)) | |
| 503 | + .await | |
| 504 | + .unwrap(); | |
| 505 | + TeamService::add_member(&db, team.id, bob, TeamRole::Member, Some(&hooks)) | |
| 506 | + .await | |
| 507 | + .unwrap(); | |
| 508 | + TeamService::remove_member(&db, team.id, bob, Some(&hooks)) | |
| 509 | + .await | |
| 510 | + .unwrap(); | |
| 511 | + TeamService::delete(&db, team.id, Some(&hooks)) | |
| 512 | + .await | |
| 513 | + .unwrap(); | |
| 514 | + | |
| 515 | + let events = recording.0.lock().unwrap().clone(); | |
| 516 | + assert_eq!(events.len(), 4, "4 hook events expected, got: {events:?}"); | |
| 517 | + assert!(events[0].starts_with("created:")); | |
| 518 | + assert!(events[1].starts_with("added:")); | |
| 519 | + assert!(events[2].starts_with("removed:")); | |
| 520 | + assert!(events[3].starts_with("deleted:")); | |
| 521 | + } | |
| 522 | +} |
A
crates/rustwarden-core/src/services/totp_service.rs
+518
-0
@@ -0,0 +1,518 @@
| 1 | +//! TOTP two-factor authentication service | |
| 2 | +//! | |
| 3 | +//! Provides methods for generating, verifying, and managing TOTP 2FA | |
| 4 | +//! including backup codes and login challenge flow. | |
| 5 | + | |
| 6 | +use chrono::{Duration, Utc}; | |
| 7 | +use sea_orm::{ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter, Set}; | |
| 8 | +use totp_rs::{Algorithm, Secret, TOTP}; | |
| 9 | +use uuid::Uuid; | |
| 10 | + | |
| 11 | +use crate::error::AppError; | |
| 12 | +use crate::models::{totp_challenge, user_totp}; | |
| 13 | +use crate::services::crypto_service::CryptoService; | |
| 14 | + | |
| 15 | +const BACKUP_CODE_COUNT: usize = 10; | |
| 16 | +const BACKUP_CODE_LENGTH: usize = 8; | |
| 17 | +const CHALLENGE_EXPIRY_MINUTES: i64 = 5; | |
| 18 | +const MAX_CHALLENGE_ATTEMPTS: i32 = 5; | |
| 19 | + | |
| 20 | +/// Response from TOTP setup containing data needed to display QR code | |
| 21 | +pub struct TotpSetupResponse { | |
| 22 | + /// Base32-encoded secret for manual entry | |
| 23 | + pub secret_base32: String, | |
| 24 | + /// otpauth:// URI for QR code generation | |
| 25 | + pub otpauth_uri: String, | |
| 26 | + /// QR code as base64-encoded PNG data URI | |
| 27 | + pub qr_code_data_uri: String, | |
| 28 | +} | |
| 29 | + | |
| 30 | +/// TOTP two-factor authentication service | |
| 31 | +pub struct TotpService; | |
| 32 | + | |
| 33 | +impl TotpService { | |
| 34 | + /// Generate a new TOTP secret and create a setup row (enabled=false). | |
| 35 | + /// | |
| 36 | + /// Returns setup data including QR code for the authenticator app. | |
| 37 | + /// The user must verify with a valid code before 2FA is enabled. | |
| 38 | + pub async fn setup_totp( | |
| 39 | + db: &DatabaseConnection, | |
| 40 | + user_id: Uuid, | |
| 41 | + username: &str, | |
| 42 | + issuer: &str, | |
| 43 | + ) -> Result<TotpSetupResponse, AppError> { | |
| 44 | + // Check if TOTP is already enabled | |
| 45 | + if let Some(existing) = user_totp::Entity::find() | |
| 46 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 47 | + .one(db) | |
| 48 | + .await? | |
| 49 | + { | |
| 50 | + if existing.enabled { | |
| 51 | + return Err(AppError::TotpAlreadyEnabled); | |
| 52 | + } | |
| 53 | + // Delete incomplete setup to start fresh | |
| 54 | + let model: user_totp::ActiveModel = existing.into(); | |
| 55 | + model.delete(db).await?; | |
| 56 | + } | |
| 57 | + | |
| 58 | + // Generate TOTP secret | |
| 59 | + let secret = Secret::generate_secret(); | |
| 60 | + let secret_base32 = secret.to_encoded().to_string(); | |
| 61 | + | |
| 62 | + let totp = TOTP::new( | |
| 63 | + Algorithm::SHA1, | |
| 64 | + 6, | |
| 65 | + 1, | |
| 66 | + 30, | |
| 67 | + secret | |
| 68 | + .to_bytes() | |
| 69 | + .map_err(|e| AppError::Internal(format!("Failed to convert TOTP secret: {}", e)))?, | |
| 70 | + Some(issuer.to_string()), | |
| 71 | + username.to_string(), | |
| 72 | + ) | |
| 73 | + .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?; | |
| 74 | + | |
| 75 | + let otpauth_uri = totp.get_url(); | |
| 76 | + | |
| 77 | + // Generate QR code as base64 PNG data URI | |
| 78 | + let qr_code_data_uri = totp | |
| 79 | + .get_qr_base64() | |
| 80 | + .map_err(|e| AppError::Internal(format!("Failed to generate QR code: {}", e)))?; | |
| 81 | + let qr_code_data_uri = format!("data:image/png;base64,{}", qr_code_data_uri); | |
| 82 | + | |
| 83 | + // Encrypt secret before storage | |
| 84 | + let secret_encrypted = CryptoService::encrypt(&secret_base32)?; | |
| 85 | + | |
| 86 | + // Create user_totp row with enabled=false | |
| 87 | + let now = Utc::now(); | |
| 88 | + let model = user_totp::ActiveModel { | |
| 89 | + id: Set(Uuid::new_v4()), | |
| 90 | + user_id: Set(user_id), | |
| 91 | + secret_encrypted: Set(secret_encrypted), | |
| 92 | + enabled: Set(false), | |
| 93 | + backup_codes_encrypted: Set(None), | |
| 94 | + backup_codes_remaining: Set(0), | |
| 95 | + enabled_at: Set(None), | |
| 96 | + created_at: Set(now), | |
| 97 | + updated_at: Set(now), | |
| 98 | + }; | |
| 99 | + model.insert(db).await?; | |
| 100 | + | |
| 101 | + Ok(TotpSetupResponse { | |
| 102 | + secret_base32, | |
| 103 | + otpauth_uri, | |
| 104 | + qr_code_data_uri, | |
| 105 | + }) | |
| 106 | + } | |
| 107 | + | |
| 108 | + /// Verify a TOTP code during setup and enable 2FA. | |
| 109 | + /// | |
| 110 | + /// On success, generates backup codes and returns them as plaintext (one-time display). | |
| 111 | + pub async fn verify_and_enable( | |
| 112 | + db: &DatabaseConnection, | |
| 113 | + user_id: Uuid, | |
| 114 | + code: &str, | |
| 115 | + ) -> Result<Vec<String>, AppError> { | |
| 116 | + let record = user_totp::Entity::find() | |
| 117 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 118 | + .one(db) | |
| 119 | + .await? | |
| 120 | + .ok_or_else(|| AppError::NotFound("TOTP setup not found".to_string()))?; | |
| 121 | + | |
| 122 | + if record.enabled { | |
| 123 | + return Err(AppError::TotpAlreadyEnabled); | |
| 124 | + } | |
| 125 | + | |
| 126 | + // Decrypt secret and verify code | |
| 127 | + let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?; | |
| 128 | + if !Self::verify_totp_code(&secret_base32, code)? { | |
| 129 | + return Err(AppError::TotpFailed); | |
| 130 | + } | |
| 131 | + | |
| 132 | + // Generate backup codes | |
| 133 | + let (plaintext_codes, encrypted_codes) = Self::generate_backup_codes_internal()?; | |
| 134 | + | |
| 135 | + // Enable TOTP | |
| 136 | + let now = Utc::now(); | |
| 137 | + let mut model: user_totp::ActiveModel = record.into(); | |
| 138 | + model.enabled = Set(true); | |
| 139 | + model.enabled_at = Set(Some(now)); | |
| 140 | + model.backup_codes_encrypted = Set(Some(encrypted_codes)); | |
| 141 | + model.backup_codes_remaining = Set(BACKUP_CODE_COUNT as i32); | |
| 142 | + model.updated_at = Set(now); | |
| 143 | + model.update(db).await?; | |
| 144 | + | |
| 145 | + Ok(plaintext_codes) | |
| 146 | + } | |
| 147 | + | |
| 148 | + /// Verify a TOTP code for an enabled user (login flow). | |
| 149 | + pub async fn verify_code( | |
| 150 | + db: &DatabaseConnection, | |
| 151 | + user_id: Uuid, | |
| 152 | + code: &str, | |
| 153 | + ) -> Result<bool, AppError> { | |
| 154 | + let record = user_totp::Entity::find() | |
| 155 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 156 | + .filter(user_totp::Column::Enabled.eq(true)) | |
| 157 | + .one(db) | |
| 158 | + .await? | |
| 159 | + .ok_or_else(|| AppError::TotpNotEnabled)?; | |
| 160 | + | |
| 161 | + let secret_base32 = CryptoService::decrypt(&record.secret_encrypted)?; | |
| 162 | + Self::verify_totp_code(&secret_base32, code) | |
| 163 | + } | |
| 164 | + | |
| 165 | + /// Verify and consume a backup code. | |
| 166 | + /// | |
| 167 | + /// Returns true if the code was valid and consumed. | |
| 168 | + pub async fn verify_backup_code( | |
| 169 | + db: &DatabaseConnection, | |
| 170 | + user_id: Uuid, | |
| 171 | + code: &str, | |
| 172 | + ) -> Result<bool, AppError> { | |
| 173 | + let record = user_totp::Entity::find() | |
| 174 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 175 | + .filter(user_totp::Column::Enabled.eq(true)) | |
| 176 | + .one(db) | |
| 177 | + .await? | |
| 178 | + .ok_or_else(|| AppError::TotpNotEnabled)?; | |
| 179 | + | |
| 180 | + let encrypted_codes = match &record.backup_codes_encrypted { | |
| 181 | + Some(c) => c.clone(), | |
| 182 | + None => return Ok(false), | |
| 183 | + }; | |
| 184 | + | |
| 185 | + // Decrypt the JSON array of hashed codes | |
| 186 | + let codes_json = CryptoService::decrypt(&encrypted_codes)?; | |
| 187 | + let mut hashed_codes: Vec<String> = serde_json::from_str(&codes_json) | |
| 188 | + .map_err(|e| AppError::Internal(format!("Failed to parse backup codes: {}", e)))?; | |
| 189 | + | |
| 190 | + // Find matching code | |
| 191 | + let mut found_index = None; | |
| 192 | + for (i, hashed) in hashed_codes.iter().enumerate() { | |
| 193 | + if bcrypt::verify(code, hashed).unwrap_or(false) { | |
| 194 | + found_index = Some(i); | |
| 195 | + break; | |
| 196 | + } | |
| 197 | + } | |
| 198 | + | |
| 199 | + let Some(index) = found_index else { | |
| 200 | + return Ok(false); | |
| 201 | + }; | |
| 202 | + | |
| 203 | + // Remove used code | |
| 204 | + hashed_codes.remove(index); | |
| 205 | + | |
| 206 | + // Re-encrypt and save | |
| 207 | + let updated_json = serde_json::to_string(&hashed_codes) | |
| 208 | + .map_err(|e| AppError::Internal(format!("Failed to serialize backup codes: {}", e)))?; | |
| 209 | + let updated_encrypted = CryptoService::encrypt(&updated_json)?; | |
| 210 | + | |
| 211 | + let mut model: user_totp::ActiveModel = record.into(); | |
| 212 | + model.backup_codes_encrypted = Set(Some(updated_encrypted)); | |
| 213 | + model.backup_codes_remaining = Set(hashed_codes.len() as i32); | |
| 214 | + model.updated_at = Set(Utc::now()); | |
| 215 | + model.update(db).await?; | |
| 216 | + | |
| 217 | + Ok(true) | |
| 218 | + } | |
| 219 | + | |
| 220 | + /// Disable TOTP for a user (deletes the user_totp row). | |
| 221 | + pub async fn disable_totp(db: &DatabaseConnection, user_id: Uuid) -> Result<(), AppError> { | |
| 222 | + let record = user_totp::Entity::find() | |
| 223 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 224 | + .one(db) | |
| 225 | + .await? | |
| 226 | + .ok_or_else(|| AppError::TotpNotEnabled)?; | |
| 227 | + | |
| 228 | + let model: user_totp::ActiveModel = record.into(); | |
| 229 | + model.delete(db).await?; | |
| 230 | + Ok(()) | |
| 231 | + } | |
| 232 | + | |
| 233 | + /// Regenerate backup codes. Returns new plaintext codes. | |
| 234 | + /// | |
| 235 | + /// Replaces all existing backup codes. | |
| 236 | + pub async fn regenerate_backup_codes( | |
| 237 | + db: &DatabaseConnection, | |
| 238 | + user_id: Uuid, | |
| 239 | + ) -> Result<Vec<String>, AppError> { | |
| 240 | + let record = user_totp::Entity::find() | |
| 241 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 242 | + .filter(user_totp::Column::Enabled.eq(true)) | |
| 243 | + .one(db) | |
| 244 | + .await? | |
| 245 | + .ok_or_else(|| AppError::TotpNotEnabled)?; | |
| 246 | + | |
| 247 | + let (plaintext_codes, encrypted_codes) = Self::generate_backup_codes_internal()?; | |
| 248 | + | |
| 249 | + let mut model: user_totp::ActiveModel = record.into(); | |
| 250 | + model.backup_codes_encrypted = Set(Some(encrypted_codes)); | |
| 251 | + model.backup_codes_remaining = Set(BACKUP_CODE_COUNT as i32); | |
| 252 | + model.updated_at = Set(Utc::now()); | |
| 253 | + model.update(db).await?; | |
| 254 | + | |
| 255 | + Ok(plaintext_codes) | |
| 256 | + } | |
| 257 | + | |
| 258 | + /// Check if a user has TOTP enabled. | |
| 259 | + pub async fn is_totp_enabled(db: &DatabaseConnection, user_id: Uuid) -> Result<bool, AppError> { | |
| 260 | + let record = user_totp::Entity::find() | |
| 261 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 262 | + .filter(user_totp::Column::Enabled.eq(true)) | |
| 263 | + .one(db) | |
| 264 | + .await?; | |
| 265 | + Ok(record.is_some()) | |
| 266 | + } | |
| 267 | + | |
| 268 | + /// Get TOTP info for a user (for display in settings/admin). | |
| 269 | + pub async fn get_totp_info( | |
| 270 | + db: &DatabaseConnection, | |
| 271 | + user_id: Uuid, | |
| 272 | + ) -> Result<Option<user_totp::Model>, AppError> { | |
| 273 | + let record = user_totp::Entity::find() | |
| 274 | + .filter(user_totp::Column::UserId.eq(user_id)) | |
| 275 | + .one(db) | |
| 276 | + .await?; | |
| 277 | + Ok(record) | |
| 278 | + } | |
| 279 | + | |
| 280 | + // --- Challenge management (for 2FA login flow) --- | |
| 281 | + | |
| 282 | + /// Create a 2FA challenge for the login flow. | |
| 283 | + /// | |
| 284 | + /// Returns the challenge token to send to the client. | |
| 285 | + pub async fn create_challenge( | |
| 286 | + db: &DatabaseConnection, | |
| 287 | + user_id: Uuid, | |
| 288 | + ) -> Result<String, AppError> { | |
| 289 | + // Check for too many recent failed challenges (anti-brute-force) | |
| 290 | + let recent_cutoff = Utc::now() - Duration::minutes(15); | |
| 291 | + let recent_failed = totp_challenge::Entity::find() | |
| 292 | + .filter(totp_challenge::Column::UserId.eq(user_id)) | |
| 293 | + .filter(totp_challenge::Column::CreatedAt.gte(recent_cutoff)) | |
| 294 | + .filter(totp_challenge::Column::Attempts.gte(MAX_CHALLENGE_ATTEMPTS)) | |
| 295 | + .all(db) | |
| 296 | + .await?; | |
| 297 | + | |
| 298 | + if recent_failed.len() >= 3 { | |
| 299 | + return Err(AppError::TotpMaxAttemptsExceeded); | |
| 300 | + } | |
| 301 | + | |
| 302 | + let challenge_token = Uuid::new_v4().to_string(); | |
| 303 | + let now = Utc::now(); | |
| 304 | + | |
| 305 | + let model = totp_challenge::ActiveModel { | |
| 306 | + id: Set(Uuid::new_v4()), | |
| 307 | + user_id: Set(user_id), | |
| 308 | + challenge_token: Set(challenge_token.clone()), | |
| 309 | + attempts: Set(0), | |
| 310 | + max_attempts: Set(MAX_CHALLENGE_ATTEMPTS), | |
| 311 | + expires_at: Set(now + Duration::minutes(CHALLENGE_EXPIRY_MINUTES)), | |
| 312 | + created_at: Set(now), | |
| 313 | + }; | |
| 314 | + model.insert(db).await?; | |
| 315 | + | |
| 316 | + Ok(challenge_token) | |
| 317 | + } | |
| 318 | + | |
| 319 | + /// Validate a challenge token. Returns the user_id if valid. | |
| 320 | + /// | |
| 321 | + /// Checks expiration and max attempts. | |
| 322 | + pub async fn validate_challenge( | |
| 323 | + db: &DatabaseConnection, | |
| 324 | + challenge_token: &str, | |
| 325 | + ) -> Result<Uuid, AppError> { | |
| 326 | + let challenge = totp_challenge::Entity::find() | |
| 327 | + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token)) | |
| 328 | + .one(db) | |
| 329 | + .await? | |
| 330 | + .ok_or(AppError::TotpChallengeInvalid)?; | |
| 331 | + | |
| 332 | + // Check expiration | |
| 333 | + if Utc::now() > challenge.expires_at { | |
| 334 | + // Delete expired challenge | |
| 335 | + let model: totp_challenge::ActiveModel = challenge.into(); | |
| 336 | + model.delete(db).await?; | |
| 337 | + return Err(AppError::TotpChallengeInvalid); | |
| 338 | + } | |
| 339 | + | |
| 340 | + // Check max attempts | |
| 341 | + if challenge.attempts >= challenge.max_attempts { | |
| 342 | + return Err(AppError::TotpMaxAttemptsExceeded); | |
| 343 | + } | |
| 344 | + | |
| 345 | + Ok(challenge.user_id) | |
| 346 | + } | |
| 347 | + | |
| 348 | + /// Increment attempt counter for a challenge. | |
| 349 | + pub async fn record_challenge_attempt( | |
| 350 | + db: &DatabaseConnection, | |
| 351 | + challenge_token: &str, | |
| 352 | + ) -> Result<(), AppError> { | |
| 353 | + let challenge = totp_challenge::Entity::find() | |
| 354 | + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token)) | |
| 355 | + .one(db) | |
| 356 | + .await? | |
| 357 | + .ok_or(AppError::TotpChallengeInvalid)?; | |
| 358 | + | |
| 359 | + let mut model: totp_challenge::ActiveModel = challenge.into(); | |
| 360 | + model.attempts = Set(model.attempts.unwrap() + 1); | |
| 361 | + model.update(db).await?; | |
| 362 | + Ok(()) | |
| 363 | + } | |
| 364 | + | |
| 365 | + /// Delete a challenge after successful verification. | |
| 366 | + pub async fn consume_challenge( | |
| 367 | + db: &DatabaseConnection, | |
| 368 | + challenge_token: &str, | |
| 369 | + ) -> Result<(), AppError> { | |
| 370 | + let challenge = totp_challenge::Entity::find() | |
| 371 | + .filter(totp_challenge::Column::ChallengeToken.eq(challenge_token)) | |
| 372 | + .one(db) | |
| 373 | + .await?; | |
| 374 | + | |
| 375 | + if let Some(c) = challenge { | |
| 376 | + let model: totp_challenge::ActiveModel = c.into(); | |
| 377 | + model.delete(db).await?; | |
| 378 | + } | |
| 379 | + Ok(()) | |
| 380 | + } | |
| 381 | + | |
| 382 | + /// Cleanup expired challenges (background task). | |
| 383 | + pub async fn cleanup_expired_challenges(db: &DatabaseConnection) -> Result<u64, AppError> { | |
| 384 | + let now = Utc::now(); | |
| 385 | + let result = totp_challenge::Entity::delete_many() | |
| 386 | + .filter(totp_challenge::Column::ExpiresAt.lt(now)) | |
| 387 | + .exec(db) | |
| 388 | + .await?; | |
| 389 | + Ok(result.rows_affected) | |
| 390 | + } | |
| 391 | + | |
| 392 | + // --- Internal helpers --- | |
| 393 | + | |
| 394 | + /// Verify a TOTP code against a base32-encoded secret. | |
| 395 | + fn verify_totp_code(secret_base32: &str, code: &str) -> Result<bool, AppError> { | |
| 396 | + let secret = Secret::Encoded(secret_base32.to_string()); | |
| 397 | + let totp = TOTP::new( | |
| 398 | + Algorithm::SHA1, | |
| 399 | + 6, | |
| 400 | + 1, | |
| 401 | + 30, | |
| 402 | + secret | |
| 403 | + .to_bytes() | |
| 404 | + .map_err(|e| AppError::Internal(format!("Failed to decode TOTP secret: {}", e)))?, | |
| 405 | + None, | |
| 406 | + String::new(), | |
| 407 | + ) | |
| 408 | + .map_err(|e| AppError::Internal(format!("Failed to create TOTP: {}", e)))?; | |
| 409 | + | |
| 410 | + Ok(totp.check_current(code).unwrap_or(false)) | |
| 411 | + } | |
| 412 | + | |
| 413 | + /// Generate backup codes: returns (plaintext_codes, encrypted_json_of_hashed_codes). | |
| 414 | + fn generate_backup_codes_internal() -> Result<(Vec<String>, String), AppError> { | |
| 415 | + use rand::Rng; | |
| 416 | + | |
| 417 | + let mut rng = rand::rng(); | |
| 418 | + let charset: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No I/O/0/1 for readability | |
| 419 | + | |
| 420 | + let mut plaintext_codes = Vec::with_capacity(BACKUP_CODE_COUNT); | |
| 421 | + let mut hashed_codes = Vec::with_capacity(BACKUP_CODE_COUNT); | |
| 422 | + | |
| 423 | + for _ in 0..BACKUP_CODE_COUNT { | |
| 424 | + let code: String = (0..BACKUP_CODE_LENGTH) | |
| 425 | + .map(|_| { | |
| 426 | + let idx = rng.random_range(0..charset.len()); | |
| 427 | + charset[idx] as char | |
| 428 | + }) | |
| 429 | + .collect(); | |
| 430 | + | |
| 431 | + let hashed = bcrypt::hash(&code, 10) | |
| 432 | + .map_err(|e| AppError::Internal(format!("Failed to hash backup code: {}", e)))?; | |
| 433 | + | |
| 434 | + plaintext_codes.push(code); | |
| 435 | + hashed_codes.push(hashed); | |
| 436 | + } | |
| 437 | + | |
| 438 | + let codes_json = serde_json::to_string(&hashed_codes) | |
| 439 | + .map_err(|e| AppError::Internal(format!("Failed to serialize backup codes: {}", e)))?; | |
| 440 | + let encrypted = CryptoService::encrypt(&codes_json)?; | |
| 441 | + | |
| 442 | + Ok((plaintext_codes, encrypted)) | |
| 443 | + } | |
| 444 | +} | |
| 445 | + | |
| 446 | +#[cfg(test)] | |
| 447 | +mod tests { | |
| 448 | + use super::*; | |
| 449 | + | |
| 450 | + #[test] | |
| 451 | + fn test_verify_totp_code_with_known_secret() { | |
| 452 | + // Generate a secret and create a valid code, then verify | |
| 453 | + let secret = Secret::generate_secret(); | |
| 454 | + let secret_base32 = secret.to_encoded().to_string(); | |
| 455 | + | |
| 456 | + let totp = TOTP::new( | |
| 457 | + Algorithm::SHA1, | |
| 458 | + 6, | |
| 459 | + 1, | |
| 460 | + 30, | |
| 461 | + secret.to_bytes().unwrap(), | |
| 462 | + None, | |
| 463 | + String::new(), | |
| 464 | + ) | |
| 465 | + .unwrap(); | |
| 466 | + | |
| 467 | + let valid_code = totp.generate_current().unwrap(); | |
| 468 | + let result = TotpService::verify_totp_code(&secret_base32, &valid_code).unwrap(); | |
| 469 | + assert!(result, "Valid TOTP code should verify"); | |
| 470 | + | |
| 471 | + let result = TotpService::verify_totp_code(&secret_base32, "000000").unwrap(); | |
| 472 | + // May or may not be valid depending on timing, but "000000" is rarely valid | |
| 473 | + // So we just check it doesn't error | |
| 474 | + let _ = result; | |
| 475 | + } | |
| 476 | + | |
| 477 | + #[test] | |
| 478 | + fn test_generate_backup_codes() { | |
| 479 | + std::env::set_var( | |
| 480 | + "ENCRYPTION_KEY", | |
| 481 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 482 | + ); | |
| 483 | + | |
| 484 | + let (plaintext, encrypted) = TotpService::generate_backup_codes_internal().unwrap(); | |
| 485 | + | |
| 486 | + assert_eq!(plaintext.len(), BACKUP_CODE_COUNT); | |
| 487 | + assert!(encrypted.starts_with("enc:")); | |
| 488 | + | |
| 489 | + // Each code should be 8 characters | |
| 490 | + for code in &plaintext { | |
| 491 | + assert_eq!(code.len(), BACKUP_CODE_LENGTH); | |
| 492 | + } | |
| 493 | + | |
| 494 | + // Decrypt and verify hashes match | |
| 495 | + let decrypted_json = CryptoService::decrypt(&encrypted).unwrap(); | |
| 496 | + let hashed_codes: Vec<String> = serde_json::from_str(&decrypted_json).unwrap(); | |
| 497 | + assert_eq!(hashed_codes.len(), BACKUP_CODE_COUNT); | |
| 498 | + | |
| 499 | + // Verify each plaintext code matches its hash | |
| 500 | + for (plain, hashed) in plaintext.iter().zip(hashed_codes.iter()) { | |
| 501 | + assert!(bcrypt::verify(plain, hashed).unwrap()); | |
| 502 | + } | |
| 503 | + } | |
| 504 | + | |
| 505 | + #[test] | |
| 506 | + fn test_backup_codes_are_unique() { | |
| 507 | + std::env::set_var( | |
| 508 | + "ENCRYPTION_KEY", | |
| 509 | + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef", | |
| 510 | + ); | |
| 511 | + | |
| 512 | + let (codes, _) = TotpService::generate_backup_codes_internal().unwrap(); | |
| 513 | + let mut unique = std::collections::HashSet::new(); | |
| 514 | + for code in &codes { | |
| 515 | + assert!(unique.insert(code.clone()), "Backup codes should be unique"); | |
| 516 | + } | |
| 517 | + } | |
| 518 | +} |
M
crates/rustwarden-core/src/services/user_preferences_service.rs
+0
-11
@@ -116,14 +116,3 @@ impl UserPreferencesService {
| 116 | 116 | Self::create_preferences(db, user_id, "light".to_string(), "fr".to_string()).await |
| 117 | 117 | } |
| 118 | 118 | } |
| 119 | - | |
| 120 | -#[cfg(test)] | |
| 121 | -mod tests { | |
| 122 | - use super::*; | |
| 123 | - | |
| 124 | - #[tokio::test] | |
| 125 | - async fn test_get_preferences_not_found() { | |
| 126 | - // This test would require a test database setup | |
| 127 | - // For now, it's a placeholder | |
| 128 | - } | |
| 129 | -} |
M
crates/rustwarden-core/src/tasks/mod.rs
+1
-0
@@ -6,3 +6,4 @@
| 6 | 6 | pub mod email_queue_processor; |
| 7 | 7 | pub mod password_change_cleanup; |
| 8 | 8 | pub mod password_change_email_retry; |
| 9 | +pub mod personal_token_cleanup; |
M
crates/rustwarden-core/src/tasks/password_change_email_retry.rs
+6
-2
@@ -112,7 +112,10 @@ impl PasswordChangeEmailRetryProcessor {
| 112 | 112 | let pending_requests = password_change_request::Entity::find() |
| 113 | 113 | .filter(password_change_request::Column::ConfirmedAt.is_null()) |
| 114 | 114 | .filter(password_change_request::Column::ExpiresAt.gt(now)) |
| 115 | - .filter(password_change_request::Column::RetryCount.lt(crate::config::tasks::password_change_max_retries())) | |
| 115 | + .filter( | |
| 116 | + password_change_request::Column::RetryCount | |
| 117 | + .lt(crate::config::tasks::password_change_max_retries()), | |
| 118 | + ) | |
| 116 | 119 | .all(&*self.db) |
| 117 | 120 | .await?; |
| 118 | 121 |
@@ -121,7 +124,8 @@ impl PasswordChangeEmailRetryProcessor {
| 121 | 124 | } |
| 122 | 125 | |
| 123 | 126 | let mut processed = 0; |
| 124 | - let retry_interval = Duration::minutes(crate::config::tasks::password_change_retry_gap_minutes()); | |
| 127 | + let retry_interval = | |
| 128 | + Duration::minutes(crate::config::tasks::password_change_retry_gap_minutes()); | |
| 125 | 129 | |
| 126 | 130 | for request in pending_requests { |
| 127 | 131 | // Check if enough time has passed since last attempt |
A
crates/rustwarden-core/src/tasks/personal_token_cleanup.rs
+50
-0
@@ -0,0 +1,50 @@
| 1 | +//! Personal access token cleanup task. | |
| 2 | +//! | |
| 3 | +//! Periodically hard-deletes PATs that are both expired AND revoked. See | |
| 4 | +//! [`PersonalTokenService::cleanup_expired`](crate::services::personal_token_service::PersonalTokenService::cleanup_expired) | |
| 5 | +//! for the exact predicate. | |
| 6 | + | |
| 7 | +use crate::services::personal_token_service::{PersonalTokenService, TokenScope}; | |
| 8 | +use sea_orm::DatabaseConnection; | |
| 9 | +use std::marker::PhantomData; | |
| 10 | +use std::sync::Arc; | |
| 11 | +use tokio::time::Duration; | |
| 12 | + | |
| 13 | +/// Background processor that periodically drops expired+revoked PAT rows. | |
| 14 | +/// | |
| 15 | +/// Generic over the app's scope vocabulary only so that | |
| 16 | +/// [`PersonalTokenService::<S>::cleanup_expired`] can be reached — the | |
| 17 | +/// predicate itself is scope-agnostic. | |
| 18 | +pub struct PersonalTokenCleanupProcessor<S: TokenScope> { | |
| 19 | + db: Arc<DatabaseConnection>, | |
| 20 | + _phantom: PhantomData<S>, | |
| 21 | +} | |
| 22 | + | |
| 23 | +impl<S: TokenScope> PersonalTokenCleanupProcessor<S> { | |
| 24 | + /// Create a new cleanup processor bound to the given database connection. | |
| 25 | + pub fn new(db: Arc<DatabaseConnection>) -> Self { | |
| 26 | + Self { | |
| 27 | + db, | |
| 28 | + _phantom: PhantomData, | |
| 29 | + } | |
| 30 | + } | |
| 31 | + | |
| 32 | + /// Spawn the cleanup loop. First tick fires immediately (to purge stale | |
| 33 | + /// rows on process start), then every | |
| 34 | + /// [`config::tasks::personal_token_cleanup_interval_secs`](crate::config::tasks::personal_token_cleanup_interval_secs). | |
| 35 | + pub fn start(self) { | |
| 36 | + tokio::spawn(async move { | |
| 37 | + let secs = crate::config::tasks::personal_token_cleanup_interval_secs(); | |
| 38 | + let mut ticker = tokio::time::interval(Duration::from_secs(secs)); | |
| 39 | + ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); | |
| 40 | + loop { | |
| 41 | + ticker.tick().await; | |
| 42 | + match PersonalTokenService::<S>::cleanup_expired(&self.db).await { | |
| 43 | + Ok(0) => {} | |
| 44 | + Ok(n) => tracing::info!("PAT cleanup: deleted {} expired+revoked token(s)", n), | |
| 45 | + Err(e) => tracing::error!("PAT cleanup failed: {}", e), | |
| 46 | + } | |
| 47 | + } | |
| 48 | + }); | |
| 49 | + } | |
| 50 | +} |
M
crates/rustwarden-core/src/templates/mod.rs
+76
-0
@@ -33,13 +33,18 @@ macro_rules! impl_template_response {
| 33 | 33 | /// Base layout context shared by all pages |
| 34 | 34 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 35 | 35 | pub struct LayoutContext { |
| 36 | + /// HTML `<title>` and heading text for the page. | |
| 36 | 37 | pub page_title: String, |
| 38 | + /// CSRF token embedded in forms to prevent cross-site request forgery. | |
| 37 | 39 | pub csrf_token: String, |
| 40 | + /// Application display name (from `APP_NAME` env var). | |
| 38 | 41 | pub app_name: String, |
| 42 | + /// Application version string from `Cargo.toml`. | |
| 39 | 43 | pub app_version: String, |
| 40 | 44 | } |
| 41 | 45 | |
| 42 | 46 | impl LayoutContext { |
| 47 | + /// Create a new `LayoutContext` with the given page title and CSRF token. | |
| 43 | 48 | pub fn new(page_title: &str, csrf_token: &str) -> Self { |
| 44 | 49 | Self { |
| 45 | 50 | page_title: page_title.to_string(), |
@@ -48,6 +53,30 @@ impl LayoutContext {
| 48 | 53 | app_version: env!("CARGO_PKG_VERSION").to_string(), |
| 49 | 54 | } |
| 50 | 55 | } |
| 56 | + | |
| 57 | + /// Async constructor that populates `csrf_token` from the tower-sessions session. | |
| 58 | + /// | |
| 59 | + /// Replaces the manual pattern: | |
| 60 | + /// ```rust,ignore | |
| 61 | + /// let csrf_token = get_or_create_csrf_token(&session).await?; | |
| 62 | + /// let ctx = LayoutContext::new("My Page", &csrf_token); | |
| 63 | + /// ``` | |
| 64 | + /// | |
| 65 | + /// Falls back to an empty token (with a warning log) if session access fails, | |
| 66 | + /// so template rendering never panics on a transient session backend error. | |
| 67 | + pub async fn from_session(session: &tower_sessions::Session, page_title: &str) -> Self { | |
| 68 | + let token = match crate::middleware::csrf::get_or_create_csrf_token(session).await { | |
| 69 | + Ok(t) => t, | |
| 70 | + Err(e) => { | |
| 71 | + tracing::warn!( | |
| 72 | + error = %e, | |
| 73 | + "LayoutContext::from_session failed to fetch CSRF token, falling back to empty" | |
| 74 | + ); | |
| 75 | + String::new() | |
| 76 | + } | |
| 77 | + }; | |
| 78 | + Self::new(page_title, &token) | |
| 79 | + } | |
| 51 | 80 | } |
| 52 | 81 | |
| 53 | 82 | impl Default for LayoutContext { |
@@ -55,3 +84,50 @@ impl Default for LayoutContext {
| 55 | 84 | Self::new("", "") |
| 56 | 85 | } |
| 57 | 86 | } |
| 87 | + | |
| 88 | +#[cfg(test)] | |
| 89 | +mod tests { | |
| 90 | + use super::*; | |
| 91 | + use std::sync::Arc; | |
| 92 | + use tower_sessions::{MemoryStore, Session}; | |
| 93 | + | |
| 94 | + fn make_test_session() -> Session { | |
| 95 | + let store = Arc::new(MemoryStore::default()); | |
| 96 | + Session::new(None, store, None) | |
| 97 | + } | |
| 98 | + | |
| 99 | + #[tokio::test] | |
| 100 | + async fn from_session_generates_csrf_token_when_absent() { | |
| 101 | + let session = make_test_session(); | |
| 102 | + let ctx = LayoutContext::from_session(&session, "Home").await; | |
| 103 | + | |
| 104 | + assert_eq!(ctx.page_title, "Home"); | |
| 105 | + assert_eq!( | |
| 106 | + ctx.csrf_token.len(), | |
| 107 | + 64, | |
| 108 | + "csrf_token should be a 32-byte hex string (64 chars)" | |
| 109 | + ); | |
| 110 | + } | |
| 111 | + | |
| 112 | + #[tokio::test] | |
| 113 | + async fn from_session_returns_existing_csrf_token() { | |
| 114 | + let session = make_test_session(); | |
| 115 | + let first = LayoutContext::from_session(&session, "First").await; | |
| 116 | + let second = LayoutContext::from_session(&session, "Second").await; | |
| 117 | + | |
| 118 | + assert_eq!( | |
| 119 | + first.csrf_token, second.csrf_token, | |
| 120 | + "from_session must reuse the session's existing token" | |
| 121 | + ); | |
| 122 | + assert_eq!(second.page_title, "Second"); | |
| 123 | + } | |
| 124 | + | |
| 125 | + #[tokio::test] | |
| 126 | + async fn from_session_fills_app_metadata() { | |
| 127 | + let session = make_test_session(); | |
| 128 | + let ctx = LayoutContext::from_session(&session, "Any").await; | |
| 129 | + | |
| 130 | + assert!(!ctx.app_name.is_empty()); | |
| 131 | + assert!(!ctx.app_version.is_empty()); | |
| 132 | + } | |
| 133 | +} |
A
crates/rustwarden-core/src/types/mod.rs
+9
-0
@@ -0,0 +1,9 @@
| 1 | +//! Framework-level value objects. | |
| 2 | +//! | |
| 3 | +//! Small, focused wrappers around strings/ids that carry a validation | |
| 4 | +//! invariant in their type. Keeps "unchecked" strings out of handlers and | |
| 5 | +//! services. | |
| 6 | + | |
| 7 | +pub mod team_slug; | |
| 8 | + | |
| 9 | +pub use team_slug::TeamSlug; |
A
crates/rustwarden-core/src/types/team_slug.rs
+127
-0
@@ -0,0 +1,127 @@
| 1 | +//! Validated team slug. | |
| 2 | +//! | |
| 3 | +//! A [`TeamSlug`] is a lowercased, URL-safe handle for a team. Enforced | |
| 4 | +//! invariants: | |
| 5 | +//! | |
| 6 | +//! - 1 to 64 characters | |
| 7 | +//! - `[a-z0-9-]+` (lowercase ASCII letters, digits, hyphen) | |
| 8 | +//! - Neither starts nor ends with `-` | |
| 9 | +//! - Not one of the [`RESERVED`] names (routes we never want to shadow) | |
| 10 | +//! | |
| 11 | +//! Wrapping raw strings in `TeamSlug` keeps `TeamService::create` and route | |
| 12 | +//! handlers from having to re-validate. | |
| 13 | + | |
| 14 | +use crate::error::AppError; | |
| 15 | +use serde::{Deserialize, Serialize}; | |
| 16 | + | |
| 17 | +/// Reserved slugs that would collide with framework routes or reserved UI | |
| 18 | +/// segments. Match CI-checked list, not an exhaustive blocklist. | |
| 19 | +pub const RESERVED: &[&str] = &[ | |
| 20 | + "admin", "api", "auth", "health", "login", "logout", "new", "oauth", "register", "settings", | |
| 21 | + "signin", "signout", "signup", "static", "teams", | |
| 22 | +]; | |
| 23 | + | |
| 24 | +/// Validated team slug. Use [`TeamSlug::parse`] to construct. | |
| 25 | +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize)] | |
| 26 | +#[serde(transparent)] | |
| 27 | +pub struct TeamSlug(String); | |
| 28 | + | |
| 29 | +impl TeamSlug { | |
| 30 | + /// Parse a raw string into a `TeamSlug`. Returns | |
| 31 | + /// `AppError::Validation` on any rule violation so handlers can bubble | |
| 32 | + /// a 400 up without extra mapping. | |
| 33 | + pub fn parse(raw: &str) -> Result<Self, AppError> { | |
| 34 | + let s = raw.trim(); | |
| 35 | + if s.is_empty() || s.len() > 64 { | |
| 36 | + return Err(AppError::Validation( | |
| 37 | + "Team slug must be 1-64 characters".into(), | |
| 38 | + )); | |
| 39 | + } | |
| 40 | + if s.starts_with('-') || s.ends_with('-') { | |
| 41 | + return Err(AppError::Validation( | |
| 42 | + "Team slug cannot start or end with '-'".into(), | |
| 43 | + )); | |
| 44 | + } | |
| 45 | + if !s | |
| 46 | + .chars() | |
| 47 | + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') | |
| 48 | + { | |
| 49 | + return Err(AppError::Validation( | |
| 50 | + "Team slug must only contain lowercase letters, digits, and '-'".into(), | |
| 51 | + )); | |
| 52 | + } | |
| 53 | + if RESERVED.contains(&s) { | |
| 54 | + return Err(AppError::Validation(format!("Team slug '{s}' is reserved"))); | |
| 55 | + } | |
| 56 | + Ok(Self(s.to_string())) | |
| 57 | + } | |
| 58 | + | |
| 59 | + /// Return the slug as a string slice. | |
| 60 | + pub fn as_str(&self) -> &str { | |
| 61 | + &self.0 | |
| 62 | + } | |
| 63 | + | |
| 64 | + /// Consume the `TeamSlug` and return the inner `String`. | |
| 65 | + pub fn into_inner(self) -> String { | |
| 66 | + self.0 | |
| 67 | + } | |
| 68 | +} | |
| 69 | + | |
| 70 | +impl std::fmt::Display for TeamSlug { | |
| 71 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { | |
| 72 | + f.write_str(&self.0) | |
| 73 | + } | |
| 74 | +} | |
| 75 | + | |
| 76 | +#[cfg(test)] | |
| 77 | +mod tests { | |
| 78 | + use super::*; | |
| 79 | + | |
| 80 | + #[test] | |
| 81 | + fn valid_slug() { | |
| 82 | + assert_eq!(TeamSlug::parse("backend").unwrap().as_str(), "backend"); | |
| 83 | + assert_eq!(TeamSlug::parse("squad-42").unwrap().as_str(), "squad-42"); | |
| 84 | + } | |
| 85 | + | |
| 86 | + #[test] | |
| 87 | + fn trims_whitespace() { | |
| 88 | + assert_eq!(TeamSlug::parse(" devops ").unwrap().as_str(), "devops"); | |
| 89 | + } | |
| 90 | + | |
| 91 | + #[test] | |
| 92 | + fn rejects_empty() { | |
| 93 | + assert!(TeamSlug::parse("").is_err()); | |
| 94 | + assert!(TeamSlug::parse(" ").is_err()); | |
| 95 | + } | |
| 96 | + | |
| 97 | + #[test] | |
| 98 | + fn rejects_too_long() { | |
| 99 | + let too_long = "a".repeat(65); | |
| 100 | + assert!(TeamSlug::parse(&too_long).is_err()); | |
| 101 | + } | |
| 102 | + | |
| 103 | + #[test] | |
| 104 | + fn rejects_uppercase() { | |
| 105 | + assert!(TeamSlug::parse("Backend").is_err()); | |
| 106 | + } | |
| 107 | + | |
| 108 | + #[test] | |
| 109 | + fn rejects_special_chars() { | |
| 110 | + assert!(TeamSlug::parse("back end").is_err()); | |
| 111 | + assert!(TeamSlug::parse("back_end").is_err()); | |
| 112 | + assert!(TeamSlug::parse("back.end").is_err()); | |
| 113 | + } | |
| 114 | + | |
| 115 | + #[test] | |
| 116 | + fn rejects_leading_or_trailing_hyphen() { | |
| 117 | + assert!(TeamSlug::parse("-backend").is_err()); | |
| 118 | + assert!(TeamSlug::parse("backend-").is_err()); | |
| 119 | + } | |
| 120 | + | |
| 121 | + #[test] | |
| 122 | + fn rejects_reserved() { | |
| 123 | + for r in RESERVED { | |
| 124 | + assert!(TeamSlug::parse(r).is_err(), "{r} should be reserved"); | |
| 125 | + } | |
| 126 | + } | |
| 127 | +} |
M
crates/rustwarden-ui/Cargo.toml
+2
-2
@@ -35,5 +35,5 @@ num-format = "0.4.4"
| 35 | 35 | # Time |
| 36 | 36 | time = { version = "0.3", features = ["macros", "serde"] } |
| 37 | 37 | |
| 38 | -[lints.clippy] | |
| 39 | -empty_line_after_doc_comments = "allow" | |
| 38 | +[lints] | |
| 39 | +workspace = true |
M
crates/rustwarden-ui/locales/de.yml
+49
-2
@@ -7,7 +7,7 @@ login:
| 7 | 7 | page_title: "Anmeldung - RustCloud" |
| 8 | 8 | page_description: "Anmeldung bei RustCloud" |
| 9 | 9 | subtitle: "Melden Sie sich in Ihrem Cloud-Bereich an" |
| 10 | - username: "Benutzername" | |
| 10 | + username: "Benutzername oder E-Mail" | |
| 11 | 11 | password: "Passwort" |
| 12 | 12 | submit: "Anmelden" |
| 13 | 13 | remember_me: "Angemeldet bleiben" |
@@ -15,7 +15,7 @@ login:
| 15 | 15 | tab_register: "Registrierung" |
| 16 | 16 | register_title: "Konto erstellen" |
| 17 | 17 | register_submit: "Mein Konto erstellen" |
| 18 | - username_placeholder: "Ihr Benutzername" | |
| 18 | + username_placeholder: "Benutzername oder E-Mail" | |
| 19 | 19 | password_placeholder: "Ihr Passwort" |
| 20 | 20 | username_placeholder_register: "Ihr Benutzername (min. 3 Zeichen)" |
| 21 | 21 | password_placeholder_register: "Ihr Passwort (min. 8 Zeichen)" |
@@ -69,6 +69,49 @@ password_reset:
| 69 | 69 | error_link_invalid: "Das Passwort konnte nicht zurückgesetzt werden. Der Link ist möglicherweise abgelaufen oder ungültig." |
| 70 | 70 | error_generic: "Das Passwort kann derzeit nicht zurückgesetzt werden." |
| 71 | 71 | |
| 72 | +# 2FA-Anmeldeseite | |
| 73 | +login_2fa: | |
| 74 | + page_title: "2FA-Überprüfung - RustCloud" | |
| 75 | + title: "Zwei-Schritt-Überprüfung" | |
| 76 | + subtitle: "Geben Sie den Code aus Ihrer Authenticator-App ein" | |
| 77 | + code_label: "Authentifizierungscode" | |
| 78 | + backup_code_label: "Backup-Code" | |
| 79 | + verify_button: "Überprüfen" | |
| 80 | + use_backup_code: "Einen Backup-Code verwenden" | |
| 81 | + use_totp_code: "Authenticator-App-Code verwenden" | |
| 82 | + back_to_login: "Zurück zur Anmeldung" | |
| 83 | + error_invalid_code: "Ungültiger Code. Bitte versuchen Sie es erneut." | |
| 84 | + error_network: "Netzwerkfehler. Bitte versuchen Sie es erneut." | |
| 85 | + | |
| 86 | +# 2FA-Sicherheit | |
| 87 | +settings_security: | |
| 88 | + page_title: "Sicherheit - RustWarden" | |
| 89 | + title: "Zwei-Faktor-Authentifizierung" | |
| 90 | + subtitle: "Schützen Sie Ihr Konto mit einer zusätzlichen Sicherheitsebene" | |
| 91 | + totp_title: "TOTP-Authentifizierung" | |
| 92 | + status_enabled: "Aktiviert" | |
| 93 | + status_disabled: "Deaktiviert" | |
| 94 | + enabled_message: "Ihr Konto ist durch Zwei-Faktor-Authentifizierung geschützt." | |
| 95 | + disabled_message: "Fügen Sie Ihrem Konto eine zusätzliche Sicherheitsebene hinzu, indem Sie die Zwei-Faktor-Authentifizierung mit einer App wie Google Authenticator oder Authy aktivieren." | |
| 96 | + enable_button: "2FA aktivieren" | |
| 97 | + disable_button: "2FA deaktivieren" | |
| 98 | + scan_qr: "Scannen Sie diesen QR-Code mit Ihrer Authenticator-App" | |
| 99 | + manual_entry: "Oder geben Sie diesen Code manuell ein" | |
| 100 | + enter_code: "Geben Sie den 6-stelligen Code aus Ihrer App ein" | |
| 101 | + verify_button: "Überprüfen und aktivieren" | |
| 102 | + backup_codes_title: "Backup-Codes" | |
| 103 | + backup_codes_warning: "Bewahren Sie diese Codes an einem sicheren Ort auf. Jeder Code kann nur einmal verwendet werden." | |
| 104 | + backup_codes_remaining: "Verbleibende Backup-Codes" | |
| 105 | + regenerate_button: "Codes neu generieren" | |
| 106 | + regenerate_confirm: "Sind Sie sicher? Alte Codes werden ersetzt." | |
| 107 | + confirm_disable: "Geben Sie Ihr Passwort ein, um 2FA zu deaktivieren" | |
| 108 | + password_label: "Aktuelles Passwort" | |
| 109 | + copy_button: "Kopieren" | |
| 110 | + saved_codes_button: "Ich habe meine Codes gespeichert" | |
| 111 | + breadcrumb: "Sicherheit" | |
| 112 | + force_2fa_message: "Ihr Administrator verlangt die Zwei-Faktor-Authentifizierung. Sie müssen 2FA einrichten, bevor Sie auf den Rest der Anwendung zugreifen können." | |
| 113 | + force_2fa_logout: "Abmelden" | |
| 114 | + | |
| 72 | 115 | # Einstellungen |
| 73 | 116 | settings: |
| 74 | 117 | title: "Einstellungen" |
@@ -122,6 +165,10 @@ preferences:
| 122 | 165 | language_de_desc: "Deutsche Benutzeroberfläche" |
| 123 | 166 | language_es: "Español" |
| 124 | 167 | language_es_desc: "Spanische Benutzeroberfläche" |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Italienische Benutzeroberfläche" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Portugiesische Benutzeroberfläche" | |
| 125 | 172 | translation_warning: "Die vollständige Übersetzung der Benutzeroberfläche befindet sich in der Entwicklung. Einige Elemente können auf Französisch bleiben." |
| 126 | 173 | modified: "Einstellungen geändert" |
| 127 | 174 | modified_desc: "Speichern Sie Ihre Änderungen, um sie anzuwenden" |
M
crates/rustwarden-ui/locales/en.yml
+49
-2
@@ -7,7 +7,7 @@ login:
| 7 | 7 | page_title: "Login - RustCloud" |
| 8 | 8 | page_description: "Login to RustCloud" |
| 9 | 9 | subtitle: "Sign in to your cloud space" |
| 10 | - username: "Username" | |
| 10 | + username: "Username or email" | |
| 11 | 11 | password: "Password" |
| 12 | 12 | submit: "Sign in" |
| 13 | 13 | remember_me: "Remember me" |
@@ -15,7 +15,7 @@ login:
| 15 | 15 | tab_register: "Register" |
| 16 | 16 | register_title: "Create an account" |
| 17 | 17 | register_submit: "Create my account" |
| 18 | - username_placeholder: "Your username" | |
| 18 | + username_placeholder: "Username or email" | |
| 19 | 19 | password_placeholder: "Your password" |
| 20 | 20 | username_placeholder_register: "Your username (min. 3 characters)" |
| 21 | 21 | password_placeholder_register: "Your password (min. 8 characters)" |
@@ -69,6 +69,49 @@ password_reset:
| 69 | 69 | error_link_invalid: "Unable to reset password. The link may be expired or invalid." |
| 70 | 70 | error_generic: "Unable to reset password at this time." |
| 71 | 71 | |
| 72 | +# 2FA Login page | |
| 73 | +login_2fa: | |
| 74 | + page_title: "2FA Verification - RustCloud" | |
| 75 | + title: "Two-Step Verification" | |
| 76 | + subtitle: "Enter the code from your authenticator app" | |
| 77 | + code_label: "Authentication code" | |
| 78 | + backup_code_label: "Backup code" | |
| 79 | + verify_button: "Verify" | |
| 80 | + use_backup_code: "Use a backup code" | |
| 81 | + use_totp_code: "Use authenticator app code" | |
| 82 | + back_to_login: "Back to login" | |
| 83 | + error_invalid_code: "Invalid code. Please try again." | |
| 84 | + error_network: "Network error. Please try again." | |
| 85 | + | |
| 86 | +# 2FA Security | |
| 87 | +settings_security: | |
| 88 | + page_title: "Security - RustWarden" | |
| 89 | + title: "Two-Factor Authentication" | |
| 90 | + subtitle: "Protect your account with an extra layer of security" | |
| 91 | + totp_title: "TOTP Authentication" | |
| 92 | + status_enabled: "Enabled" | |
| 93 | + status_disabled: "Disabled" | |
| 94 | + enabled_message: "Your account is protected by two-factor authentication." | |
| 95 | + disabled_message: "Add an extra layer of security to your account by enabling two-factor authentication using an app like Google Authenticator or Authy." | |
| 96 | + enable_button: "Enable 2FA" | |
| 97 | + disable_button: "Disable 2FA" | |
| 98 | + scan_qr: "Scan this QR code with your authenticator app" | |
| 99 | + manual_entry: "Or enter this code manually" | |
| 100 | + enter_code: "Enter the 6-digit code from your app" | |
| 101 | + verify_button: "Verify and Enable" | |
| 102 | + backup_codes_title: "Backup Codes" | |
| 103 | + backup_codes_warning: "Save these codes in a safe place. Each code can only be used once." | |
| 104 | + backup_codes_remaining: "Backup codes remaining" | |
| 105 | + regenerate_button: "Regenerate codes" | |
| 106 | + regenerate_confirm: "Are you sure? Old codes will be replaced." | |
| 107 | + confirm_disable: "Enter your password to disable 2FA" | |
| 108 | + password_label: "Current password" | |
| 109 | + copy_button: "Copy" | |
| 110 | + saved_codes_button: "I've saved my codes" | |
| 111 | + breadcrumb: "Security" | |
| 112 | + force_2fa_message: "Your administrator requires two-factor authentication. You must set up 2FA before you can access the rest of the application." | |
| 113 | + force_2fa_logout: "Sign out" | |
| 114 | + | |
| 72 | 115 | # Settings |
| 73 | 116 | settings: |
| 74 | 117 | title: "Settings" |
@@ -122,6 +165,10 @@ preferences:
| 122 | 165 | language_de_desc: "German interface" |
| 123 | 166 | language_es: "Español" |
| 124 | 167 | language_es_desc: "Spanish interface" |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Italian interface" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Portuguese interface" | |
| 125 | 172 | translation_warning: "Complete interface translation is under development. Some elements may remain in French." |
| 126 | 173 | modified: "Preferences modified" |
| 127 | 174 | modified_desc: "Save your changes to apply them" |
M
crates/rustwarden-ui/locales/es.yml
+49
-2
@@ -7,7 +7,7 @@ login:
| 7 | 7 | page_title: "Iniciar sesión - RustCloud" |
| 8 | 8 | page_description: "Iniciar sesión en RustCloud" |
| 9 | 9 | subtitle: "Inicie sesión en su espacio en la nube" |
| 10 | - username: "Nombre de usuario" | |
| 10 | + username: "Nombre de usuario o email" | |
| 11 | 11 | password: "Contraseña" |
| 12 | 12 | submit: "Iniciar sesión" |
| 13 | 13 | remember_me: "Recordarme" |
@@ -15,7 +15,7 @@ login:
| 15 | 15 | tab_register: "Registrarse" |
| 16 | 16 | register_title: "Crear una cuenta" |
| 17 | 17 | register_submit: "Crear mi cuenta" |
| 18 | - username_placeholder: "Su nombre de usuario" | |
| 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 | 21 | password_placeholder_register: "Su contraseña (mín. 8 caracteres)" |
@@ -69,6 +69,49 @@ password_reset:
| 69 | 69 | error_link_invalid: "No se pudo restablecer la contraseña. El enlace puede haber expirado o ser inválido." |
| 70 | 70 | error_generic: "No se puede restablecer la contraseña en este momento." |
| 71 | 71 | |
| 72 | +# Página de inicio de sesión 2FA | |
| 73 | +login_2fa: | |
| 74 | + page_title: "Verificación 2FA - RustCloud" | |
| 75 | + title: "Verificación en dos pasos" | |
| 76 | + subtitle: "Ingrese el código de su aplicación de autenticación" | |
| 77 | + code_label: "Código de autenticación" | |
| 78 | + backup_code_label: "Código de respaldo" | |
| 79 | + verify_button: "Verificar" | |
| 80 | + use_backup_code: "Usar un código de respaldo" | |
| 81 | + use_totp_code: "Usar código de la aplicación" | |
| 82 | + back_to_login: "Volver al inicio de sesión" | |
| 83 | + error_invalid_code: "Código inválido. Inténtelo de nuevo." | |
| 84 | + error_network: "Error de red. Inténtelo de nuevo." | |
| 85 | + | |
| 86 | +# Seguridad 2FA | |
| 87 | +settings_security: | |
| 88 | + page_title: "Seguridad - RustWarden" | |
| 89 | + title: "Autenticación de dos factores" | |
| 90 | + subtitle: "Proteja su cuenta con una capa adicional de seguridad" | |
| 91 | + totp_title: "Autenticación TOTP" | |
| 92 | + status_enabled: "Activado" | |
| 93 | + status_disabled: "Desactivado" | |
| 94 | + enabled_message: "Su cuenta está protegida por autenticación de dos factores." | |
| 95 | + disabled_message: "Agregue una capa adicional de seguridad a su cuenta habilitando la autenticación de dos factores con una aplicación como Google Authenticator o Authy." | |
| 96 | + enable_button: "Activar 2FA" | |
| 97 | + disable_button: "Desactivar 2FA" | |
| 98 | + scan_qr: "Escanee este código QR con su aplicación de autenticación" | |
| 99 | + manual_entry: "O ingrese este código manualmente" | |
| 100 | + enter_code: "Ingrese el código de 6 dígitos de su aplicación" | |
| 101 | + verify_button: "Verificar y activar" | |
| 102 | + backup_codes_title: "Códigos de respaldo" | |
| 103 | + backup_codes_warning: "Guarde estos códigos en un lugar seguro. Cada código solo se puede usar una vez." | |
| 104 | + backup_codes_remaining: "Códigos de respaldo restantes" | |
| 105 | + regenerate_button: "Regenerar códigos" | |
| 106 | + regenerate_confirm: "¿Está seguro? Los códigos antiguos serán reemplazados." | |
| 107 | + confirm_disable: "Ingrese su contraseña para desactivar 2FA" | |
| 108 | + password_label: "Contraseña actual" | |
| 109 | + copy_button: "Copiar" | |
| 110 | + saved_codes_button: "He guardado mis códigos" | |
| 111 | + breadcrumb: "Seguridad" | |
| 112 | + force_2fa_message: "Su administrador requiere la autenticación de dos factores. Debe configurar 2FA antes de poder acceder al resto de la aplicación." | |
| 113 | + force_2fa_logout: "Cerrar sesión" | |
| 114 | + | |
| 72 | 115 | # Configuración |
| 73 | 116 | settings: |
| 74 | 117 | title: "Configuración" |
@@ -122,6 +165,10 @@ preferences:
| 122 | 165 | language_de_desc: "Interfaz en alemán" |
| 123 | 166 | language_es: "Español" |
| 124 | 167 | language_es_desc: "Interfaz en español" |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Interfaz en italiano" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Interfaz en portugués" | |
| 125 | 172 | translation_warning: "La traducción completa de la interfaz está en desarrollo. Algunos elementos pueden permanecer en francés." |
| 126 | 173 | modified: "Preferencias modificadas" |
| 127 | 174 | modified_desc: "Guarde sus cambios para aplicarlos" |
M
crates/rustwarden-ui/locales/fr.yml
+49
-2
@@ -7,7 +7,7 @@ login:
| 7 | 7 | page_title: "Connexion - RustCloud" |
| 8 | 8 | page_description: "Connexion à RustCloud" |
| 9 | 9 | subtitle: "Connectez-vous à votre espace cloud" |
| 10 | - username: "Nom d'utilisateur" | |
| 10 | + username: "Nom d'utilisateur ou email" | |
| 11 | 11 | password: "Mot de passe" |
| 12 | 12 | submit: "Se connecter" |
| 13 | 13 | remember_me: "Se souvenir de moi" |
@@ -15,7 +15,7 @@ login:
| 15 | 15 | tab_register: "Inscription" |
| 16 | 16 | register_title: "Créer un compte" |
| 17 | 17 | register_submit: "Créer mon compte" |
| 18 | - username_placeholder: "Votre nom d'utilisateur" | |
| 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 | 21 | password_placeholder_register: "Votre mot de passe (min. 8 caractères)" |
@@ -69,6 +69,49 @@ password_reset:
| 69 | 69 | error_link_invalid: "Impossible de réinitialiser le mot de passe. Le lien peut être expiré ou invalide." |
| 70 | 70 | error_generic: "Impossible de réinitialiser le mot de passe pour l'instant." |
| 71 | 71 | |
| 72 | +# Page 2FA login | |
| 73 | +login_2fa: | |
| 74 | + page_title: "Vérification 2FA - RustCloud" | |
| 75 | + title: "Vérification en deux étapes" | |
| 76 | + subtitle: "Entrez le code de votre application d'authentification" | |
| 77 | + code_label: "Code d'authentification" | |
| 78 | + backup_code_label: "Code de secours" | |
| 79 | + verify_button: "Vérifier" | |
| 80 | + use_backup_code: "Utiliser un code de secours" | |
| 81 | + use_totp_code: "Utiliser le code de l'application" | |
| 82 | + back_to_login: "Retour à la connexion" | |
| 83 | + error_invalid_code: "Code invalide. Veuillez réessayer." | |
| 84 | + error_network: "Erreur réseau. Veuillez réessayer." | |
| 85 | + | |
| 86 | +# Sécurité 2FA | |
| 87 | +settings_security: | |
| 88 | + page_title: "Sécurité - RustWarden" | |
| 89 | + title: "Authentification à deux facteurs" | |
| 90 | + subtitle: "Protégez votre compte avec une couche de sécurité supplémentaire" | |
| 91 | + totp_title: "Authentification TOTP" | |
| 92 | + status_enabled: "Activé" | |
| 93 | + status_disabled: "Désactivé" | |
| 94 | + enabled_message: "Votre compte est protégé par l'authentification à deux facteurs." | |
| 95 | + disabled_message: "Ajoutez une couche de sécurité supplémentaire à votre compte en activant l'authentification à deux facteurs via une application comme Google Authenticator ou Authy." | |
| 96 | + enable_button: "Activer le 2FA" | |
| 97 | + disable_button: "Désactiver le 2FA" | |
| 98 | + scan_qr: "Scannez ce QR code avec votre application d'authentification" | |
| 99 | + manual_entry: "Ou entrez ce code manuellement" | |
| 100 | + enter_code: "Entrez le code à 6 chiffres de votre application" | |
| 101 | + verify_button: "Vérifier et activer" | |
| 102 | + backup_codes_title: "Codes de secours" | |
| 103 | + backup_codes_warning: "Conservez ces codes dans un endroit sûr. Chaque code ne peut être utilisé qu'une seule fois." | |
| 104 | + backup_codes_remaining: "Codes de secours restants" | |
| 105 | + regenerate_button: "Régénérer les codes" | |
| 106 | + regenerate_confirm: "Êtes-vous sûr ? Les anciens codes seront remplacés." | |
| 107 | + confirm_disable: "Entrez votre mot de passe pour désactiver le 2FA" | |
| 108 | + password_label: "Mot de passe actuel" | |
| 109 | + copy_button: "Copier" | |
| 110 | + saved_codes_button: "J'ai sauvegardé mes codes" | |
| 111 | + breadcrumb: "Sécurité" | |
| 112 | + force_2fa_message: "L'administrateur exige l'activation de l'authentification à deux facteurs. Vous devez configurer le 2FA avant de pouvoir accéder au reste de l'application." | |
| 113 | + force_2fa_logout: "Se déconnecter" | |
| 114 | + | |
| 72 | 115 | # Paramètres |
| 73 | 116 | settings: |
| 74 | 117 | title: "Paramètres" |
@@ -122,6 +165,10 @@ preferences:
| 122 | 165 | language_de_desc: "Deutsche Benutzeroberfläche" |
| 123 | 166 | language_es: "Español" |
| 124 | 167 | language_es_desc: "Interfaz en español" |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Interface en italien" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Interface en portugais" | |
| 125 | 172 | translation_warning: "La traduction complète de l'interface est en cours de développement. Certains éléments peuvent rester en français." |
| 126 | 173 | modified: "Préférences modifiées" |
| 127 | 174 | modified_desc: "Enregistrez vos changements pour les appliquer" |
A
crates/rustwarden-ui/locales/it.yml
+702
-0
@@ -0,0 +1,702 @@
| 1 | +# Traduzioni italiane — rustwarden-ui | |
| 2 | +# Sezioni: login, pagine utente, admin, navigazione | |
| 3 | + | |
| 4 | +# Accesso | |
| 5 | +login: | |
| 6 | + title: "Accesso" | |
| 7 | + page_title: "Accesso - RustCloud" | |
| 8 | + page_description: "Accesso a RustCloud" | |
| 9 | + subtitle: "Accedi al tuo spazio cloud" | |
| 10 | + username: "Nome utente o email" | |
| 11 | + password: "Password" | |
| 12 | + submit: "Accedi" | |
| 13 | + remember_me: "Ricordami" | |
| 14 | + tab_login: "Accesso" | |
| 15 | + tab_register: "Registrazione" | |
| 16 | + register_title: "Crea un account" | |
| 17 | + register_submit: "Crea il mio account" | |
| 18 | + username_placeholder: "Nome utente o email" | |
| 19 | + password_placeholder: "La tua password" | |
| 20 | + username_placeholder_register: "Il tuo nome utente (min. 3 caratteri)" | |
| 21 | + password_placeholder_register: "La tua password (min. 8 caratteri)" | |
| 22 | + email_placeholder: "tua@email.com" | |
| 23 | + forgot_password: "Password dimenticata?" | |
| 24 | + email_not_verified: "Email non verificata?" | |
| 25 | + resend_verification: "Reinvia l'email di verifica" | |
| 26 | + already_have_account: "Hai già un account?" | |
| 27 | + sign_in_link: "Accedi" | |
| 28 | + success_message: "Accesso riuscito! Reindirizzamento..." | |
| 29 | + error_message: "Errore di accesso. Verifica le tue credenziali." | |
| 30 | + email_unverified_message: "La tua email non è verificata. Clicca qui sotto per reinviare il link." | |
| 31 | + resend_button: "Reinvia l'email di verifica" | |
| 32 | + resend_success: "Email di verifica reinviata se l'account esiste." | |
| 33 | + resend_error: "Impossibile inviare la richiesta al momento." | |
| 34 | + resend_prompt: "Inserisci la tua email prima di reinviare." | |
| 35 | + register_success: "Account creato con successo! Controlla la tua email." | |
| 36 | + register_error: "Errore durante la registrazione" | |
| 37 | + email_label: "Email" | |
| 38 | + footer_text: "Fork di Nextcloud in Rust" | |
| 39 | + | |
| 40 | +# Reinvio email di verifica | |
| 41 | +resend_verification: | |
| 42 | + page_title: "Reinvia l'email di verifica - RustWarden" | |
| 43 | + title: "Reinvia l'email di verifica" | |
| 44 | + subtitle: "Inserisci il tuo indirizzo email per ricevere un nuovo link di verifica." | |
| 45 | + email_label: "Indirizzo email" | |
| 46 | + email_placeholder: "tu@example.com" | |
| 47 | + submit: "Invia il link di verifica" | |
| 48 | + back_to_login: "Torna all'accesso" | |
| 49 | + error_default: "Impossibile inviare la richiesta." | |
| 50 | + success_default: "Se l'account esiste e non è verificato, un'email è stata inviata." | |
| 51 | + error_missing_email: "Inserisci la tua email." | |
| 52 | + error_cannot_send: "Impossibile inviare la richiesta al momento." | |
| 53 | + | |
| 54 | +# Reimpostazione password (tramite link email) | |
| 55 | +password_reset: | |
| 56 | + page_title: "Reimposta la password" | |
| 57 | + title: "Reimposta la password" | |
| 58 | + subtitle: "Inserisci la tua nuova password qui sotto." | |
| 59 | + new_password_label: "Nuova password" | |
| 60 | + new_password_placeholder: "Nuova password sicura" | |
| 61 | + requirements: "⚠️ Deve contenere: 8+ caratteri, maiuscola, minuscola, cifra, carattere speciale" | |
| 62 | + confirm_password_label: "Conferma password" | |
| 63 | + confirm_password_placeholder: "Conferma la tua nuova password" | |
| 64 | + missing_token_url: "Token di reimpostazione mancante nell'URL." | |
| 65 | + missing_token: "Token di reimpostazione mancante." | |
| 66 | + missing_new_password: "Inserisci una nuova password." | |
| 67 | + passwords_not_match: "Le password non corrispondono." | |
| 68 | + success_message: "La tua password è stata reimpostata con successo. Ora puoi accedere." | |
| 69 | + error_link_invalid: "Impossibile reimpostare la password. Il link potrebbe essere scaduto o non valido." | |
| 70 | + error_generic: "Impossibile reimpostare la password al momento." | |
| 71 | + | |
| 72 | +# Pagina di accesso 2FA | |
| 73 | +login_2fa: | |
| 74 | + page_title: "Verifica 2FA - RustCloud" | |
| 75 | + title: "Verifica in due passaggi" | |
| 76 | + subtitle: "Inserisci il codice dalla tua app di autenticazione" | |
| 77 | + code_label: "Codice di autenticazione" | |
| 78 | + backup_code_label: "Codice di backup" | |
| 79 | + verify_button: "Verifica" | |
| 80 | + use_backup_code: "Usa un codice di backup" | |
| 81 | + use_totp_code: "Usa il codice dell'app" | |
| 82 | + back_to_login: "Torna all'accesso" | |
| 83 | + error_invalid_code: "Codice non valido. Riprova." | |
| 84 | + error_network: "Errore di rete. Riprova." | |
| 85 | + | |
| 86 | +# Sicurezza 2FA | |
| 87 | +settings_security: | |
| 88 | + page_title: "Sicurezza - RustWarden" | |
| 89 | + title: "Autenticazione a due fattori" | |
| 90 | + subtitle: "Proteggi il tuo account con un livello di sicurezza aggiuntivo" | |
| 91 | + totp_title: "Autenticazione TOTP" | |
| 92 | + status_enabled: "Attivata" | |
| 93 | + status_disabled: "Disattivata" | |
| 94 | + enabled_message: "Il tuo account è protetto dall'autenticazione a due fattori." | |
| 95 | + disabled_message: "Aggiungi un livello di sicurezza aggiuntivo al tuo account attivando l'autenticazione a due fattori tramite un'app come Google Authenticator o Authy." | |
| 96 | + enable_button: "Attiva 2FA" | |
| 97 | + disable_button: "Disattiva 2FA" | |
| 98 | + scan_qr: "Scansiona questo codice QR con la tua app di autenticazione" | |
| 99 | + manual_entry: "Oppure inserisci questo codice manualmente" | |
| 100 | + enter_code: "Inserisci il codice a 6 cifre dalla tua app" | |
| 101 | + verify_button: "Verifica e attiva" | |
| 102 | + backup_codes_title: "Codici di backup" | |
| 103 | + backup_codes_warning: "Conserva questi codici in un luogo sicuro. Ogni codice può essere utilizzato una sola volta." | |
| 104 | + backup_codes_remaining: "Codici di backup rimanenti" | |
| 105 | + regenerate_button: "Rigenera codici" | |
| 106 | + regenerate_confirm: "Sei sicuro? I vecchi codici verranno sostituiti." | |
| 107 | + confirm_disable: "Inserisci la tua password per disattivare il 2FA" | |
| 108 | + password_label: "Password attuale" | |
| 109 | + copy_button: "Copia" | |
| 110 | + saved_codes_button: "Ho salvato i miei codici" | |
| 111 | + breadcrumb: "Sicurezza" | |
| 112 | + force_2fa_message: "L'amministratore richiede l'autenticazione a due fattori. Devi configurare il 2FA prima di poter accedere al resto dell'applicazione." | |
| 113 | + force_2fa_logout: "Disconnettersi" | |
| 114 | + | |
| 115 | +# Impostazioni | |
| 116 | +settings: | |
| 117 | + title: "Impostazioni" | |
| 118 | + page_title: "Impostazioni - RustWarden" | |
| 119 | + subtitle: "Gestisci le tue preferenze e impostazioni dell'account" | |
| 120 | + profile_title: "Il mio Profilo" | |
| 121 | + profile_desc: "Modifica le tue informazioni personali (nome utente, email)" | |
| 122 | + password_title: "Password" | |
| 123 | + password_desc: "Cambia la tua password per proteggere il tuo account" | |
| 124 | + sessions_title: "Sessioni attive" | |
| 125 | + sessions_desc: "Gestisci le tue sessioni attive e revoca l'accesso da altri dispositivi" | |
| 126 | + preferences_title: "Preferenze" | |
| 127 | + preferences_desc: "Personalizza la tua esperienza (tema, lingua)" | |
| 128 | + access: "Accedi" | |
| 129 | + info_title: "Informazioni" | |
| 130 | + info_desc: "Utilizza le sezioni sopra per gestire i diversi aspetti del tuo account. Tutte le modifiche sono sicure e richiedono la tua autenticazione." | |
| 131 | + language: "Lingua" | |
| 132 | + language_description: "Scegli la tua lingua preferita" | |
| 133 | + save: "Salva" | |
| 134 | + saved: "Impostazioni salvate" | |
| 135 | + | |
| 136 | +# Pagina principale | |
| 137 | +home: | |
| 138 | + welcome: "Benvenuto, {username}!" | |
| 139 | + subtitle: "Benvenuto nel tuo spazio personale" | |
| 140 | + my_account: "Il mio Account" | |
| 141 | + stat_username: "Nome utente" | |
| 142 | + stat_email: "Email" | |
| 143 | + stat_member_since: "Membro dal" | |
| 144 | + stat_roles: "Ruoli" | |
| 145 | + | |
| 146 | +# Preferenze | |
| 147 | +preferences: | |
| 148 | + title: "Preferenze di visualizzazione" | |
| 149 | + page_title: "Preferenze di visualizzazione - RustCloud" | |
| 150 | + subtitle: "Personalizza l'aspetto dell'interfaccia" | |
| 151 | + theme: "Tema" | |
| 152 | + theme_light: "Chiaro" | |
| 153 | + theme_light_desc: "Interfaccia luminosa e pulita" | |
| 154 | + theme_dark: "Scuro" | |
| 155 | + theme_dark_desc: "Riduce l'affaticamento degli occhi" | |
| 156 | + theme_auto: "Automatico" | |
| 157 | + theme_auto_desc: "Si adatta all'ora del giorno" | |
| 158 | + theme_preview: "Il tema verrà applicato immediatamente dopo il salvataggio" | |
| 159 | + language: "Lingua" | |
| 160 | + language_fr: "Français" | |
| 161 | + language_fr_desc: "Interfaccia in francese" | |
| 162 | + language_en: "English" | |
| 163 | + language_en_desc: "Interfaccia in inglese" | |
| 164 | + language_de: "Deutsch" | |
| 165 | + language_de_desc: "Interfaccia in tedesco" | |
| 166 | + language_es: "Español" | |
| 167 | + language_es_desc: "Interfaccia in spagnolo" | |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Interfaccia in italiano" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Interfaccia in portoghese" | |
| 172 | + translation_warning: "La traduzione completa dell'interfaccia è in fase di sviluppo. Alcuni elementi potrebbero rimanere in francese." | |
| 173 | + modified: "Preferenze modificate" | |
| 174 | + modified_desc: "Salva le modifiche per applicarle" | |
| 175 | + save: "Salva le preferenze" | |
| 176 | + saving: "Salvataggio..." | |
| 177 | + cancel: "Annulla" | |
| 178 | + saved_success: "Preferenze salvate con successo!" | |
| 179 | + saved_reload: "Preferenze salvate con successo!\n\nLa lingua è stata modificata. Vuoi ricaricare la pagina ora per applicare le modifiche?" | |
| 180 | + error: "Errore: {message}" | |
| 181 | + network_error: "Errore di rete. Riprova." | |
| 182 | + sync_title: "Le tue preferenze sono sincronizzate" | |
| 183 | + sync_desc: "Le tue preferenze di visualizzazione sono salvate e si applicano automaticamente su tutti i dispositivi connessi al tuo account RustCloud." | |
| 184 | + preview_dark: "Anteprima: Tema scuro verrà applicato" | |
| 185 | + preview_light: "Anteprima: Tema chiaro verrà applicato" | |
| 186 | + preview_auto: "Anteprima: Tema automatico verrà applicato" | |
| 187 | + | |
| 188 | +# Profilo | |
| 189 | +profile: | |
| 190 | + title: "Il mio Profilo" | |
| 191 | + page_title: "Il mio Profilo - RustCloud" | |
| 192 | + subtitle: "Modifica le tue informazioni personali" | |
| 193 | + account_info: "Informazioni dell'account" | |
| 194 | + username: "Nome utente" | |
| 195 | + username_public: "Visibile pubblicamente" | |
| 196 | + username_placeholder: "Il tuo nome utente" | |
| 197 | + username_pattern: "Solo lettere, numeri, trattini e underscore" | |
| 198 | + email: "Email" | |
| 199 | + email_placeholder: "tua@email.com" | |
| 200 | + save: "Salva le modifiche" | |
| 201 | + saving: "Salvataggio..." | |
| 202 | + saved: "Profilo aggiornato con successo" | |
| 203 | + error: "Errore durante l'aggiornamento del profilo" | |
| 204 | + email_label: "Indirizzo email" | |
| 205 | + email_used_for: "Utilizzato per l'accesso" | |
| 206 | + email_warning: "⚠️ Verrai disconnesso dopo la modifica" | |
| 207 | + email_change_info: "Le modifiche all'email richiedono una nuova connessione per motivi di sicurezza." | |
| 208 | + username_help: "3-50 caratteri (lettere, numeri, -, _)" | |
| 209 | + additional_info: "Informazioni aggiuntive" | |
| 210 | + account_created: "Account creato il" | |
| 211 | + roles: "Ruoli" | |
| 212 | + linked_accounts_title: "Account collegati" | |
| 213 | + no_linked_accounts: "Nessun account esterno collegato." | |
| 214 | + linked_since: "Collegato dal" | |
| 215 | + unlink_button: "Scollega" | |
| 216 | + unlink_confirm: "Sei sicuro di voler scollegare questo account?" | |
| 217 | + unlink_error: "Impossibile scollegare questo account." | |
| 218 | + cannot_unlink_last: "Impossibile rimuovere l'ultimo metodo di autenticazione." | |
| 219 | + | |
| 220 | +# Breadcrumb | |
| 221 | +breadcrumb: | |
| 222 | + home: "Home" | |
| 223 | + settings: "Impostazioni" | |
| 224 | + admin: "Amministrazione" | |
| 225 | + preferences: "Preferenze" | |
| 226 | + profile: "Profilo" | |
| 227 | + password: "Password" | |
| 228 | + users: "Utenti" | |
| 229 | + sessions: "Sessioni" | |
| 230 | + | |
| 231 | +# Admin | |
| 232 | +admin: | |
| 233 | + dashboard: | |
| 234 | + title: "Amministrazione" | |
| 235 | + heading: "Pannello di controllo Amministratore" | |
| 236 | + users_card_title: "Utenti" | |
| 237 | + users_card_subtitle: "Totale utenti" | |
| 238 | + access_button: "Accedi" | |
| 239 | + sessions_card_title: "Sessioni attive" | |
| 240 | + sessions_card_subtitle: "Sessioni in corso" | |
| 241 | + admins_card_title: "Amministratori" | |
| 242 | + admins_card_subtitle: "Account admin" | |
| 243 | + last_activity_card_title: "Ultima attività" | |
| 244 | + last_activity_card_subtitle: "Ultimo accesso" | |
| 245 | + audit_section_title: "Statistiche log di audit" | |
| 246 | + audit_total_logs: "Totale log" | |
| 247 | + audit_last_24h: "Ultime 24h" | |
| 248 | + audit_by_level: "Per livello" | |
| 249 | + chart_label: "Log per livello" | |
| 250 | + roles_card_title: "Gestione Ruoli" | |
| 251 | + roles_card_desc: "Gestisci i ruoli e i permessi granulari" | |
| 252 | + roles_stat_title: "Ruoli" | |
| 253 | + settings_card_title: "Impostazioni Applicazione" | |
| 254 | + settings_card_desc: "Configura le impostazioni globali dell'applicazione" | |
| 255 | + apps_card_title: "Gestione Applicazioni" | |
| 256 | + apps_card_desc: "Attiva o disattiva le applicazioni modulari" | |
| 257 | + orgs_card_title: "Gestione Organizzazioni" | |
| 258 | + orgs_card_desc: "Crea e gestisci le organizzazioni e i loro membri" | |
| 259 | + orgs_stat_title: "Organizzazioni" | |
| 260 | + orgs_members_stat_title: "Membri totali" | |
| 261 | + roles_list: | |
| 262 | + page_title: "Gestione dei ruoli" | |
| 263 | + breadcrumb_roles: "Ruoli" | |
| 264 | + title: "Ruoli" | |
| 265 | + create_role: "Crea un ruolo" | |
| 266 | + table_name: "Nome" | |
| 267 | + table_description: "Descrizione" | |
| 268 | + table_users: "Utenti" | |
| 269 | + table_permissions: "Permessi" | |
| 270 | + table_actions: "Azioni" | |
| 271 | + edit: "Modifica" | |
| 272 | + delete: "Elimina" | |
| 273 | + no_roles: "Nessun ruolo trovato." | |
| 274 | + protected_hint: "Protetto" | |
| 275 | + delete_confirm_title: "Conferma eliminazione" | |
| 276 | + delete_confirm_message: "Sei sicuro di voler eliminare il ruolo" | |
| 277 | + delete_button: "Elimina" | |
| 278 | + role_form: | |
| 279 | + title_create: "Crea un ruolo" | |
| 280 | + title_edit: "Modifica ruolo" | |
| 281 | + breadcrumb_create: "Crea" | |
| 282 | + breadcrumb_edit: "Modifica" | |
| 283 | + back_to_list: "Torna alla lista" | |
| 284 | + name_label: "Nome del ruolo" | |
| 285 | + name_placeholder: "es: moderator" | |
| 286 | + description_label: "Descrizione" | |
| 287 | + description_placeholder: "Descrizione del ruolo..." | |
| 288 | + permissions_label: "Permessi" | |
| 289 | + protected_name_hint: "Il nome di questo ruolo non può essere modificato." | |
| 290 | + cancel: "Annulla" | |
| 291 | + submit_create: "Crea ruolo" | |
| 292 | + submit_edit: "Salva modifiche" | |
| 293 | + errors: | |
| 294 | + title: "Codici di Errore" | |
| 295 | + description: "Documentazione dei codici di errore dell'applicazione. Ogni codice di errore è associato a un middleware e fornisce informazioni di contesto per il debug." | |
| 296 | + breadcrumb_errors: "Codici di Errore" | |
| 297 | + table_code: "Codice" | |
| 298 | + table_description: "Descrizione" | |
| 299 | + table_category: "Categoria" | |
| 300 | + table_middleware: "Middleware" | |
| 301 | + table_context: "Contesto" | |
| 302 | + table_example: "Esempio" | |
| 303 | + legend_title: "Legenda" | |
| 304 | + legend_code: "Identificatore unico dell'errore (formato: CATEGORY_NUMBER)" | |
| 305 | + legend_description: "Descrizione dell'errore" | |
| 306 | + legend_category: "Tipo di errore (Autenticazione, CSRF, Rate Limiting, ecc.)" | |
| 307 | + legend_middleware: "Middleware responsabile della generazione di questo errore" | |
| 308 | + legend_context: "Spiegazione dettagliata di quando si verifica questo errore" | |
| 309 | + legend_example: "Esempio concreto di situazione che genera questo errore" | |
| 310 | + error_auth001_desc: "Credenziali non valide" | |
| 311 | + error_auth001_category: "Autenticazione" | |
| 312 | + error_auth001_context: "L'utente ha fornito credenziali errate durante l'accesso" | |
| 313 | + error_auth001_example: "Tentativo di accesso con password errata" | |
| 314 | + error_auth002_desc: "Token non valido" | |
| 315 | + error_auth002_category: "Autenticazione" | |
| 316 | + error_auth002_context: "Il token JWT fornito non è valido o è malformato" | |
| 317 | + error_auth002_example: "Token JWT corrotto o firmato con una chiave diversa" | |
| 318 | + error_auth003_desc: "Token scaduto" | |
| 319 | + error_auth003_category: "Autenticazione" | |
| 320 | + error_auth003_context: "Il token JWT è scaduto e deve essere rinnovato" | |
| 321 | + error_auth003_example: "Tentativo di accesso con un token scaduto dopo il periodo di validità" | |
| 322 | + error_csrf001_desc: "Token CSRF mancante" | |
| 323 | + error_csrf001_category: "Protezione CSRF" | |
| 324 | + error_csrf001_context: "La richiesta POST/PUT/DELETE non contiene un token CSRF" | |
| 325 | + error_csrf001_example: "Modulo inviato senza token CSRF nell'header o nel body" | |
| 326 | + error_csrf002_desc: "Token CSRF non valido" | |
| 327 | + error_csrf002_category: "Protezione CSRF" | |
| 328 | + error_csrf002_context: "Il token CSRF fornito non corrisponde al token della sessione" | |
| 329 | + error_csrf002_example: "Token CSRF modificato o rubato" | |
| 330 | + error_rate001_desc: "Limite di frequenza superato" | |
| 331 | + error_rate001_category: "Rate Limiting" | |
| 332 | + error_rate001_context: "Il numero di richieste al minuto è stato superato" | |
| 333 | + error_rate001_example: "Più di 100 richieste/min sugli endpoint admin" | |
| 334 | + error_valid001_desc: "Dati di input non validi" | |
| 335 | + error_valid001_category: "Validazione" | |
| 336 | + error_valid001_context: "I dati forniti non rispettano le regole di validazione" | |
| 337 | + error_valid001_example: "Email in formato non valido o campo obbligatorio mancante" | |
| 338 | + error_valid002_desc: "Complessità della password non rispettata" | |
| 339 | + error_valid002_category: "Validazione" | |
| 340 | + error_valid002_context: "La password non rispetta le regole di complessità configurate" | |
| 341 | + error_valid002_example: "Password troppo corta o mancante dei caratteri richiesti" | |
| 342 | + error_rbac001_desc: "Permessi insufficienti" | |
| 343 | + error_rbac001_category: "Autorizzazione" | |
| 344 | + error_rbac001_context: "L'utente non ha i permessi necessari per questa azione" | |
| 345 | + error_rbac001_example: "Utente non admin che tenta di accedere a /admin/users" | |
| 346 | + error_rbac002_desc: "Ruolo amministratore richiesto" | |
| 347 | + error_rbac002_category: "Autorizzazione" | |
| 348 | + error_rbac002_context: "L'azione richiede il ruolo di amministratore" | |
| 349 | + error_rbac002_example: "Tentativo di eliminazione utente senza ruolo admin" | |
| 350 | + error_db001_desc: "Errore operazione database" | |
| 351 | + error_db001_category: "Database" | |
| 352 | + error_db001_context: "Un'operazione del database è fallita" | |
| 353 | + error_db001_example: "Vincolo di chiave esterna violato o connessione DB persa" | |
| 354 | + error_db002_desc: "Risorsa non trovata" | |
| 355 | + error_db002_category: "Database" | |
| 356 | + error_db002_context: "La risorsa richiesta non esiste nel database" | |
| 357 | + error_db002_example: "Utente con ID inesistente" | |
| 358 | + user_form: | |
| 359 | + breadcrumb_create: "Crea" | |
| 360 | + breadcrumb_edit: "Modifica" | |
| 361 | + title_create: "Crea un utente" | |
| 362 | + title_edit: "Modifica un utente" | |
| 363 | + back_to_list: "Torna alla lista" | |
| 364 | + username_label: "Nome utente" | |
| 365 | + username_placeholder: "nome_utente" | |
| 366 | + username_readonly_hint: "Il nome utente non può essere modificato dopo la creazione" | |
| 367 | + email_label: "Email" | |
| 368 | + email_placeholder: "utente@example.com" | |
| 369 | + password_label: "Password" | |
| 370 | + password_optional: "Opzionale" | |
| 371 | + password_placeholder: "Lascia vuoto per generare una password casuale" | |
| 372 | + password_hint: "Se lasciato vuoto, verrà generata automaticamente una password sicura" | |
| 373 | + roles_label: "Ruoli" | |
| 374 | + roles_hint: "Seleziona almeno un ruolo" | |
| 375 | + must_change_password_label: "Forza cambio password" | |
| 376 | + must_change_password_desc: "L'utente dovrà cambiare la password al prossimo accesso" | |
| 377 | + cancel: "Annulla" | |
| 378 | + submit_create: "Crea utente" | |
| 379 | + submit_edit: "Salva modifiche" | |
| 380 | + settings: | |
| 381 | + title: "Impostazioni dell'Applicazione" | |
| 382 | + page_title: "Impostazioni dell'Applicazione - Admin - RustCloud" | |
| 383 | + subtitle: "Configurazione globale di RustCloud" | |
| 384 | + general: "Generale" | |
| 385 | + app_name: "Nome dell'applicazione" | |
| 386 | + app_domain: "Dominio" | |
| 387 | + app_domain_placeholder: "example.com" | |
| 388 | + app_description: "Descrizione" | |
| 389 | + allow_registration: "Consenti la registrazione" | |
| 390 | + allow_registration_desc: "Gli utenti possono creare account dalla pagina principale" | |
| 391 | + email_validation_required: "Validazione email richiesta" | |
| 392 | + email_validation_required_desc: "Gli utenti devono validare la loro email prima di poter accedere" | |
| 393 | + save: "Salva le impostazioni" | |
| 394 | + cancel: "Annulla" | |
| 395 | + saved: "Impostazioni salvate con successo" | |
| 396 | + error: "Errore durante il salvataggio" | |
| 397 | + network_error: "Errore di rete. Riprova." | |
| 398 | + password_security: "Sicurezza delle Password" | |
| 399 | + password_min_length: "Lunghezza minima" | |
| 400 | + password_min_length_hint: "Tra 6 e 128 caratteri" | |
| 401 | + password_complexity: "Requisiti di complessità" | |
| 402 | + require_uppercase: "Maiuscola richiesta" | |
| 403 | + require_lowercase: "Minuscola richiesta" | |
| 404 | + require_digits: "Cifra richiesta" | |
| 405 | + require_special: "Carattere speciale richiesto" | |
| 406 | + password_change_require_email: "Conferma via email del cambio password" | |
| 407 | + password_change_require_email_desc: "Quando attivato, viene inviata un'email di conferma per validare qualsiasi cambio di password. Altrimenti, il cambio viene applicato immediatamente." | |
| 408 | + password_expiration: "Scadenza delle password" | |
| 409 | + password_expiration_desc: "Forza il cambio periodico" | |
| 410 | + expiration_days: "Scadenza dopo (giorni)" | |
| 411 | + expiration_alert: "Avviso email prima della scadenza" | |
| 412 | + expiration_alert_days: "Avviso (giorni prima)" | |
| 413 | + audit_logging: "Registrazione Audit" | |
| 414 | + log_level: "Livello di log" | |
| 415 | + log_level_debug: "DEBUG - Tutti i dettagli" | |
| 416 | + log_level_info: "INFO - Informazioni normali" | |
| 417 | + log_level_warn: "WARN - Solo avvertimenti" | |
| 418 | + log_level_error: "ERROR - Solo errori" | |
| 419 | + log_level_hint: "Controlla la verbosità dei log di audit" | |
| 420 | + log_retention: "Conservazione dei log (giorni)" | |
| 421 | + log_retention_hint: "Periodo di conservazione dei log (1-365 giorni)" | |
| 422 | + audit_info_message: "I log di audit registrano tutte le azioni amministrative (creazione, modifica, eliminazione di utenti)." | |
| 423 | + save_changes: "Salva le modifiche" | |
| 424 | + save_changes_desc: "Le impostazioni verranno applicate immediatamente" | |
| 425 | + saving: "Salvataggio..." | |
| 426 | + save_success: "Impostazioni salvate con successo!" | |
| 427 | + save_error_prefix: "Errore" | |
| 428 | + oauth_sso_title: "OAuth / SSO" | |
| 429 | + oauth_enabled_label: "Attiva OAuth" | |
| 430 | + oauth_enabled_desc: "Consenti l'accesso tramite provider esterni (Google, GitHub, ecc.)" | |
| 431 | + oauth_redirect_url_label: "URL di reindirizzamento base" | |
| 432 | + oauth_redirect_url_desc: "URL base per i callback OAuth (es: https://miaapp.example.com)" | |
| 433 | + oauth_auto_register_label: "Auto-registrazione" | |
| 434 | + oauth_auto_register_desc: "Crea automaticamente un account se l'email non esiste" | |
| 435 | + oauth_link_existing_label: "Collega account esistenti" | |
| 436 | + oauth_link_existing_desc: "Collega automaticamente un account OAuth se l'email verificata corrisponde" | |
| 437 | + oauth_client_id: "Client ID" | |
| 438 | + oauth_client_secret: "Client Secret" | |
| 439 | + oauth_tenant: "Tenant ID" | |
| 440 | + oauth_redirect_uri: "URI di reindirizzamento" | |
| 441 | + oauth_copy_uri: "Copia" | |
| 442 | + oauth_secret_configured: "Configurato" | |
| 443 | + oauth_secret_not_configured: "Non configurato" | |
| 444 | + oauth_secret_change: "Modifica" | |
| 445 | + oauth_restart_warning: "Le modifiche alle credenziali richiedono un riavvio dell'applicazione." | |
| 446 | + oauth_env_info: "Le credenziali possono anche essere configurate tramite variabili d'ambiente (OAUTH_*_CLIENT_ID, ecc.)." | |
| 447 | + oauth_google_label: "Google" | |
| 448 | + oauth_google_desc: "OpenID Connect" | |
| 449 | + oauth_github_label: "GitHub" | |
| 450 | + oauth_github_desc: "OAuth2" | |
| 451 | + oauth_discord_label: "Discord" | |
| 452 | + oauth_discord_desc: "OAuth2" | |
| 453 | + oauth_microsoft_label: "Microsoft" | |
| 454 | + oauth_microsoft_desc: "OpenID Connect" | |
| 455 | + users_list: | |
| 456 | + page_title: "Gestione Utenti - Admin - RustCloud" | |
| 457 | + title: "Gestione Utenti" | |
| 458 | + breadcrumb_users: "Utenti" | |
| 459 | + create_user: "Crea un utente" | |
| 460 | + search_label: "Cerca" | |
| 461 | + search_placeholder: "Nome utente o email..." | |
| 462 | + role_filter_label: "Ruolo" | |
| 463 | + all_roles: "Tutti i ruoli" | |
| 464 | + per_page_label: "Per pagina" | |
| 465 | + search_button: "Cerca" | |
| 466 | + results_summary: "Visualizzazione di {count} utente/i su {total} totali" | |
| 467 | + page_info: "Pagina {page} di {total_pages}" | |
| 468 | + table_username: "Nome utente" | |
| 469 | + table_email: "Email" | |
| 470 | + table_roles: "Ruoli" | |
| 471 | + table_created_at: "Creato il" | |
| 472 | + table_last_login: "Ultimo accesso" | |
| 473 | + table_actions: "Azioni" | |
| 474 | + never: "Mai" | |
| 475 | + edit: "Modifica" | |
| 476 | + delete: "Elimina" | |
| 477 | + reset_password: "Reimposta la password" | |
| 478 | + no_users: "Nessun utente trovato" | |
| 479 | + reset_password_title: "Reimposta la password" | |
| 480 | + reset_generate: "Genera una password casuale" | |
| 481 | + reset_custom: "Imposta una password personalizzata" | |
| 482 | + reset_email: "Reimposta via email" | |
| 483 | + reset_new_password_label: "Nuova password" | |
| 484 | + reset_new_password_placeholder: "Minimo 8 caratteri, maiuscola, minuscola, cifra, carattere speciale" | |
| 485 | + reset_password_requirements: "⚠️ Deve contenere: 8+ caratteri, maiuscola, minuscola, cifra, carattere speciale" | |
| 486 | + reset_invalidate_sessions: "Invalida tutte le sessioni attive di questo utente" | |
| 487 | + reset_invalidate_sessions_desc: "L'utente dovrà riconnettersi dopo la modifica" | |
| 488 | + reset_button: "Reimposta" | |
| 489 | + delete_confirm_title: "⚠️ Conferma eliminazione" | |
| 490 | + delete_confirm_message: "Sei sicuro di voler eliminare questo utente?" | |
| 491 | + delete_confirm_username: "Nome utente:" | |
| 492 | + delete_confirm_email: "Email:" | |
| 493 | + delete_irreversible: "Questa azione è irreversibile!" | |
| 494 | + delete_button: "Elimina definitivamente" | |
| 495 | + edit_user_title: "Modifica utente" | |
| 496 | + delete_error: "Errore durante l'eliminazione dell'utente" | |
| 497 | + network_error: "Errore di rete" | |
| 498 | + resetting: "Reimpostazione..." | |
| 499 | + reset_by_email_title: "Reimpostazione via email" | |
| 500 | + close: "Chiudi" | |
| 501 | + password_reset_success_title: "Password reimpostata" | |
| 502 | + password_warning_once: "Questa password verrà mostrata una sola volta. Copiala ora!" | |
| 503 | + new_password_label: "Nuova password" | |
| 504 | + copy: "Copia" | |
| 505 | + password_copied: "Password copiata negli appunti" | |
| 506 | + password_set_success: "Password reimpostata con successo" | |
| 507 | + reset_error: "Errore durante la reimpostazione della password" | |
| 508 | + | |
| 509 | +# Cambio password | |
| 510 | +password: | |
| 511 | + title: "Cambia la mia password" | |
| 512 | + page_title: "Cambia la mia password - RustCloud" | |
| 513 | + subtitle: "Modifica la tua password per proteggere il tuo account. Un'email di conferma ti verrà inviata per validare la modifica." | |
| 514 | + subtitle_no_email: "Modifica la tua password per proteggere il tuo account." | |
| 515 | + must_change_message: "⚠️ Devi cambiare la tua password prima di continuare." | |
| 516 | + new_password: "Nuova password" | |
| 517 | + current_password: "Password attuale" | |
| 518 | + current_password_placeholder: "La tua password attuale" | |
| 519 | + new_password_placeholder: "Nuova password sicura" | |
| 520 | + required: "Obbligatorio" | |
| 521 | + requirements: "📌 Rispetta i requisiti di complessità indicati di seguito." | |
| 522 | + strength: "Sicurezza della password:" | |
| 523 | + strength_weak: "Debole" | |
| 524 | + strength_medium: "Media" | |
| 525 | + strength_strong: "Forte ✓" | |
| 526 | + strength_too_short: "troppo corta" | |
| 527 | + strength_missing_lowercase: "minuscola mancante" | |
| 528 | + strength_missing_uppercase: "maiuscola mancante" | |
| 529 | + strength_missing_digit: "cifra mancante" | |
| 530 | + strength_missing_special: "carattere speciale mancante" | |
| 531 | + confirm_password: "Conferma la nuova password" | |
| 532 | + confirm_password_placeholder: "Ripeti la nuova password" | |
| 533 | + passwords_match: "✓ Le password corrispondono" | |
| 534 | + passwords_not_match: "✗ Le password non corrispondono" | |
| 535 | + change_button: "Cambia password" | |
| 536 | + changing: "Modifica in corso..." | |
| 537 | + logout_warning_title: "Disconnessione dalle altre sessioni" | |
| 538 | + logout_warning_desc: "Tutte le tue altre sessioni attive verranno automaticamente disconnesse dopo il cambio di password. Solo la tua sessione attuale rimarrà attiva." | |
| 539 | + save: "Salva la nuova password" | |
| 540 | + saving: "Salvataggio..." | |
| 541 | + saved: "Un'email di conferma è stata inviata al tuo indirizzo. Clicca sul link nell'email per validare il cambio di password." | |
| 542 | + saved_direct: "Password cambiata con successo! Le tue altre sessioni sono state disconnesse." | |
| 543 | + error: "Errore durante la modifica della password" | |
| 544 | + error_all_fields_required: "Tutti i campi sono obbligatori." | |
| 545 | + error_empty_data: "I dati da inviare sono vuoti." | |
| 546 | + error_invalid_data: "Dati non validi. Verifica che tutti i campi siano compilati." | |
| 547 | + error_incorrect_current: "Password attuale errata." | |
| 548 | + error_reading_response: "Errore durante la lettura della risposta del server" | |
| 549 | + error_415: "Errore 415: Il server non ha ricevuto i dati nel formato JSON atteso. Riprova." | |
| 550 | + network_error: "Errore di rete. Riprova." | |
| 551 | + security_tips_title: "Consigli di sicurezza" | |
| 552 | + security_tip_unique: "Usa una password unica che non usi altrove" | |
| 553 | + security_tip_share: "Non condividere mai la tua password con nessuno" | |
| 554 | + security_tip_regular: "Cambia la tua password regolarmente (ogni 3-6 mesi)" | |
| 555 | + security_tip_2fa: "Attiva l'autenticazione a due fattori se disponibile" | |
| 556 | + security_tip_manager: "Usa un gestore di password per maggiore sicurezza" | |
| 557 | + pending_request_message: "⚠️ Una richiesta di cambio password è in attesa di conferma via email." | |
| 558 | + pending_request_info: "Un'email di conferma è stata inviata al tuo indirizzo. Clicca sul link nell'email per confermare la modifica." | |
| 559 | + pending_request_cancel: "Annulla la richiesta in attesa" | |
| 560 | + pending_request_canceled: "Richiesta annullata con successo" | |
| 561 | + pending_request_cancel_error: "Errore durante l'annullamento della richiesta" | |
| 562 | + confirmation_sent: "Email di conferma inviata! Controlla la tua casella di posta." | |
| 563 | + confirmation_error: "Errore durante l'invio dell'email di conferma" | |
| 564 | + | |
| 565 | +# Sessioni | |
| 566 | +sessions: | |
| 567 | + title: "Sessioni attive" | |
| 568 | + page_title: "Sessioni attive - RustCloud" | |
| 569 | + subtitle: "Gestisci i dispositivi connessi al tuo account" | |
| 570 | + current_session: "Sessione attuale" | |
| 571 | + current_badge: "Attuale" | |
| 572 | + unknown_device: "Dispositivo sconosciuto" | |
| 573 | + ip_address: "Indirizzo IP:" | |
| 574 | + created_at: "Creata il:" | |
| 575 | + last_activity: "Ultima attività:" | |
| 576 | + session_id: "ID Sessione:" | |
| 577 | + revoke: "Revoca" | |
| 578 | + revoke_confirm: "Sei sicuro di voler revocare la sessione per \"{device}\"?\n\nQuesto dispositivo verrà disconnesso immediatamente." | |
| 579 | + revoke_success: "Sessione revocata con successo!" | |
| 580 | + revoke_error: "Errore durante la revoca della sessione" | |
| 581 | + no_sessions: "Nessuna sessione attiva" | |
| 582 | + no_sessions_desc: "Non hai attualmente sessioni attive" | |
| 583 | + danger_zone: "Zona pericolosa" | |
| 584 | + danger_zone_desc: "Disconnetti tutti gli altri dispositivi in un'unica azione (la tua sessione attuale rimarrà attiva)." | |
| 585 | + revoke_all: "Revoca tutte le altre sessioni" | |
| 586 | + revoke_all_confirm: "⚠️ ATTENZIONE\n\nSei sicuro di voler revocare TUTTE le tue altre sessioni?\n\nTutti i tuoi altri dispositivi verranno disconnessi. Solo la tua sessione attuale rimarrà attiva." | |
| 587 | + revoke_all_success: "Tutte le altre sessioni sono state revocate!" | |
| 588 | + revoke_all_error: "Errore durante la revoca. Alcune sessioni potrebbero non essere state revocate." | |
| 589 | + about_title: "Informazioni sulle sessioni" | |
| 590 | + about_desc: "Una sessione viene creata ogni volta che accedi da un dispositivo o browser diverso. Revocare una sessione disconnette immediatamente quel dispositivo. La tua sessione attuale non può essere revocata da questa pagina (disconnettiti per farlo)." | |
| 591 | + back_to_settings: "Torna alle impostazioni" | |
| 592 | + | |
| 593 | +# Menu di navigazione (navbar) | |
| 594 | +nav: | |
| 595 | + home: "Home" | |
| 596 | + settings: "Impostazioni" | |
| 597 | + admin: "Amministrazione" | |
| 598 | + | |
| 599 | +# Menu utente | |
| 600 | +user_menu: | |
| 601 | + profile: "Profilo" | |
| 602 | + logout: "Disconnetti" | |
| 603 | + settings: "Impostazioni" | |
| 604 | + | |
| 605 | +# Organizzazioni | |
| 606 | +organisations: | |
| 607 | + list: | |
| 608 | + title: "Gestione delle Organizzazioni" | |
| 609 | + create: "Crea un'organizzazione" | |
| 610 | + search: "Cerca" | |
| 611 | + search_placeholder: "Nome o descrizione..." | |
| 612 | + no_organisations: "Nessuna organizzazione trovata" | |
| 613 | + results_summary: "Risultati" | |
| 614 | + manage_members: "Gestisci i membri" | |
| 615 | + table: | |
| 616 | + name: "Nome" | |
| 617 | + description: "Descrizione" | |
| 618 | + members: "Membri" | |
| 619 | + created_at: "Creata il" | |
| 620 | + actions: "Azioni" | |
| 621 | + create: | |
| 622 | + title: "Crea un'organizzazione" | |
| 623 | + submit: "Crea" | |
| 624 | + success: "Organizzazione creata con successo" | |
| 625 | + error: | |
| 626 | + already_exists: "L'organizzazione '{gid}' esiste già" | |
| 627 | + invalid_gid: "Formato gid non valido: {gid}" | |
| 628 | + generic: "Si è verificato un errore durante la creazione dell'organizzazione" | |
| 629 | + edit: | |
| 630 | + title: "Modifica l'organizzazione" | |
| 631 | + submit: "Salva" | |
| 632 | + update: | |
| 633 | + success: "Organizzazione aggiornata con successo" | |
| 634 | + delete: | |
| 635 | + success: "Organizzazione eliminata con successo" | |
| 636 | + confirm: "Sei sicuro di voler eliminare questa organizzazione? Questa azione è irreversibile." | |
| 637 | + form: | |
| 638 | + name_label: "Nome" | |
| 639 | + name_placeholder: "Nome dell'organizzazione" | |
| 640 | + description_label: "Descrizione" | |
| 641 | + description_placeholder: "Descrizione dell'organizzazione (opzionale)" | |
| 642 | + gid_label: "Identificatore (GID)" | |
| 643 | + gid_placeholder: "identificatore-unico" | |
| 644 | + gid_help: "Identificatore unico (lettere, numeri, underscore, max 64 caratteri)" | |
| 645 | + members: | |
| 646 | + title: "Membri dell'organizzazione" | |
| 647 | + add_member: "Aggiungi un membro" | |
| 648 | + search_user_label: "Cerca un utente" | |
| 649 | + search_user_placeholder: "Nome utente o email..." | |
| 650 | + no_members: "Nessun membro in questa organizzazione" | |
| 651 | + back_to_list: "Torna alla lista" | |
| 652 | + table: | |
| 653 | + username: "Nome utente" | |
| 654 | + role: "Ruolo" | |
| 655 | + joined_at: "Iscritto il" | |
| 656 | + actions: "Azioni" | |
| 657 | + add_success: "Membro aggiunto con successo" | |
| 658 | + remove_success: "Membro rimosso con successo" | |
| 659 | + add_error: "Errore durante l'aggiunta del membro" | |
| 660 | + remove_error: "Errore durante la rimozione del membro" | |
| 661 | + user_not_found: "Utente '{search}' non trovato" | |
| 662 | + error: | |
| 663 | + organisation_not_found: "Organizzazione '{gid}' non trovata" | |
| 664 | + already_member: "Questo utente è già membro dell'organizzazione" | |
| 665 | + | |
| 666 | +# Condivisione | |
| 667 | +sharing: | |
| 668 | + modal: | |
| 669 | + title: "Condividi una risorsa" | |
| 670 | + resource_label: "Risorsa" | |
| 671 | + resource_type_label: "Tipo" | |
| 672 | + expiration_label: "Data di scadenza (opzionale)" | |
| 673 | + expiration_help: "La condivisione scadrà automaticamente a questa data" | |
| 674 | + cancel_button: "Annulla" | |
| 675 | + submit_button: "Condividi" | |
| 676 | + close_button: "Chiudi" | |
| 677 | + success_message: "Condivisione creata con successo" | |
| 678 | + error_message: "Errore durante la creazione della condivisione" | |
| 679 | + error: | |
| 680 | + already_exists: "Questa condivisione esiste già per questa risorsa e questa organizzazione" | |
| 681 | + components: | |
| 682 | + share_button_label: "Condividi" | |
| 683 | + permissions_label: "Permessi" | |
| 684 | + permissions_help: "Scegli i permessi concessi per questa condivisione" | |
| 685 | + share_with_label: "Condividi con" | |
| 686 | + user_tab_label: "Utente" | |
| 687 | + organisation_tab_label: "Organizzazione" | |
| 688 | + select_user_label: "Seleziona un utente" | |
| 689 | + search_user_placeholder: "Cerca un utente..." | |
| 690 | + select_organisation_label: "Seleziona un'organizzazione" | |
| 691 | + select_organisation_placeholder: "Scegli un'organizzazione..." | |
| 692 | + no_organisations_message: "Nessuna organizzazione disponibile" | |
| 693 | + permissions: | |
| 694 | + read_only: "Sola lettura" | |
| 695 | + read_only_value: "1" | |
| 696 | + read_only_description: "Può solo consultare la risorsa" | |
| 697 | + read_write: "Lettura + Scrittura" | |
| 698 | + read_write_value: "3" | |
| 699 | + read_write_description: "Può consultare e modificare la risorsa" | |
| 700 | + all: "Tutti i permessi" | |
| 701 | + all_value: "15" | |
| 702 | + all_description: "Può consultare, modificare, eliminare e condividere" |
A
crates/rustwarden-ui/locales/pt.yml
+702
-0
@@ -0,0 +1,702 @@
| 1 | +# Traduções portuguesas — rustwarden-ui | |
| 2 | +# Seções: login, páginas de usuário, admin, navegação | |
| 3 | + | |
| 4 | +# Acesso | |
| 5 | +login: | |
| 6 | + title: "Entrar" | |
| 7 | + page_title: "Entrar - RustCloud" | |
| 8 | + page_description: "Acesso ao RustCloud" | |
| 9 | + subtitle: "Acesse seu espaço na nuvem" | |
| 10 | + username: "Nome de usuário ou email" | |
| 11 | + password: "Senha" | |
| 12 | + submit: "Entrar" | |
| 13 | + remember_me: "Lembrar-me" | |
| 14 | + tab_login: "Entrar" | |
| 15 | + tab_register: "Registrar" | |
| 16 | + register_title: "Criar uma conta" | |
| 17 | + register_submit: "Criar minha conta" | |
| 18 | + username_placeholder: "Nome de usuário ou email" | |
| 19 | + password_placeholder: "Sua senha" | |
| 20 | + username_placeholder_register: "Seu nome de usuário (mín. 3 caracteres)" | |
| 21 | + password_placeholder_register: "Sua senha (mín. 8 caracteres)" | |
| 22 | + email_placeholder: "seu@email.com" | |
| 23 | + forgot_password: "Esqueceu a senha?" | |
| 24 | + email_not_verified: "Email não verificado?" | |
| 25 | + resend_verification: "Reenviar o email de verificação" | |
| 26 | + already_have_account: "Já tem uma conta?" | |
| 27 | + sign_in_link: "Entrar" | |
| 28 | + success_message: "Login realizado com sucesso! Redirecionando..." | |
| 29 | + error_message: "Erro de login. Verifique suas credenciais." | |
| 30 | + email_unverified_message: "Seu email não está verificado. Clique abaixo para reenviar o link." | |
| 31 | + resend_button: "Reenviar o email de verificação" | |
| 32 | + resend_success: "Email de verificação reenviado se a conta existir." | |
| 33 | + resend_error: "Não foi possível enviar a solicitação no momento." | |
| 34 | + resend_prompt: "Insira seu email antes de reenviar." | |
| 35 | + register_success: "Conta criada com sucesso! Verifique seu email." | |
| 36 | + register_error: "Erro durante o registro" | |
| 37 | + email_label: "Email" | |
| 38 | + footer_text: "Fork do Nextcloud em Rust" | |
| 39 | + | |
| 40 | +# Reenvio de email de verificação | |
| 41 | +resend_verification: | |
| 42 | + page_title: "Reenviar o email de verificação - RustWarden" | |
| 43 | + title: "Reenviar o email de verificação" | |
| 44 | + subtitle: "Insira seu endereço de email para receber um novo link de verificação." | |
| 45 | + email_label: "Endereço de email" | |
| 46 | + email_placeholder: "voce@exemplo.com" | |
| 47 | + submit: "Enviar o link de verificação" | |
| 48 | + back_to_login: "Voltar ao login" | |
| 49 | + error_default: "Não foi possível enviar a solicitação." | |
| 50 | + success_default: "Se a conta existir e não estiver verificada, um email foi enviado." | |
| 51 | + error_missing_email: "Insira seu email." | |
| 52 | + error_cannot_send: "Não foi possível enviar a solicitação no momento." | |
| 53 | + | |
| 54 | +# Redefinição de senha (via link de email) | |
| 55 | +password_reset: | |
| 56 | + page_title: "Redefinir a senha" | |
| 57 | + title: "Redefinir a senha" | |
| 58 | + subtitle: "Insira sua nova senha abaixo." | |
| 59 | + new_password_label: "Nova senha" | |
| 60 | + new_password_placeholder: "Nova senha segura" | |
| 61 | + requirements: "⚠️ Deve conter: 8+ caracteres, maiúscula, minúscula, dígito, caractere especial" | |
| 62 | + confirm_password_label: "Confirmar senha" | |
| 63 | + confirm_password_placeholder: "Confirme sua nova senha" | |
| 64 | + missing_token_url: "Token de redefinição ausente na URL." | |
| 65 | + missing_token: "Token de redefinição ausente." | |
| 66 | + missing_new_password: "Insira uma nova senha." | |
| 67 | + passwords_not_match: "As senhas não correspondem." | |
| 68 | + success_message: "Sua senha foi redefinida com sucesso. Agora você pode entrar." | |
| 69 | + error_link_invalid: "Não foi possível redefinir a senha. O link pode ter expirado ou ser inválido." | |
| 70 | + error_generic: "Não foi possível redefinir a senha no momento." | |
| 71 | + | |
| 72 | +# Página de login 2FA | |
| 73 | +login_2fa: | |
| 74 | + page_title: "Verificação 2FA - RustCloud" | |
| 75 | + title: "Verificação em duas etapas" | |
| 76 | + subtitle: "Insira o código do seu aplicativo de autenticação" | |
| 77 | + code_label: "Código de autenticação" | |
| 78 | + backup_code_label: "Código de backup" | |
| 79 | + verify_button: "Verificar" | |
| 80 | + use_backup_code: "Usar um código de backup" | |
| 81 | + use_totp_code: "Usar o código do aplicativo" | |
| 82 | + back_to_login: "Voltar ao login" | |
| 83 | + error_invalid_code: "Código inválido. Tente novamente." | |
| 84 | + error_network: "Erro de rede. Tente novamente." | |
| 85 | + | |
| 86 | +# Segurança 2FA | |
| 87 | +settings_security: | |
| 88 | + page_title: "Segurança - RustWarden" | |
| 89 | + title: "Autenticação de dois fatores" | |
| 90 | + subtitle: "Proteja sua conta com uma camada adicional de segurança" | |
| 91 | + totp_title: "Autenticação TOTP" | |
| 92 | + status_enabled: "Ativada" | |
| 93 | + status_disabled: "Desativada" | |
| 94 | + enabled_message: "Sua conta está protegida pela autenticação de dois fatores." | |
| 95 | + disabled_message: "Adicione uma camada extra de segurança à sua conta ativando a autenticação de dois fatores por meio de um aplicativo como Google Authenticator ou Authy." | |
| 96 | + enable_button: "Ativar 2FA" | |
| 97 | + disable_button: "Desativar 2FA" | |
| 98 | + scan_qr: "Escaneie este código QR com seu aplicativo de autenticação" | |
| 99 | + manual_entry: "Ou insira este código manualmente" | |
| 100 | + enter_code: "Insira o código de 6 dígitos do seu aplicativo" | |
| 101 | + verify_button: "Verificar e ativar" | |
| 102 | + backup_codes_title: "Códigos de backup" | |
| 103 | + backup_codes_warning: "Guarde estes códigos em um lugar seguro. Cada código pode ser usado apenas uma vez." | |
| 104 | + backup_codes_remaining: "Códigos de backup restantes" | |
| 105 | + regenerate_button: "Regenerar códigos" | |
| 106 | + regenerate_confirm: "Tem certeza? Os códigos antigos serão substituídos." | |
| 107 | + confirm_disable: "Insira sua senha para desativar o 2FA" | |
| 108 | + password_label: "Senha atual" | |
| 109 | + copy_button: "Copiar" | |
| 110 | + saved_codes_button: "Salvei meus códigos" | |
| 111 | + breadcrumb: "Segurança" | |
| 112 | + force_2fa_message: "O administrador exige a autenticação de dois fatores. Você deve configurar o 2FA antes de poder acessar o restante da aplicação." | |
| 113 | + force_2fa_logout: "Sair" | |
| 114 | + | |
| 115 | +# Configurações | |
| 116 | +settings: | |
| 117 | + title: "Configurações" | |
| 118 | + page_title: "Configurações - RustWarden" | |
| 119 | + subtitle: "Gerencie suas preferências e configurações de conta" | |
| 120 | + profile_title: "Meu Perfil" | |
| 121 | + profile_desc: "Edite suas informações pessoais (nome de usuário, email)" | |
| 122 | + password_title: "Senha" | |
| 123 | + password_desc: "Altere sua senha para proteger sua conta" | |
| 124 | + sessions_title: "Sessões ativas" | |
| 125 | + sessions_desc: "Gerencie suas sessões ativas e revogue o acesso de outros dispositivos" | |
| 126 | + preferences_title: "Preferências" | |
| 127 | + preferences_desc: "Personalize sua experiência (tema, idioma)" | |
| 128 | + access: "Acessar" | |
| 129 | + info_title: "Informações" | |
| 130 | + info_desc: "Use as seções acima para gerenciar os diferentes aspectos da sua conta. Todas as alterações são seguras e requerem sua autenticação." | |
| 131 | + language: "Idioma" | |
| 132 | + language_description: "Escolha seu idioma preferido" | |
| 133 | + save: "Salvar" | |
| 134 | + saved: "Configurações salvas" | |
| 135 | + | |
| 136 | +# Página principal | |
| 137 | +home: | |
| 138 | + welcome: "Bem-vindo, {username}!" | |
| 139 | + subtitle: "Bem-vindo ao seu espaço pessoal" | |
| 140 | + my_account: "Minha Conta" | |
| 141 | + stat_username: "Nome de usuário" | |
| 142 | + stat_email: "Email" | |
| 143 | + stat_member_since: "Membro desde" | |
| 144 | + stat_roles: "Funções" | |
| 145 | + | |
| 146 | +# Preferências | |
| 147 | +preferences: | |
| 148 | + title: "Preferências de exibição" | |
| 149 | + page_title: "Preferências de exibição - RustCloud" | |
| 150 | + subtitle: "Personalize a aparência da interface" | |
| 151 | + theme: "Tema" | |
| 152 | + theme_light: "Claro" | |
| 153 | + theme_light_desc: "Interface clara e limpa" | |
| 154 | + theme_dark: "Escuro" | |
| 155 | + theme_dark_desc: "Reduz o cansaço visual" | |
| 156 | + theme_auto: "Automático" | |
| 157 | + theme_auto_desc: "Adapta-se à hora do dia" | |
| 158 | + theme_preview: "O tema será aplicado imediatamente após salvar" | |
| 159 | + language: "Idioma" | |
| 160 | + language_fr: "Français" | |
| 161 | + language_fr_desc: "Interface em francês" | |
| 162 | + language_en: "English" | |
| 163 | + language_en_desc: "Interface em inglês" | |
| 164 | + language_de: "Deutsch" | |
| 165 | + language_de_desc: "Interface em alemão" | |
| 166 | + language_es: "Español" | |
| 167 | + language_es_desc: "Interface em espanhol" | |
| 168 | + language_it: "Italiano" | |
| 169 | + language_it_desc: "Interface em italiano" | |
| 170 | + language_pt: "Português" | |
| 171 | + language_pt_desc: "Interface em português" | |
| 172 | + translation_warning: "A tradução completa da interface está em desenvolvimento. Alguns elementos podem permanecer em francês." | |
| 173 | + modified: "Preferências modificadas" | |
| 174 | + modified_desc: "Salve as alterações para aplicá-las" | |
| 175 | + save: "Salvar preferências" | |
| 176 | + saving: "Salvando..." | |
| 177 | + cancel: "Cancelar" | |
| 178 | + saved_success: "Preferências salvas com sucesso!" | |
| 179 | + saved_reload: "Preferências salvas com sucesso!\n\nO idioma foi alterado. Deseja recarregar a página agora para aplicar as alterações?" | |
| 180 | + error: "Erro: {message}" | |
| 181 | + network_error: "Erro de rede. Tente novamente." | |
| 182 | + sync_title: "Suas preferências estão sincronizadas" | |
| 183 | + sync_desc: "Suas preferências de exibição estão salvas e se aplicam automaticamente em todos os dispositivos conectados à sua conta RustCloud." | |
| 184 | + preview_dark: "Prévia: Tema escuro será aplicado" | |
| 185 | + preview_light: "Prévia: Tema claro será aplicado" | |
| 186 | + preview_auto: "Prévia: Tema automático será aplicado" | |
| 187 | + | |
| 188 | +# Perfil | |
| 189 | +profile: | |
| 190 | + title: "Meu Perfil" | |
| 191 | + page_title: "Meu Perfil - RustCloud" | |
| 192 | + subtitle: "Edite suas informações pessoais" | |
| 193 | + account_info: "Informações da conta" | |
| 194 | + username: "Nome de usuário" | |
| 195 | + username_public: "Visível publicamente" | |
| 196 | + username_placeholder: "Seu nome de usuário" | |
| 197 | + username_pattern: "Apenas letras, números, hífens e sublinhados" | |
| 198 | + email: "Email" | |
| 199 | + email_placeholder: "seu@email.com" | |
| 200 | + save: "Salvar alterações" | |
| 201 | + saving: "Salvando..." | |
| 202 | + saved: "Perfil atualizado com sucesso" | |
| 203 | + error: "Erro ao atualizar o perfil" | |
| 204 | + email_label: "Endereço de email" | |
| 205 | + email_used_for: "Usado para login" | |
| 206 | + email_warning: "⚠️ Você será desconectado após a alteração" | |
| 207 | + 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 | + additional_info: "Informações adicionais" | |
| 210 | + account_created: "Conta criada em" | |
| 211 | + roles: "Funções" | |
| 212 | + linked_accounts_title: "Contas vinculadas" | |
| 213 | + no_linked_accounts: "Nenhuma conta externa vinculada." | |
| 214 | + linked_since: "Vinculado desde" | |
| 215 | + unlink_button: "Desvincular" | |
| 216 | + unlink_confirm: "Tem certeza de que deseja desvincular esta conta?" | |
| 217 | + unlink_error: "Não foi possível desvincular esta conta." | |
| 218 | + cannot_unlink_last: "Não é possível remover o último método de autenticação." | |
| 219 | + | |
| 220 | +# Breadcrumb | |
| 221 | +breadcrumb: | |
| 222 | + home: "Home" | |
| 223 | + settings: "Configurações" | |
| 224 | + admin: "Administração" | |
| 225 | + preferences: "Preferências" | |
| 226 | + profile: "Perfil" | |
| 227 | + password: "Senha" | |
| 228 | + users: "Usuários" | |
| 229 | + sessions: "Sessões" | |
| 230 | + | |
| 231 | +# Admin | |
| 232 | +admin: | |
| 233 | + dashboard: | |
| 234 | + title: "Administração" | |
| 235 | + heading: "Painel de controle do Administrador" | |
| 236 | + users_card_title: "Usuários" | |
| 237 | + users_card_subtitle: "Total de usuários" | |
| 238 | + access_button: "Acessar" | |
| 239 | + sessions_card_title: "Sessões ativas" | |
| 240 | + sessions_card_subtitle: "Sessões em andamento" | |
| 241 | + admins_card_title: "Administradores" | |
| 242 | + admins_card_subtitle: "Contas admin" | |
| 243 | + last_activity_card_title: "Última atividade" | |
| 244 | + last_activity_card_subtitle: "Último acesso" | |
| 245 | + audit_section_title: "Estatísticas de log de auditoria" | |
| 246 | + audit_total_logs: "Total de logs" | |
| 247 | + audit_last_24h: "Últimas 24h" | |
| 248 | + audit_by_level: "Por nível" | |
| 249 | + chart_label: "Logs por nível" | |
| 250 | + roles_card_title: "Gerenciamento de Funções" | |
| 251 | + roles_card_desc: "Gerencie as funções e permissões granulares" | |
| 252 | + roles_stat_title: "Funções" | |
| 253 | + settings_card_title: "Configurações da Aplicação" | |
| 254 | + settings_card_desc: "Configure as configurações globais da aplicação" | |
| 255 | + apps_card_title: "Gerenciamento de Aplicações" | |
| 256 | + apps_card_desc: "Ative ou desative as aplicações modulares" | |
| 257 | + orgs_card_title: "Gerenciamento de Organizações" | |
| 258 | + orgs_card_desc: "Crie e gerencie organizações e seus membros" | |
| 259 | + orgs_stat_title: "Organizações" | |
| 260 | + orgs_members_stat_title: "Membros totais" | |
| 261 | + roles_list: | |
| 262 | + page_title: "Gerenciamento de funções" | |
| 263 | + breadcrumb_roles: "Funções" | |
| 264 | + title: "Funções" | |
| 265 | + create_role: "Criar uma função" | |
| 266 | + table_name: "Nome" | |
| 267 | + table_description: "Descrição" | |
| 268 | + table_users: "Usuários" | |
| 269 | + table_permissions: "Permissões" | |
| 270 | + table_actions: "Ações" | |
| 271 | + edit: "Editar" | |
| 272 | + delete: "Excluir" | |
| 273 | + no_roles: "Nenhuma função encontrada." | |
| 274 | + protected_hint: "Protegida" | |
| 275 | + delete_confirm_title: "Confirmar exclusão" | |
| 276 | + delete_confirm_message: "Tem certeza de que deseja excluir a função" | |
| 277 | + delete_button: "Excluir" | |
| 278 | + role_form: | |
| 279 | + title_create: "Criar uma função" | |
| 280 | + title_edit: "Editar função" | |
| 281 | + breadcrumb_create: "Criar" | |
| 282 | + breadcrumb_edit: "Editar" | |
| 283 | + back_to_list: "Voltar à lista" | |
| 284 | + name_label: "Nome da função" | |
| 285 | + name_placeholder: "ex: moderador" | |
| 286 | + description_label: "Descrição" | |
| 287 | + description_placeholder: "Descrição da função..." | |
| 288 | + permissions_label: "Permissões" | |
| 289 | + protected_name_hint: "O nome desta função não pode ser alterado." | |
| 290 | + cancel: "Cancelar" | |
| 291 | + submit_create: "Criar função" | |
| 292 | + submit_edit: "Salvar alterações" | |
| 293 | + errors: | |
| 294 | + title: "Códigos de Erro" | |
| 295 | + description: "Documentação dos códigos de erro da aplicação. Cada código de erro está associado a um middleware e fornece informações de contexto para depuração." | |
| 296 | + breadcrumb_errors: "Códigos de Erro" | |
| 297 | + table_code: "Código" | |
| 298 | + table_description: "Descrição" | |
| 299 | + table_category: "Categoria" | |
| 300 | + table_middleware: "Middleware" | |
| 301 | + table_context: "Contexto" | |
| 302 | + table_example: "Exemplo" | |
| 303 | + legend_title: "Legenda" | |
| 304 | + legend_code: "Identificador único do erro (formato: CATEGORY_NUMBER)" | |
| 305 | + legend_description: "Descrição do erro" | |
| 306 | + legend_category: "Tipo de erro (Autenticação, CSRF, Rate Limiting, etc.)" | |
| 307 | + legend_middleware: "Middleware responsável pela geração deste erro" | |
| 308 | + legend_context: "Explicação detalhada de quando este erro ocorre" | |
| 309 | + legend_example: "Exemplo concreto de situação que gera este erro" | |
| 310 | + error_auth001_desc: "Credenciais inválidas" | |
| 311 | + error_auth001_category: "Autenticação" | |
| 312 | + error_auth001_context: "O usuário forneceu credenciais incorretas durante o login" | |
| 313 | + error_auth001_example: "Tentativa de login com senha incorreta" | |
| 314 | + error_auth002_desc: "Token inválido" | |
| 315 | + error_auth002_category: "Autenticação" | |
| 316 | + error_auth002_context: "O token JWT fornecido é inválido ou malformado" | |
| 317 | + error_auth002_example: "Token JWT corrompido ou assinado com uma chave diferente" | |
| 318 | + error_auth003_desc: "Token expirado" | |
| 319 | + error_auth003_category: "Autenticação" | |
| 320 | + error_auth003_context: "O token JWT expirou e deve ser renovado" | |
| 321 | + error_auth003_example: "Tentativa de acesso com token expirado após o período de validade" | |
| 322 | + error_csrf001_desc: "Token CSRF ausente" | |
| 323 | + error_csrf001_category: "Proteção CSRF" | |
| 324 | + error_csrf001_context: "A requisição POST/PUT/DELETE não contém um token CSRF" | |
| 325 | + error_csrf001_example: "Formulário enviado sem token CSRF no header ou no body" | |
| 326 | + error_csrf002_desc: "Token CSRF inválido" | |
| 327 | + error_csrf002_category: "Proteção CSRF" | |
| 328 | + error_csrf002_context: "O token CSRF fornecido não corresponde ao token da sessão" | |
| 329 | + error_csrf002_example: "Token CSRF modificado ou roubado" | |
| 330 | + error_rate001_desc: "Limite de taxa excedido" | |
| 331 | + error_rate001_category: "Rate Limiting" | |
| 332 | + error_rate001_context: "O número de requisições por minuto foi excedido" | |
| 333 | + error_rate001_example: "Mais de 100 requisições/min nos endpoints admin" | |
| 334 | + error_valid001_desc: "Dados de entrada inválidos" | |
| 335 | + error_valid001_category: "Validação" | |
| 336 | + error_valid001_context: "Os dados fornecidos não respeitam as regras de validação" | |
| 337 | + error_valid001_example: "Email em formato inválido ou campo obrigatório ausente" | |
| 338 | + error_valid002_desc: "Complexidade de senha não respeitada" | |
| 339 | + error_valid002_category: "Validação" | |
| 340 | + error_valid002_context: "A senha não respeita as regras de complexidade configuradas" | |
| 341 | + error_valid002_example: "Senha muito curta ou sem os caracteres exigidos" | |
| 342 | + error_rbac001_desc: "Permissões insuficientes" | |
| 343 | + error_rbac001_category: "Autorização" | |
| 344 | + error_rbac001_context: "O usuário não tem as permissões necessárias para esta ação" | |
| 345 | + error_rbac001_example: "Usuário não admin tentando acessar /admin/users" | |
| 346 | + error_rbac002_desc: "Função de administrador necessária" | |
| 347 | + error_rbac002_category: "Autorização" | |
| 348 | + error_rbac002_context: "A ação requer a função de administrador" | |
| 349 | + error_rbac002_example: "Tentativa de exclusão de usuário sem função admin" | |
| 350 | + error_db001_desc: "Erro na operação do banco de dados" | |
| 351 | + error_db001_category: "Banco de dados" | |
| 352 | + error_db001_context: "Uma operação do banco de dados falhou" | |
| 353 | + error_db001_example: "Restrição de chave estrangeira violada ou conexão com DB perdida" | |
| 354 | + error_db002_desc: "Recurso não encontrado" | |
| 355 | + error_db002_category: "Banco de dados" | |
| 356 | + error_db002_context: "O recurso solicitado não existe no banco de dados" | |
| 357 | + error_db002_example: "Usuário com ID inexistente" | |
| 358 | + user_form: | |
| 359 | + breadcrumb_create: "Criar" | |
| 360 | + breadcrumb_edit: "Editar" | |
| 361 | + title_create: "Criar um usuário" | |
| 362 | + title_edit: "Editar um usuário" | |
| 363 | + back_to_list: "Voltar à lista" | |
| 364 | + username_label: "Nome de usuário" | |
| 365 | + username_placeholder: "nome_usuario" | |
| 366 | + username_readonly_hint: "O nome de usuário não pode ser alterado após a criação" | |
| 367 | + email_label: "Email" | |
| 368 | + email_placeholder: "usuario@exemplo.com" | |
| 369 | + password_label: "Senha" | |
| 370 | + password_optional: "Opcional" | |
| 371 | + password_placeholder: "Deixe em branco para gerar uma senha aleatória" | |
| 372 | + password_hint: "Se deixado em branco, uma senha segura será gerada automaticamente" | |
| 373 | + roles_label: "Funções" | |
| 374 | + roles_hint: "Selecione pelo menos uma função" | |
| 375 | + must_change_password_label: "Forçar alteração de senha" | |
| 376 | + must_change_password_desc: "O usuário deverá alterar a senha no próximo acesso" | |
| 377 | + cancel: "Cancelar" | |
| 378 | + submit_create: "Criar usuário" | |
| 379 | + submit_edit: "Salvar alterações" | |
| 380 | + settings: | |
| 381 | + title: "Configurações da Aplicação" | |
| 382 | + page_title: "Configurações da Aplicação - Admin - RustCloud" | |
| 383 | + subtitle: "Configuração global do RustCloud" | |
| 384 | + general: "Geral" | |
| 385 | + app_name: "Nome da aplicação" | |
| 386 | + app_domain: "Domínio" | |
| 387 | + app_domain_placeholder: "exemplo.com" | |
| 388 | + app_description: "Descrição" | |
| 389 | + allow_registration: "Permitir registro" | |
| 390 | + allow_registration_desc: "Os usuários podem criar contas na página principal" | |
| 391 | + email_validation_required: "Validação de email obrigatória" | |
| 392 | + email_validation_required_desc: "Os usuários devem validar seu email antes de acessar" | |
| 393 | + save: "Salvar configurações" | |
| 394 | + cancel: "Cancelar" | |
| 395 | + saved: "Configurações salvas com sucesso" | |
| 396 | + error: "Erro ao salvar" | |
| 397 | + network_error: "Erro de rede. Tente novamente." | |
| 398 | + password_security: "Segurança das Senhas" | |
| 399 | + password_min_length: "Comprimento mínimo" | |
| 400 | + password_min_length_hint: "Entre 6 e 128 caracteres" | |
| 401 | + password_complexity: "Requisitos de complexidade" | |
| 402 | + require_uppercase: "Maiúscula obrigatória" | |
| 403 | + require_lowercase: "Minúscula obrigatória" | |
| 404 | + require_digits: "Dígito obrigatório" | |
| 405 | + require_special: "Caractere especial obrigatório" | |
| 406 | + password_change_require_email: "Confirmação por email da alteração de senha" | |
| 407 | + password_change_require_email_desc: "Quando ativado, um email de confirmação é enviado para validar qualquer alteração de senha. Caso contrário, a alteração é aplicada imediatamente." | |
| 408 | + password_expiration: "Expiração de senhas" | |
| 409 | + password_expiration_desc: "Força a troca periódica" | |
| 410 | + expiration_days: "Expirar após (dias)" | |
| 411 | + expiration_alert: "Aviso por email antes da expiração" | |
| 412 | + expiration_alert_days: "Aviso (dias antes)" | |
| 413 | + audit_logging: "Registro de Auditoria" | |
| 414 | + log_level: "Nível de log" | |
| 415 | + log_level_debug: "DEBUG - Todos os detalhes" | |
| 416 | + log_level_info: "INFO - Informações normais" | |
| 417 | + log_level_warn: "WARN - Apenas avisos" | |
| 418 | + log_level_error: "ERROR - Apenas erros" | |
| 419 | + log_level_hint: "Controla a verbosidade dos logs de auditoria" | |
| 420 | + log_retention: "Retenção de logs (dias)" | |
| 421 | + log_retention_hint: "Período de retenção dos logs (1-365 dias)" | |
| 422 | + audit_info_message: "Os logs de auditoria registram todas as ações administrativas (criação, edição, exclusão de usuários)." | |
| 423 | + save_changes: "Salvar alterações" | |
| 424 | + save_changes_desc: "As configurações serão aplicadas imediatamente" | |
| 425 | + saving: "Salvando..." | |
| 426 | + save_success: "Configurações salvas com sucesso!" | |
| 427 | + save_error_prefix: "Erro" | |
| 428 | + oauth_sso_title: "OAuth / SSO" | |
| 429 | + oauth_enabled_label: "Ativar OAuth" | |
| 430 | + oauth_enabled_desc: "Permitir login via provedores externos (Google, GitHub, etc.)" | |
| 431 | + oauth_redirect_url_label: "URL de redirecionamento base" | |
| 432 | + oauth_redirect_url_desc: "URL base para callbacks OAuth (ex: https://minhaapp.exemplo.com)" | |
| 433 | + oauth_auto_register_label: "Auto-registro" | |
| 434 | + oauth_auto_register_desc: "Criar automaticamente uma conta se o email não existir" | |
| 435 | + oauth_link_existing_label: "Vincular contas existentes" | |
| 436 | + oauth_link_existing_desc: "Vincular automaticamente uma conta OAuth se o email verificado corresponder" | |
| 437 | + oauth_client_id: "Client ID" | |
| 438 | + oauth_client_secret: "Client Secret" | |
| 439 | + oauth_tenant: "Tenant ID" | |
| 440 | + oauth_redirect_uri: "URI de redirecionamento" | |
| 441 | + oauth_copy_uri: "Copiar" | |
| 442 | + oauth_secret_configured: "Configurado" | |
| 443 | + oauth_secret_not_configured: "Não configurado" | |
| 444 | + oauth_secret_change: "Alterar" | |
| 445 | + oauth_restart_warning: "Alterações nas credenciais requerem reinicialização da aplicação." | |
| 446 | + oauth_env_info: "As credenciais também podem ser configuradas via variáveis de ambiente (OAUTH_*_CLIENT_ID, etc.)." | |
| 447 | + oauth_google_label: "Google" | |
| 448 | + oauth_google_desc: "OpenID Connect" | |
| 449 | + oauth_github_label: "GitHub" | |
| 450 | + oauth_github_desc: "OAuth2" | |
| 451 | + oauth_discord_label: "Discord" | |
| 452 | + oauth_discord_desc: "OAuth2" | |
| 453 | + oauth_microsoft_label: "Microsoft" | |
| 454 | + oauth_microsoft_desc: "OpenID Connect" | |
| 455 | + users_list: | |
| 456 | + page_title: "Gerenciamento de Usuários - Admin - RustCloud" | |
| 457 | + title: "Gerenciamento de Usuários" | |
| 458 | + breadcrumb_users: "Usuários" | |
| 459 | + create_user: "Criar um usuário" | |
| 460 | + search_label: "Buscar" | |
| 461 | + search_placeholder: "Nome de usuário ou email..." | |
| 462 | + role_filter_label: "Função" | |
| 463 | + all_roles: "Todas as funções" | |
| 464 | + per_page_label: "Por página" | |
| 465 | + search_button: "Buscar" | |
| 466 | + results_summary: "Exibindo {count} usuário(s) de {total} no total" | |
| 467 | + page_info: "Página {page} de {total_pages}" | |
| 468 | + table_username: "Nome de usuário" | |
| 469 | + table_email: "Email" | |
| 470 | + table_roles: "Funções" | |
| 471 | + table_created_at: "Criado em" | |
| 472 | + table_last_login: "Último acesso" | |
| 473 | + table_actions: "Ações" | |
| 474 | + never: "Nunca" | |
| 475 | + edit: "Editar" | |
| 476 | + delete: "Excluir" | |
| 477 | + reset_password: "Redefinir senha" | |
| 478 | + no_users: "Nenhum usuário encontrado" | |
| 479 | + reset_password_title: "Redefinir senha" | |
| 480 | + reset_generate: "Gerar uma senha aleatória" | |
| 481 | + reset_custom: "Definir uma senha personalizada" | |
| 482 | + reset_email: "Redefinir via email" | |
| 483 | + reset_new_password_label: "Nova senha" | |
| 484 | + reset_new_password_placeholder: "Mínimo 8 caracteres, maiúscula, minúscula, dígito, caractere especial" | |
| 485 | + reset_password_requirements: "⚠️ Deve conter: 8+ caracteres, maiúscula, minúscula, dígito, caractere especial" | |
| 486 | + reset_invalidate_sessions: "Invalidar todas as sessões ativas deste usuário" | |
| 487 | + reset_invalidate_sessions_desc: "O usuário precisará se reconectar após a alteração" | |
| 488 | + reset_button: "Redefinir" | |
| 489 | + delete_confirm_title: "⚠️ Confirmar exclusão" | |
| 490 | + delete_confirm_message: "Tem certeza de que deseja excluir este usuário?" | |
| 491 | + delete_confirm_username: "Nome de usuário:" | |
| 492 | + delete_confirm_email: "Email:" | |
| 493 | + delete_irreversible: "Esta ação é irreversível!" | |
| 494 | + delete_button: "Excluir permanentemente" | |
| 495 | + edit_user_title: "Editar usuário" | |
| 496 | + delete_error: "Erro ao excluir o usuário" | |
| 497 | + network_error: "Erro de rede" | |
| 498 | + resetting: "Redefinindo..." | |
| 499 | + reset_by_email_title: "Redefinição via email" | |
| 500 | + close: "Fechar" | |
| 501 | + password_reset_success_title: "Senha redefinida" | |
| 502 | + password_warning_once: "Esta senha será exibida apenas uma vez. Copie agora!" | |
| 503 | + new_password_label: "Nova senha" | |
| 504 | + copy: "Copiar" | |
| 505 | + password_copied: "Senha copiada para a área de transferência" | |
| 506 | + password_set_success: "Senha redefinida com sucesso" | |
| 507 | + reset_error: "Erro ao redefinir a senha" | |
| 508 | + | |
| 509 | +# Alteração de senha | |
| 510 | +password: | |
| 511 | + title: "Alterar minha senha" | |
| 512 | + page_title: "Alterar minha senha - RustCloud" | |
| 513 | + subtitle: "Altere sua senha para proteger sua conta. Um email de confirmação será enviado para validar a alteração." | |
| 514 | + subtitle_no_email: "Altere sua senha para proteger sua conta." | |
| 515 | + must_change_message: "⚠️ Você deve alterar sua senha antes de continuar." | |
| 516 | + new_password: "Nova senha" | |
| 517 | + current_password: "Senha atual" | |
| 518 | + current_password_placeholder: "Sua senha atual" | |
| 519 | + new_password_placeholder: "Nova senha segura" | |
| 520 | + required: "Obrigatório" | |
| 521 | + requirements: "📌 Respeite os requisitos de complexidade indicados abaixo." | |
| 522 | + strength: "Força da senha:" | |
| 523 | + strength_weak: "Fraca" | |
| 524 | + strength_medium: "Média" | |
| 525 | + strength_strong: "Forte ✓" | |
| 526 | + strength_too_short: "muito curta" | |
| 527 | + strength_missing_lowercase: "minúscula ausente" | |
| 528 | + strength_missing_uppercase: "maiúscula ausente" | |
| 529 | + strength_missing_digit: "dígito ausente" | |
| 530 | + strength_missing_special: "caractere especial ausente" | |
| 531 | + confirm_password: "Confirmar a nova senha" | |
| 532 | + confirm_password_placeholder: "Repita a nova senha" | |
| 533 | + passwords_match: "✓ As senhas correspondem" | |
| 534 | + passwords_not_match: "✗ As senhas não correspondem" | |
| 535 | + change_button: "Alterar senha" | |
| 536 | + changing: "Alterando..." | |
| 537 | + logout_warning_title: "Desconexão das outras sessões" | |
| 538 | + logout_warning_desc: "Todas as suas outras sessões ativas serão automaticamente desconectadas após a alteração de senha. Apenas sua sessão atual permanecerá ativa." | |
| 539 | + save: "Salvar a nova senha" | |
| 540 | + saving: "Salvando..." | |
| 541 | + saved: "Um email de confirmação foi enviado para seu endereço. Clique no link no email para validar a alteração de senha." | |
| 542 | + saved_direct: "Senha alterada com sucesso! Suas outras sessões foram desconectadas." | |
| 543 | + error: "Erro ao alterar a senha" | |
| 544 | + error_all_fields_required: "Todos os campos são obrigatórios." | |
| 545 | + error_empty_data: "Os dados a enviar estão vazios." | |
| 546 | + error_invalid_data: "Dados inválidos. Verifique se todos os campos estão preenchidos." | |
| 547 | + error_incorrect_current: "Senha atual incorreta." | |
| 548 | + error_reading_response: "Erro ao ler a resposta do servidor" | |
| 549 | + error_415: "Erro 415: O servidor não recebeu os dados no formato JSON esperado. Tente novamente." | |
| 550 | + network_error: "Erro de rede. Tente novamente." | |
| 551 | + security_tips_title: "Dicas de segurança" | |
| 552 | + security_tip_unique: "Use uma senha única que não use em outro lugar" | |
| 553 | + security_tip_share: "Nunca compartilhe sua senha com ninguém" | |
| 554 | + security_tip_regular: "Altere sua senha regularmente (a cada 3-6 meses)" | |
| 555 | + security_tip_2fa: "Ative a autenticação de dois fatores se disponível" | |
| 556 | + security_tip_manager: "Use um gerenciador de senhas para maior segurança" | |
| 557 | + pending_request_message: "⚠️ Uma solicitação de alteração de senha aguarda confirmação por email." | |
| 558 | + pending_request_info: "Um email de confirmação foi enviado para seu endereço. Clique no link no email para confirmar a alteração." | |
| 559 | + pending_request_cancel: "Cancelar a solicitação pendente" | |
| 560 | + pending_request_canceled: "Solicitação cancelada com sucesso" | |
| 561 | + pending_request_cancel_error: "Erro ao cancelar a solicitação" | |
| 562 | + confirmation_sent: "Email de confirmação enviado! Verifique sua caixa de entrada." | |
| 563 | + confirmation_error: "Erro ao enviar o email de confirmação" | |
| 564 | + | |
| 565 | +# Sessões | |
| 566 | +sessions: | |
| 567 | + title: "Sessões ativas" | |
| 568 | + page_title: "Sessões ativas - RustCloud" | |
| 569 | + subtitle: "Gerencie os dispositivos conectados à sua conta" | |
| 570 | + current_session: "Sessão atual" | |
| 571 | + current_badge: "Atual" | |
| 572 | + unknown_device: "Dispositivo desconhecido" | |
| 573 | + ip_address: "Endereço IP:" | |
| 574 | + created_at: "Criada em:" | |
| 575 | + last_activity: "Última atividade:" | |
| 576 | + session_id: "ID da Sessão:" | |
| 577 | + revoke: "Revogar" | |
| 578 | + revoke_confirm: "Tem certeza de que deseja revogar a sessão para \"{device}\"?\n\nEste dispositivo será desconectado imediatamente." | |
| 579 | + revoke_success: "Sessão revogada com sucesso!" | |
| 580 | + revoke_error: "Erro ao revogar a sessão" | |
| 581 | + no_sessions: "Nenhuma sessão ativa" | |
| 582 | + no_sessions_desc: "Você não tem sessões ativas no momento" | |
| 583 | + danger_zone: "Zona de perigo" | |
| 584 | + danger_zone_desc: "Desconecte todos os outros dispositivos em uma única ação (sua sessão atual permanecerá ativa)." | |
| 585 | + revoke_all: "Revogar todas as outras sessões" | |
| 586 | + revoke_all_confirm: "⚠️ ATENÇÃO\n\nTem certeza de que deseja revogar TODAS as suas outras sessões?\n\nTodos os seus outros dispositivos serão desconectados. Apenas sua sessão atual permanecerá ativa." | |
| 587 | + revoke_all_success: "Todas as outras sessões foram revogadas!" | |
| 588 | + revoke_all_error: "Erro ao revogar. Algumas sessões podem não ter sido revogadas." | |
| 589 | + about_title: "Informações sobre sessões" | |
| 590 | + about_desc: "Uma sessão é criada toda vez que você acessa de um dispositivo ou navegador diferente. Revogar uma sessão desconecta imediatamente esse dispositivo. Sua sessão atual não pode ser revogada nesta página (saia para fazer isso)." | |
| 591 | + back_to_settings: "Voltar às configurações" | |
| 592 | + | |
| 593 | +# Menu de navegação (navbar) | |
| 594 | +nav: | |
| 595 | + home: "Home" | |
| 596 | + settings: "Configurações" | |
| 597 | + admin: "Administração" | |
| 598 | + | |
| 599 | +# Menu do usuário | |
| 600 | +user_menu: | |
| 601 | + profile: "Perfil" | |
| 602 | + logout: "Sair" | |
| 603 | + settings: "Configurações" | |
| 604 | + | |
| 605 | +# Organizações | |
| 606 | +organisations: | |
| 607 | + list: | |
| 608 | + title: "Gerenciamento de Organizações" | |
| 609 | + create: "Criar uma organização" | |
| 610 | + search: "Buscar" | |
| 611 | + search_placeholder: "Nome ou descrição..." | |
| 612 | + no_organisations: "Nenhuma organização encontrada" | |
| 613 | + results_summary: "Resultados" | |
| 614 | + manage_members: "Gerenciar membros" | |
| 615 | + table: | |
| 616 | + name: "Nome" | |
| 617 | + description: "Descrição" | |
| 618 | + members: "Membros" | |
| 619 | + created_at: "Criada em" | |
| 620 | + actions: "Ações" | |
| 621 | + create: | |
| 622 | + title: "Criar uma organização" | |
| 623 | + submit: "Criar" | |
| 624 | + success: "Organização criada com sucesso" | |
| 625 | + error: | |
| 626 | + already_exists: "A organização '{gid}' já existe" | |
| 627 | + invalid_gid: "Formato de gid inválido: {gid}" | |
| 628 | + generic: "Ocorreu um erro ao criar a organização" | |
| 629 | + edit: | |
| 630 | + title: "Editar a organização" | |
| 631 | + submit: "Salvar" | |
| 632 | + update: | |
| 633 | + success: "Organização atualizada com sucesso" | |
| 634 | + delete: | |
| 635 | + success: "Organização excluída com sucesso" | |
| 636 | + confirm: "Tem certeza de que deseja excluir esta organização? Esta ação é irreversível." | |
| 637 | + form: | |
| 638 | + name_label: "Nome" | |
| 639 | + name_placeholder: "Nome da organização" | |
| 640 | + description_label: "Descrição" | |
| 641 | + description_placeholder: "Descrição da organização (opcional)" | |
| 642 | + gid_label: "Identificador (GID)" | |
| 643 | + gid_placeholder: "identificador-unico" | |
| 644 | + gid_help: "Identificador único (letras, números, sublinhados, máx. 64 caracteres)" | |
| 645 | + members: | |
| 646 | + title: "Membros da organização" | |
| 647 | + add_member: "Adicionar um membro" | |
| 648 | + search_user_label: "Buscar um usuário" | |
| 649 | + search_user_placeholder: "Nome de usuário ou email..." | |
| 650 | + no_members: "Nenhum membro nesta organização" | |
| 651 | + back_to_list: "Voltar à lista" | |
| 652 | + table: | |
| 653 | + username: "Nome de usuário" | |
| 654 | + role: "Função" | |
| 655 | + joined_at: "Ingressou em" | |
| 656 | + actions: "Ações" | |
| 657 | + add_success: "Membro adicionado com sucesso" | |
| 658 | + remove_success: "Membro removido com sucesso" | |
| 659 | + add_error: "Erro ao adicionar o membro" | |
| 660 | + remove_error: "Erro ao remover o membro" | |
| 661 | + user_not_found: "Usuário '{search}' não encontrado" | |
| 662 | + error: | |
| 663 | + organisation_not_found: "Organização '{gid}' não encontrada" | |
| 664 | + already_member: "Este usuário já é membro da organização" | |
| 665 | + | |
| 666 | +# Compartilhamento | |
| 667 | +sharing: | |
| 668 | + modal: | |
| 669 | + title: "Compartilhar um recurso" | |
| 670 | + resource_label: "Recurso" | |
| 671 | + resource_type_label: "Tipo" | |
| 672 | + expiration_label: "Data de expiração (opcional)" | |
| 673 | + expiration_help: "O compartilhamento expirará automaticamente nesta data" | |
| 674 | + cancel_button: "Cancelar" | |
| 675 | + submit_button: "Compartilhar" | |
| 676 | + close_button: "Fechar" | |
| 677 | + success_message: "Compartilhamento criado com sucesso" | |
| 678 | + error_message: "Erro ao criar o compartilhamento" | |
| 679 | + error: | |
| 680 | + already_exists: "Este compartilhamento já existe para este recurso e esta organização" | |
| 681 | + components: | |
| 682 | + share_button_label: "Compartilhar" | |
| 683 | + permissions_label: "Permissões" | |
| 684 | + permissions_help: "Escolha as permissões concedidas para este compartilhamento" | |
| 685 | + share_with_label: "Compartilhar com" | |
| 686 | + user_tab_label: "Usuário" | |
| 687 | + organisation_tab_label: "Organização" | |
| 688 | + select_user_label: "Selecionar um usuário" | |
| 689 | + search_user_placeholder: "Buscar um usuário..." | |
| 690 | + select_organisation_label: "Selecionar uma organização" | |
| 691 | + select_organisation_placeholder: "Escolha uma organização..." | |
| 692 | + no_organisations_message: "Nenhuma organização disponível" | |
| 693 | + permissions: | |
| 694 | + read_only: "Somente leitura" | |
| 695 | + read_only_value: "1" | |
| 696 | + read_only_description: "Pode apenas consultar o recurso" | |
| 697 | + read_write: "Leitura + Escrita" | |
| 698 | + read_write_value: "3" | |
| 699 | + read_write_description: "Pode consultar e modificar o recurso" | |
| 700 | + all: "Todas as permissões" | |
| 701 | + all_value: "15" | |
| 702 | + all_description: "Pode consultar, modificar, excluir e compartilhar" |
M
crates/rustwarden-ui/src/dto/admin_dto.rs
+21
-0
@@ -37,9 +37,13 @@ pub struct AuditLogStats {
| 37 | 37 | /// Logs count by level |
| 38 | 38 | #[derive(Debug, Clone, Serialize, Deserialize)] |
| 39 | 39 | pub struct LogsByLevel { |
| 40 | + /// Number of DEBUG-level log entries. | |
| 40 | 41 | pub debug: u64, |
| 42 | + /// Number of INFO-level log entries. | |
| 41 | 43 | pub info: u64, |
| 44 | + /// Number of WARN-level log entries. | |
| 42 | 45 | pub warn: u64, |
| 46 | + /// Number of ERROR-level log entries. | |
| 43 | 47 | pub error: u64, |
| 44 | 48 | } |
| 45 | 49 |
@@ -215,26 +219,43 @@ pub struct CreateUserResponse {
| 215 | 219 | #[derive(Debug, Deserialize, Serialize)] |
| 216 | 220 | pub struct UpdateAppSettingsRequest { |
| 217 | 221 | // General settings |
| 222 | + /// Application display name. | |
| 218 | 223 | pub app_name: Option<String>, |
| 224 | + /// Short description of the application. | |
| 219 | 225 | pub app_description: Option<String>, |
| 226 | + /// Public domain of the application. | |
| 220 | 227 | pub app_domain: Option<String>, |
| 228 | + /// Whether public self-registration is allowed. | |
| 221 | 229 | pub allow_registration: Option<bool>, |
| 230 | + /// Whether email validation is required on registration. | |
| 222 | 231 | pub validation_email_required: Option<bool>, |
| 232 | + /// Alias for `validation_email_required` (legacy French field name). | |
| 223 | 233 | pub validation_email_requise: Option<bool>, |
| 224 | 234 | |
| 225 | 235 | // Security - Password complexity |
| 236 | + /// Minimum password length. | |
| 226 | 237 | pub password_min_length: Option<u32>, |
| 238 | + /// Whether passwords must contain at least one uppercase letter. | |
| 227 | 239 | pub password_require_uppercase: Option<bool>, |
| 240 | + /// Whether passwords must contain at least one lowercase letter. | |
| 228 | 241 | pub password_require_lowercase: Option<bool>, |
| 242 | + /// Whether passwords must contain at least one digit. | |
| 229 | 243 | pub password_require_digits: Option<bool>, |
| 244 | + /// Whether passwords must contain at least one special character. | |
| 230 | 245 | pub password_require_special: Option<bool>, |
| 246 | + /// Whether periodic password expiration is enabled. | |
| 231 | 247 | pub password_expiration_enabled: Option<bool>, |
| 248 | + /// Number of days before a password expires. | |
| 232 | 249 | pub password_expiration_days: Option<u32>, |
| 250 | + /// Whether an alert email is sent before password expiry. | |
| 233 | 251 | pub password_expiration_alert_enabled: Option<bool>, |
| 252 | + /// Number of days before expiry at which the alert is sent. | |
| 234 | 253 | pub password_expiration_alert_days: Option<u32>, |
| 235 | 254 | |
| 236 | 255 | // Audit logging |
| 256 | + /// Minimum severity level for audit log entries. | |
| 237 | 257 | pub audit_log_level: Option<String>, |
| 258 | + /// Number of days to retain audit log entries. | |
| 238 | 259 | pub audit_log_retention_days: Option<u32>, |
| 239 | 260 | } |
| 240 | 261 |
M
crates/rustwarden-ui/src/dto/auth_dto.rs
+12
-0
@@ -17,10 +17,15 @@ pub struct LoginRequest {
| 17 | 17 | /// User info in login response |
| 18 | 18 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 19 | 19 | pub struct UserInfo { |
| 20 | + /// User unique identifier. | |
| 20 | 21 | pub id: uuid::Uuid, |
| 22 | + /// User login name. | |
| 21 | 23 | pub username: String, |
| 24 | + /// User email address. | |
| 22 | 25 | pub email: String, |
| 26 | + /// Roles assigned to the user. | |
| 23 | 27 | pub roles: Vec<String>, |
| 28 | + /// Whether the user must change their password on next login. | |
| 24 | 29 | pub must_change_password: Option<bool>, |
| 25 | 30 | } |
| 26 | 31 |
@@ -83,12 +88,19 @@ pub struct UserInfoResponse {
| 83 | 88 | /// Current user response (for GET /auth/me) |
| 84 | 89 | #[derive(Debug, Serialize, Deserialize, Clone)] |
| 85 | 90 | pub struct CurrentUserResponse { |
| 91 | + /// User unique identifier. | |
| 86 | 92 | pub id: uuid::Uuid, |
| 93 | + /// User login name. | |
| 87 | 94 | pub username: String, |
| 95 | + /// User email address. | |
| 88 | 96 | pub email: String, |
| 97 | + /// List of roles assigned to the user. | |
| 89 | 98 | pub roles: Vec<String>, |
| 99 | + /// Timestamp when the account was created. | |
| 90 | 100 | pub created_at: chrono::DateTime<chrono::Utc>, |
| 101 | + /// Timestamp of the last account update. | |
| 91 | 102 | pub updated_at: chrono::DateTime<chrono::Utc>, |
| 103 | + /// Whether the user must change their password on next login. | |
| 92 | 104 | pub must_change_password: bool, |
| 93 | 105 | } |
| 94 | 106 |
M
crates/rustwarden-ui/src/dto/email_dto.rs
+24
-1
@@ -9,14 +9,19 @@ use serde::{Deserialize, Serialize};
| 9 | 9 | use uuid::Uuid; |
| 10 | 10 | use validator::Validate; |
| 11 | 11 | |
| 12 | -/// États de suivi pour les emails critiques (traçabilité SMTP) | |
| 12 | +/// Tracking states for critical emails (SMTP traceability) | |
| 13 | 13 | #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] |
| 14 | 14 | #[serde(rename_all = "snake_case")] |
| 15 | 15 | pub enum EmailDeliveryState { |
| 16 | + /// Email has been queued but not yet sent. | |
| 16 | 17 | Queued, |
| 18 | + /// Email is currently being transmitted to the SMTP server. | |
| 17 | 19 | Sending, |
| 20 | + /// SMTP server accepted the message for delivery. | |
| 18 | 21 | Accepted, |
| 22 | + /// SMTP server rejected the message. | |
| 19 | 23 | Rejected, |
| 24 | + /// Email was not delivered before its expiry deadline. | |
| 20 | 25 | Expired, |
| 21 | 26 | /// Final delivery confirmed (via DSN) |
| 22 | 27 | Delivered, |
@@ -25,6 +30,7 @@ pub enum EmailDeliveryState {
| 25 | 30 | } |
| 26 | 31 | |
| 27 | 32 | impl EmailDeliveryState { |
| 33 | + /// Returns a static string representation of this delivery state. | |
| 28 | 34 | pub fn as_str(self) -> &'static str { |
| 29 | 35 | match self { |
| 30 | 36 | EmailDeliveryState::Queued => "queued", |
@@ -37,6 +43,7 @@ impl EmailDeliveryState {
| 37 | 43 | } |
| 38 | 44 | } |
| 39 | 45 | |
| 46 | + /// Parses a string slice into an `EmailDeliveryState`, defaulting to `Queued` for unknown values. | |
| 40 | 47 | pub fn parse(value: &str) -> Self { |
| 41 | 48 | match value { |
| 42 | 49 | "queued" => EmailDeliveryState::Queued, |
@@ -203,23 +210,39 @@ pub struct RetryEmailResponse {
| 203 | 210 | /// Delivery status response for critical emails (admin) |
| 204 | 211 | #[derive(Debug, Serialize)] |
| 205 | 212 | pub struct EmailDeliveryStatusResponse { |
| 213 | + /// Unique identifier of the email queue entry. | |
| 206 | 214 | pub id: Uuid, |
| 215 | + /// Type of email (e.g. `password_reset`, `email_validation`). | |
| 207 | 216 | pub email_type: String, |
| 217 | + /// Recipient email address. | |
| 208 | 218 | pub recipient: String, |
| 219 | + /// Template name used to render the email. | |
| 209 | 220 | pub template: String, |
| 221 | + /// Current delivery state. | |
| 210 | 222 | pub state: EmailDeliveryState, |
| 223 | + /// SMTP response code from the last delivery attempt. | |
| 211 | 224 | pub smtp_code: Option<String>, |
| 225 | + /// Full SMTP response message from the last delivery attempt. | |
| 212 | 226 | pub smtp_response: Option<String>, |
| 227 | + /// Number of delivery attempts made so far. | |
| 213 | 228 | pub attempt_count: i32, |
| 229 | + /// Timestamp of the most recent delivery attempt. | |
| 214 | 230 | pub last_attempt_at: Option<chrono::DateTime<chrono::Utc>>, |
| 231 | + /// Timestamp of the last state change. | |
| 215 | 232 | pub last_state_at: chrono::DateTime<chrono::Utc>, |
| 233 | + /// Optional correlation ID linking to the originating request. | |
| 216 | 234 | pub correlation_id: Option<Uuid>, |
| 235 | + /// Timestamp when the queue entry was created. | |
| 217 | 236 | pub created_at: chrono::DateTime<chrono::Utc>, |
| 237 | + /// Timestamp of the last update to this entry. | |
| 218 | 238 | pub updated_at: chrono::DateTime<chrono::Utc>, |
| 219 | 239 | } |
| 220 | 240 | |
| 241 | +/// Paginated list of email delivery status entries. | |
| 221 | 242 | #[derive(Debug, Serialize)] |
| 222 | 243 | pub struct EmailDeliveryStatusListResponse { |
| 244 | + /// Page of delivery status entries. | |
| 223 | 245 | pub items: Vec<EmailDeliveryStatusResponse>, |
| 246 | + /// Cursor for retrieving the next page, if any. | |
| 224 | 247 | pub next_cursor: Option<String>, |
| 225 | 248 | } |
M
crates/rustwarden-ui/src/dto/mod.rs
+11
-1
@@ -1,15 +1,25 @@
| 1 | -// Data Transfer Objects (DTOs) | |
| 1 | +//! Data Transfer Objects (DTOs) | |
| 2 | 2 | |
| 3 | +/// DTOs for admin endpoints (users, roles, settings). | |
| 3 | 4 | pub mod admin_dto; |
| 5 | +/// DTOs for authentication endpoints (login, refresh, me). | |
| 4 | 6 | pub mod auth_dto; |
| 7 | +/// DTOs for breadcrumb navigation rendering. | |
| 5 | 8 | pub mod breadcrumb_dto; |
| 6 | 9 | |
| 10 | +/// DTOs for email delivery status and password-reset flows. | |
| 7 | 11 | pub mod email_dto; |
| 12 | +/// DTOs for the home/dashboard page. | |
| 8 | 13 | pub mod home_dto; |
| 14 | +/// DTOs for navigation menu rendering. | |
| 9 | 15 | pub mod navigation_dto; |
| 16 | +/// DTOs for user settings and preferences. | |
| 10 | 17 | pub mod settings_dto; |
| 18 | +/// DTOs for sidebar rendering. | |
| 11 | 19 | pub mod sidebar_dto; |
| 20 | +/// DTOs for user management endpoints. | |
| 12 | 21 | pub mod user_dto; |
| 22 | +/// DTO for the user profile page. | |
| 13 | 23 | pub mod user_profile_dto; |
| 14 | 24 | |
| 15 | 25 | pub use admin_dto::{ |
M
crates/rustwarden-ui/src/dto/navigation_dto.rs
+16
-18
@@ -192,9 +192,9 @@ impl NavigationDTO {
| 192 | 192 | impl Default for NavigationDTO { |
| 193 | 193 | fn default() -> Self { |
| 194 | 194 | Self { |
| 195 | - items: vec![ | |
| 196 | - NavigationItemDTO::regular("home", "Accueil", "home", "/home"), | |
| 197 | - ], | |
| 195 | + items: vec![NavigationItemDTO::regular( | |
| 196 | + "home", "Accueil", "home", "/home", | |
| 197 | + )], | |
| 198 | 198 | secondary_items: vec![], |
| 199 | 199 | has_pending_password_change: false, |
| 200 | 200 | } |
@@ -248,21 +248,19 @@ impl NavigationDTO {
| 248 | 248 | let home_label = i18n_service.translate("nav.home", locale_str).await; |
| 249 | 249 | let admin_label = i18n_service.translate("nav.admin", locale_str).await; |
| 250 | 250 | |
| 251 | - let mut items = vec![ | |
| 252 | - NavigationItemDTO { | |
| 253 | - id: "home".to_string(), | |
| 254 | - label: home_label, | |
| 255 | - icon: "home".to_string(), | |
| 256 | - url: "/home".to_string(), | |
| 257 | - active: false, | |
| 258 | - enabled: true, | |
| 259 | - badge: String::new(), | |
| 260 | - has_badge: false, | |
| 261 | - disabled_message: String::new(), | |
| 262 | - has_disabled_message: false, | |
| 263 | - order: 0, | |
| 264 | - }, | |
| 265 | - ]; | |
| 251 | + let mut items = vec![NavigationItemDTO { | |
| 252 | + id: "home".to_string(), | |
| 253 | + label: home_label, | |
| 254 | + icon: "home".to_string(), | |
| 255 | + url: "/home".to_string(), | |
| 256 | + active: false, | |
| 257 | + enabled: true, | |
| 258 | + badge: String::new(), | |
| 259 | + has_badge: false, | |
| 260 | + disabled_message: String::new(), | |
| 261 | + has_disabled_message: false, | |
| 262 | + order: 0, | |
| 263 | + }]; | |
| 266 | 264 | |
| 267 | 265 | if roles.contains(&"admin".to_string()) { |
| 268 | 266 | items.push(NavigationItemDTO { |
M
crates/rustwarden-ui/src/extractors.rs
+21
-17
@@ -53,8 +53,8 @@ where
| 53 | 53 | .await |
| 54 | 54 | .map_err(|_| FormRejection::BodyReadError)?; |
| 55 | 55 | |
| 56 | - let body_str = std::str::from_utf8(&body_bytes) | |
| 57 | - .map_err(|_| FormRejection::InvalidEncoding)?; | |
| 56 | + let body_str = | |
| 57 | + std::str::from_utf8(&body_bytes).map_err(|_| FormRejection::InvalidEncoding)?; | |
| 58 | 58 | |
| 59 | 59 | // Parse URL-encoded data and handle duplicates |
| 60 | 60 | let parsed_map = parse_urlencoded_with_duplicates(body_str)?; |
@@ -72,7 +72,9 @@ where
| 72 | 72 | } |
| 73 | 73 | |
| 74 | 74 | /// Parse URL-encoded string and handle duplicate keys by collecting them into vectors |
| 75 | -fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde_json::Value>, FormRejection> { | |
| 75 | +fn parse_urlencoded_with_duplicates( | |
| 76 | + input: &str, | |
| 77 | +) -> Result<HashMap<String, serde_json::Value>, FormRejection> { | |
| 76 | 78 | let mut map: HashMap<String, Vec<String>> = HashMap::new(); |
| 77 | 79 | |
| 78 | 80 | for pair in input.split('&') { |
@@ -82,14 +84,13 @@ fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde
| 82 | 84 | |
| 83 | 85 | let (key, value) = if let Some(eq_pos) = pair.find('=') { |
| 84 | 86 | // Simple URL decoding - replace %XX with actual characters |
| 85 | - let key = percent_decode(&pair[..eq_pos]) | |
| 86 | - .map_err(|_| FormRejection::InvalidEncoding)?; | |
| 87 | - let value = percent_decode(&pair[eq_pos + 1..]) | |
| 88 | - .map_err(|_| FormRejection::InvalidEncoding)?; | |
| 87 | + let key = | |
| 88 | + percent_decode(&pair[..eq_pos]).map_err(|_| FormRejection::InvalidEncoding)?; | |
| 89 | + let value = | |
| 90 | + percent_decode(&pair[eq_pos + 1..]).map_err(|_| FormRejection::InvalidEncoding)?; | |
| 89 | 91 | (key, value) |
| 90 | 92 | } else { |
| 91 | - let key = percent_decode(pair) | |
| 92 | - .map_err(|_| FormRejection::InvalidEncoding)?; | |
| 93 | + let key = percent_decode(pair).map_err(|_| FormRejection::InvalidEncoding)?; | |
| 93 | 94 | (key, String::new()) |
| 94 | 95 | }; |
| 95 | 96 |
@@ -102,7 +103,8 @@ fn parse_urlencoded_with_duplicates(input: &str) -> Result<HashMap<String, serde
| 102 | 103 | if values.len() == 1 { |
| 103 | 104 | result.insert(key, serde_json::Value::String(values[0].clone())); |
| 104 | 105 | } else { |
| 105 | - let array: Vec<serde_json::Value> = values.into_iter().map(serde_json::Value::String).collect(); | |
| 106 | + let array: Vec<serde_json::Value> = | |
| 107 | + values.into_iter().map(serde_json::Value::String).collect(); | |
| 106 | 108 | result.insert(key, serde_json::Value::Array(array)); |
| 107 | 109 | } |
| 108 | 110 | } |
@@ -116,7 +118,7 @@ fn percent_decode(input: &str) -> Result<String, ()> {
| 116 | 118 | let mut result = Vec::new(); |
| 117 | 119 | let bytes = input.as_bytes(); |
| 118 | 120 | let mut i = 0; |
| 119 | - | |
| 121 | + | |
| 120 | 122 | while i < bytes.len() { |
| 121 | 123 | if bytes[i] == b'%' { |
| 122 | 124 | if i + 2 >= bytes.len() { |
@@ -137,16 +139,20 @@ fn percent_decode(input: &str) -> Result<String, ()> {
| 137 | 139 | i += 1; |
| 138 | 140 | } |
| 139 | 141 | } |
| 140 | - | |
| 142 | + | |
| 141 | 143 | String::from_utf8(result).map_err(|_| ()) |
| 142 | 144 | } |
| 143 | 145 | |
| 144 | 146 | /// Rejection type for form extraction errors |
| 145 | 147 | #[derive(Debug)] |
| 146 | 148 | pub enum FormRejection { |
| 149 | + /// Content-Type header is not `application/x-www-form-urlencoded`. | |
| 147 | 150 | InvalidContentType, |
| 151 | + /// Failed to read the request body bytes. | |
| 148 | 152 | BodyReadError, |
| 153 | + /// Body bytes are not valid UTF-8 or percent-encoding is malformed. | |
| 149 | 154 | InvalidEncoding, |
| 155 | + /// Deserialization of the form data into the target type failed. | |
| 150 | 156 | DeserializeError(String), |
| 151 | 157 | } |
| 152 | 158 |
@@ -161,10 +167,9 @@ impl IntoResponse for FormRejection {
| 161 | 167 | StatusCode::BAD_REQUEST, |
| 162 | 168 | "Failed to read request body".to_string(), |
| 163 | 169 | ), |
| 164 | - FormRejection::InvalidEncoding => ( | |
| 165 | - StatusCode::BAD_REQUEST, | |
| 166 | - "Invalid URL encoding".to_string(), | |
| 167 | - ), | |
| 170 | + FormRejection::InvalidEncoding => { | |
| 171 | + (StatusCode::BAD_REQUEST, "Invalid URL encoding".to_string()) | |
| 172 | + } | |
| 168 | 173 | FormRejection::DeserializeError(msg) => ( |
| 169 | 174 | StatusCode::BAD_REQUEST, |
| 170 | 175 | format!("Failed to deserialize form: {}", msg), |
@@ -174,4 +179,3 @@ impl IntoResponse for FormRejection {
| 174 | 179 | (status, message).into_response() |
| 175 | 180 | } |
| 176 | 181 | } |
| 177 | - |
M
crates/rustwarden-ui/src/handlers/admin_handler.rs
+1140
-315
@@ -12,17 +12,17 @@ use serde::Deserialize;
| 12 | 12 | use std::sync::Arc; |
| 13 | 13 | use tower_sessions::Session; |
| 14 | 14 | |
| 15 | -use rustwarden_core::middleware::csrf; | |
| 15 | +use crate::dto::NavigationDTO; | |
| 16 | +use crate::template_helpers::build_base_context_with_claims; | |
| 17 | +use axum::extract::Extension; | |
| 16 | 18 | use rustwarden_core::auth::jwt::Claims; |
| 17 | 19 | use rustwarden_core::error::AppError; |
| 18 | 20 | use rustwarden_core::i18n::extractor::Locale; |
| 19 | 21 | use rustwarden_core::i18n::service::I18nService; |
| 20 | 22 | use rustwarden_core::impl_template_response; |
| 23 | +use rustwarden_core::middleware::csrf; | |
| 21 | 24 | use rustwarden_core::models::{role, user_role}; |
| 22 | 25 | use rustwarden_core::services::app_settings_service::AppSettingsService; |
| 23 | -use crate::template_helpers::build_base_context_with_claims; | |
| 24 | -use crate::dto::NavigationDTO; | |
| 25 | -use axum::extract::Extension; | |
| 26 | 26 | use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; |
| 27 | 27 | |
| 28 | 28 | #[derive(Debug, Clone)] |
@@ -82,9 +82,13 @@ async fn load_app_domain_and_description(
| 82 | 82 | /// Inline DTO: Create user request from admin form |
| 83 | 83 | #[derive(Debug, Clone, serde::Deserialize)] |
| 84 | 84 | pub struct CreateUserRequest { |
| 85 | + /// Login name for the new user. | |
| 85 | 86 | pub username: String, |
| 87 | + /// Email address for the new user. | |
| 86 | 88 | pub email: String, |
| 89 | + /// Initial password for the new user. | |
| 87 | 90 | pub password: String, |
| 91 | + /// Roles to assign to the new user. | |
| 88 | 92 | #[serde(default)] |
| 89 | 93 | #[serde(deserialize_with = "deserialize_string_or_vec")] |
| 90 | 94 | pub roles: Vec<String>, |
@@ -93,13 +97,16 @@ pub struct CreateUserRequest {
| 93 | 97 | /// Inline DTO: Reset password request |
| 94 | 98 | #[derive(Debug, Clone, serde::Deserialize)] |
| 95 | 99 | pub struct ResetPasswordRequest { |
| 100 | + /// New password to set, or `None` to generate one automatically. | |
| 96 | 101 | pub new_password: Option<String>, |
| 97 | 102 | } |
| 98 | 103 | |
| 99 | 104 | /// Inline DTO: Reset password response |
| 100 | 105 | #[derive(Debug, Clone, serde::Serialize)] |
| 101 | 106 | pub struct ResetPasswordResponse { |
| 107 | + /// The new password (shown once when auto-generated). | |
| 102 | 108 | pub password: Option<String>, |
| 109 | + /// Human-readable confirmation message. | |
| 103 | 110 | pub message: String, |
| 104 | 111 | } |
| 105 | 112 |
@@ -254,7 +261,9 @@ pub async fn admin_dashboard(
| 254 | 261 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 255 | 262 | |
| 256 | 263 | let template = AdminDashboardTemplate { |
| 257 | - page_title: i18n_service.translate("admin.dashboard.title", locale_str).await, | |
| 264 | + page_title: i18n_service | |
| 265 | + .translate("admin.dashboard.title", locale_str) | |
| 266 | + .await, | |
| 258 | 267 | csrf_token, |
| 259 | 268 | app_name: base_context.app_name, |
| 260 | 269 | app_version: base_context.app_version, |
@@ -279,33 +288,79 @@ pub async fn admin_dashboard(
| 279 | 288 | audit_warn_count: 0, |
| 280 | 289 | audit_error_count: 0, |
| 281 | 290 | total_roles, |
| 282 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 291 | + user_menu_profile: i18n_service | |
| 292 | + .translate("user_menu.profile", locale_str) | |
| 293 | + .await, | |
| 283 | 294 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 284 | 295 | |
| 285 | 296 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 286 | 297 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 287 | - heading: i18n_service.translate("admin.dashboard.heading", locale_str).await, | |
| 288 | - users_card_title: i18n_service.translate("admin.dashboard.users_card_title", locale_str).await, | |
| 289 | - users_card_subtitle: i18n_service.translate("admin.dashboard.users_card_subtitle", locale_str).await, | |
| 290 | - access_button: i18n_service.translate("admin.dashboard.access_button", locale_str).await, | |
| 291 | - sessions_card_title: i18n_service.translate("admin.dashboard.sessions_card_title", locale_str).await, | |
| 292 | - sessions_card_subtitle: i18n_service.translate("admin.dashboard.sessions_card_subtitle", locale_str).await, | |
| 293 | - admins_card_title: i18n_service.translate("admin.dashboard.admins_card_title", locale_str).await, | |
| 294 | - admins_card_subtitle: i18n_service.translate("admin.dashboard.admins_card_subtitle", locale_str).await, | |
| 295 | - last_activity_card_title: i18n_service.translate("admin.dashboard.last_activity_card_title", locale_str).await, | |
| 296 | - last_activity_card_subtitle: i18n_service.translate("admin.dashboard.last_activity_card_subtitle", locale_str).await, | |
| 297 | - audit_section_title: i18n_service.translate("admin.dashboard.audit_section_title", locale_str).await, | |
| 298 | - audit_total_logs_label: i18n_service.translate("admin.dashboard.audit_total_logs", locale_str).await, | |
| 299 | - audit_last_24h: i18n_service.translate("admin.dashboard.audit_last_24h", locale_str).await, | |
| 300 | - audit_by_level: i18n_service.translate("admin.dashboard.audit_by_level", locale_str).await, | |
| 301 | - chart_label: i18n_service.translate("admin.dashboard.chart_label", locale_str).await, | |
| 302 | - roles_card_title: i18n_service.translate("admin.dashboard.roles_card_title", locale_str).await, | |
| 303 | - roles_card_desc: i18n_service.translate("admin.dashboard.roles_card_desc", locale_str).await, | |
| 304 | - roles_stat_title: i18n_service.translate("admin.dashboard.roles_stat_title", locale_str).await, | |
| 305 | - settings_card_title: i18n_service.translate("admin.dashboard.settings_card_title", locale_str).await, | |
| 306 | - settings_card_desc: i18n_service.translate("admin.dashboard.settings_card_desc", locale_str).await, | |
| 307 | - apps_card_title: i18n_service.translate("admin.dashboard.apps_card_title", locale_str).await, | |
| 308 | - apps_card_desc: i18n_service.translate("admin.dashboard.apps_card_desc", locale_str).await, | |
| 298 | + heading: i18n_service | |
| 299 | + .translate("admin.dashboard.heading", locale_str) | |
| 300 | + .await, | |
| 301 | + users_card_title: i18n_service | |
| 302 | + .translate("admin.dashboard.users_card_title", locale_str) | |
| 303 | + .await, | |
| 304 | + users_card_subtitle: i18n_service | |
| 305 | + .translate("admin.dashboard.users_card_subtitle", locale_str) | |
| 306 | + .await, | |
| 307 | + access_button: i18n_service | |
| 308 | + .translate("admin.dashboard.access_button", locale_str) | |
| 309 | + .await, | |
| 310 | + sessions_card_title: i18n_service | |
| 311 | + .translate("admin.dashboard.sessions_card_title", locale_str) | |
| 312 | + .await, | |
| 313 | + sessions_card_subtitle: i18n_service | |
| 314 | + .translate("admin.dashboard.sessions_card_subtitle", locale_str) | |
| 315 | + .await, | |
| 316 | + admins_card_title: i18n_service | |
| 317 | + .translate("admin.dashboard.admins_card_title", locale_str) | |
| 318 | + .await, | |
| 319 | + admins_card_subtitle: i18n_service | |
| 320 | + .translate("admin.dashboard.admins_card_subtitle", locale_str) | |
| 321 | + .await, | |
| 322 | + last_activity_card_title: i18n_service | |
| 323 | + .translate("admin.dashboard.last_activity_card_title", locale_str) | |
| 324 | + .await, | |
| 325 | + last_activity_card_subtitle: i18n_service | |
| 326 | + .translate("admin.dashboard.last_activity_card_subtitle", locale_str) | |
| 327 | + .await, | |
| 328 | + audit_section_title: i18n_service | |
| 329 | + .translate("admin.dashboard.audit_section_title", locale_str) | |
| 330 | + .await, | |
| 331 | + audit_total_logs_label: i18n_service | |
| 332 | + .translate("admin.dashboard.audit_total_logs", locale_str) | |
| 333 | + .await, | |
| 334 | + audit_last_24h: i18n_service | |
| 335 | + .translate("admin.dashboard.audit_last_24h", locale_str) | |
| 336 | + .await, | |
| 337 | + audit_by_level: i18n_service | |
| 338 | + .translate("admin.dashboard.audit_by_level", locale_str) | |
| 339 | + .await, | |
| 340 | + chart_label: i18n_service | |
| 341 | + .translate("admin.dashboard.chart_label", locale_str) | |
| 342 | + .await, | |
| 343 | + roles_card_title: i18n_service | |
| 344 | + .translate("admin.dashboard.roles_card_title", locale_str) | |
| 345 | + .await, | |
| 346 | + roles_card_desc: i18n_service | |
| 347 | + .translate("admin.dashboard.roles_card_desc", locale_str) | |
| 348 | + .await, | |
| 349 | + roles_stat_title: i18n_service | |
| 350 | + .translate("admin.dashboard.roles_stat_title", locale_str) | |
| 351 | + .await, | |
| 352 | + settings_card_title: i18n_service | |
| 353 | + .translate("admin.dashboard.settings_card_title", locale_str) | |
| 354 | + .await, | |
| 355 | + settings_card_desc: i18n_service | |
| 356 | + .translate("admin.dashboard.settings_card_desc", locale_str) | |
| 357 | + .await, | |
| 358 | + apps_card_title: i18n_service | |
| 359 | + .translate("admin.dashboard.apps_card_title", locale_str) | |
| 360 | + .await, | |
| 361 | + apps_card_desc: i18n_service | |
| 362 | + .translate("admin.dashboard.apps_card_desc", locale_str) | |
| 363 | + .await, | |
| 309 | 364 | }; |
| 310 | 365 | |
| 311 | 366 | Ok(template) |
@@ -314,9 +369,13 @@ pub async fn admin_dashboard(
| 314 | 369 | /// Query parameters for user list endpoint |
| 315 | 370 | #[derive(Debug, Deserialize)] |
| 316 | 371 | pub struct UserListQuery { |
| 372 | + /// Page number (1-based). | |
| 317 | 373 | pub page: Option<u64>, |
| 374 | + /// Number of results per page. | |
| 318 | 375 | pub per_page: Option<u64>, |
| 376 | + /// Optional full-text search filter. | |
| 319 | 377 | pub search: Option<String>, |
| 378 | + /// Filter by role name. | |
| 320 | 379 | pub role_filter: Option<String>, |
| 321 | 380 | } |
| 322 | 381 |
@@ -347,6 +406,7 @@ struct UserInfoDisplay {
| 347 | 406 | last_login_at: String, |
| 348 | 407 | has_last_login_ip: bool, |
| 349 | 408 | last_login_ip: String, |
| 409 | + totp_enabled: bool, | |
| 350 | 410 | } |
| 351 | 411 | |
| 352 | 412 | /// User list template |
@@ -541,16 +601,25 @@ pub async fn list_users(
| 541 | 601 | .filter(user_role::Column::RoleId.eq(role_model.id)) |
| 542 | 602 | .all(&db) |
| 543 | 603 | .await?; |
| 544 | - let user_ids: Vec<uuid::Uuid> = user_roles_for_filter.iter().map(|ur| ur.user_id).collect(); | |
| 604 | + let user_ids: Vec<uuid::Uuid> = | |
| 605 | + user_roles_for_filter.iter().map(|ur| ur.user_id).collect(); | |
| 545 | 606 | all_users.retain(|u| user_ids.contains(&u.id)); |
| 546 | 607 | } |
| 547 | 608 | } |
| 548 | 609 | } |
| 549 | 610 | |
| 550 | 611 | let total = all_users.len() as u64; |
| 551 | - let total_pages = if total == 0 { 1 } else { total.div_ceil(per_page) }; | |
| 612 | + let total_pages = if total == 0 { | |
| 613 | + 1 | |
| 614 | + } else { | |
| 615 | + total.div_ceil(per_page) | |
| 616 | + }; | |
| 552 | 617 | let start = ((page - 1) * per_page) as usize; |
| 553 | - let page_users: Vec<_> = all_users.into_iter().skip(start).take(per_page as usize).collect(); | |
| 618 | + let page_users: Vec<_> = all_users | |
| 619 | + .into_iter() | |
| 620 | + .skip(start) | |
| 621 | + .take(per_page as usize) | |
| 622 | + .collect(); | |
| 554 | 623 | |
| 555 | 624 | let csrf_token = csrf::get_or_create_csrf_token(&session).await?; |
| 556 | 625 | let base_context = build_base_context_with_claims(&session, &db, &claims).await?; |
@@ -583,6 +652,12 @@ pub async fn list_users(
| 583 | 652 | None => (false, String::new()), |
| 584 | 653 | }; |
| 585 | 654 | |
| 655 | + // Check TOTP status | |
| 656 | + let totp_enabled = | |
| 657 | + rustwarden_core::services::totp_service::TotpService::is_totp_enabled(&db, u.id) | |
| 658 | + .await | |
| 659 | + .unwrap_or(false); | |
| 660 | + | |
| 586 | 661 | users_display.push(UserInfoDisplay { |
| 587 | 662 | id: u.id, |
| 588 | 663 | username: u.username, |
@@ -593,6 +668,7 @@ pub async fn list_users(
| 593 | 668 | last_login_at: last_login_str, |
| 594 | 669 | has_last_login_ip: u.last_login_ip.is_some(), |
| 595 | 670 | last_login_ip: u.last_login_ip.unwrap_or_default(), |
| 671 | + totp_enabled, | |
| 596 | 672 | }); |
| 597 | 673 | } |
| 598 | 674 |
@@ -617,7 +693,9 @@ pub async fn list_users(
| 617 | 693 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 618 | 694 | |
| 619 | 695 | let template = UserListTemplate { |
| 620 | - page_title: i18n_service.translate("admin.users_list.page_title", locale_str).await, | |
| 696 | + page_title: i18n_service | |
| 697 | + .translate("admin.users_list.page_title", locale_str) | |
| 698 | + .await, | |
| 621 | 699 | csrf_token, |
| 622 | 700 | app_name: base_context.app_name, |
| 623 | 701 | locale: locale_str.to_string(), |
@@ -642,65 +720,169 @@ pub async fn list_users(
| 642 | 720 | has_role_filter: role_filter.as_ref().is_some_and(|r| !r.is_empty()), |
| 643 | 721 | is_admin_filter: role_filter.as_ref().map(|r| r == "admin").unwrap_or(false), |
| 644 | 722 | is_user_filter: role_filter.as_ref().map(|r| r == "user").unwrap_or(false), |
| 645 | - title: i18n_service.translate("admin.users_list.title", locale_str).await, | |
| 723 | + title: i18n_service | |
| 724 | + .translate("admin.users_list.title", locale_str) | |
| 725 | + .await, | |
| 646 | 726 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 647 | 727 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 648 | 728 | breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await, |
| 649 | - create_user: i18n_service.translate("admin.users_list.create_user", locale_str).await, | |
| 650 | - search_label: i18n_service.translate("admin.users_list.search_label", locale_str).await, | |
| 651 | - search_placeholder: i18n_service.translate("admin.users_list.search_placeholder", locale_str).await, | |
| 652 | - role_filter_label: i18n_service.translate("admin.users_list.role_filter_label", locale_str).await, | |
| 653 | - all_roles: i18n_service.translate("admin.users_list.all_roles", locale_str).await, | |
| 654 | - per_page_label: i18n_service.translate("admin.users_list.per_page_label", locale_str).await, | |
| 655 | - search_button: i18n_service.translate("admin.users_list.search_button", locale_str).await, | |
| 729 | + create_user: i18n_service | |
| 730 | + .translate("admin.users_list.create_user", locale_str) | |
| 731 | + .await, | |
| 732 | + search_label: i18n_service | |
| 733 | + .translate("admin.users_list.search_label", locale_str) | |
| 734 | + .await, | |
| 735 | + search_placeholder: i18n_service | |
| 736 | + .translate("admin.users_list.search_placeholder", locale_str) | |
| 737 | + .await, | |
| 738 | + role_filter_label: i18n_service | |
| 739 | + .translate("admin.users_list.role_filter_label", locale_str) | |
| 740 | + .await, | |
| 741 | + all_roles: i18n_service | |
| 742 | + .translate("admin.users_list.all_roles", locale_str) | |
| 743 | + .await, | |
| 744 | + per_page_label: i18n_service | |
| 745 | + .translate("admin.users_list.per_page_label", locale_str) | |
| 746 | + .await, | |
| 747 | + search_button: i18n_service | |
| 748 | + .translate("admin.users_list.search_button", locale_str) | |
| 749 | + .await, | |
| 656 | 750 | results_summary, |
| 657 | 751 | page_info, |
| 658 | - table_username: i18n_service.translate("admin.users_list.table_username", locale_str).await, | |
| 659 | - table_email: i18n_service.translate("admin.users_list.table_email", locale_str).await, | |
| 660 | - table_roles: i18n_service.translate("admin.users_list.table_roles", locale_str).await, | |
| 661 | - table_created_at: i18n_service.translate("admin.users_list.table_created_at", locale_str).await, | |
| 662 | - table_last_login: i18n_service.translate("admin.users_list.table_last_login", locale_str).await, | |
| 663 | - table_actions: i18n_service.translate("admin.users_list.table_actions", locale_str).await, | |
| 664 | - never: i18n_service.translate("admin.users_list.never", locale_str).await, | |
| 665 | - edit: i18n_service.translate("admin.users_list.edit", locale_str).await, | |
| 666 | - delete: i18n_service.translate("admin.users_list.delete", locale_str).await, | |
| 667 | - reset_password: i18n_service.translate("admin.users_list.reset_password", locale_str).await, | |
| 668 | - no_users: i18n_service.translate("admin.users_list.no_users", locale_str).await, | |
| 669 | - reset_password_title: i18n_service.translate("admin.users_list.reset_password_title", locale_str).await, | |
| 670 | - reset_generate: i18n_service.translate("admin.users_list.reset_generate", locale_str).await, | |
| 671 | - reset_custom: i18n_service.translate("admin.users_list.reset_custom", locale_str).await, | |
| 672 | - reset_email: i18n_service.translate("admin.users_list.reset_email", locale_str).await, | |
| 673 | - reset_new_password_label: i18n_service.translate("admin.users_list.reset_new_password_label", locale_str).await, | |
| 674 | - reset_new_password_placeholder: i18n_service.translate("admin.users_list.reset_new_password_placeholder", locale_str).await, | |
| 675 | - reset_password_requirements: i18n_service.translate("admin.users_list.reset_password_requirements", locale_str).await, | |
| 676 | - reset_invalidate_sessions: i18n_service.translate("admin.users_list.reset_invalidate_sessions", locale_str).await, | |
| 677 | - reset_invalidate_sessions_desc: i18n_service.translate("admin.users_list.reset_invalidate_sessions_desc", locale_str).await, | |
| 678 | - reset_button: i18n_service.translate("admin.users_list.reset_button", locale_str).await, | |
| 679 | - delete_confirm_title: i18n_service.translate("admin.users_list.delete_confirm_title", locale_str).await, | |
| 680 | - delete_confirm_message: i18n_service.translate("admin.users_list.delete_confirm_message", locale_str).await, | |
| 681 | - delete_confirm_username: i18n_service.translate("admin.users_list.delete_confirm_username", locale_str).await, | |
| 682 | - delete_confirm_email: i18n_service.translate("admin.users_list.delete_confirm_email", locale_str).await, | |
| 683 | - delete_irreversible: i18n_service.translate("admin.users_list.delete_irreversible", locale_str).await, | |
| 684 | - delete_button: i18n_service.translate("admin.users_list.delete_button", locale_str).await, | |
| 685 | - delete_error: i18n_service.translate("admin.users_list.delete_error", locale_str).await, | |
| 686 | - network_error: i18n_service.translate("admin.users_list.network_error", locale_str).await, | |
| 752 | + table_username: i18n_service | |
| 753 | + .translate("admin.users_list.table_username", locale_str) | |
| 754 | + .await, | |
| 755 | + table_email: i18n_service | |
| 756 | + .translate("admin.users_list.table_email", locale_str) | |
| 757 | + .await, | |
| 758 | + table_roles: i18n_service | |
| 759 | + .translate("admin.users_list.table_roles", locale_str) | |
| 760 | + .await, | |
| 761 | + table_created_at: i18n_service | |
| 762 | + .translate("admin.users_list.table_created_at", locale_str) | |
| 763 | + .await, | |
| 764 | + table_last_login: i18n_service | |
| 765 | + .translate("admin.users_list.table_last_login", locale_str) | |
| 766 | + .await, | |
| 767 | + table_actions: i18n_service | |
| 768 | + .translate("admin.users_list.table_actions", locale_str) | |
| 769 | + .await, | |
| 770 | + never: i18n_service | |
| 771 | + .translate("admin.users_list.never", locale_str) | |
| 772 | + .await, | |
| 773 | + edit: i18n_service | |
| 774 | + .translate("admin.users_list.edit", locale_str) | |
| 775 | + .await, | |
| 776 | + delete: i18n_service | |
| 777 | + .translate("admin.users_list.delete", locale_str) | |
| 778 | + .await, | |
| 779 | + reset_password: i18n_service | |
| 780 | + .translate("admin.users_list.reset_password", locale_str) | |
| 781 | + .await, | |
| 782 | + no_users: i18n_service | |
| 783 | + .translate("admin.users_list.no_users", locale_str) | |
| 784 | + .await, | |
| 785 | + reset_password_title: i18n_service | |
| 786 | + .translate("admin.users_list.reset_password_title", locale_str) | |
| 787 | + .await, | |
| 788 | + reset_generate: i18n_service | |
| 789 | + .translate("admin.users_list.reset_generate", locale_str) | |
| 790 | + .await, | |
| 791 | + reset_custom: i18n_service | |
| 792 | + .translate("admin.users_list.reset_custom", locale_str) | |
| 793 | + .await, | |
| 794 | + reset_email: i18n_service | |
| 795 | + .translate("admin.users_list.reset_email", locale_str) | |
| 796 | + .await, | |
| 797 | + reset_new_password_label: i18n_service | |
| 798 | + .translate("admin.users_list.reset_new_password_label", locale_str) | |
| 799 | + .await, | |
| 800 | + reset_new_password_placeholder: i18n_service | |
| 801 | + .translate( | |
| 802 | + "admin.users_list.reset_new_password_placeholder", | |
| 803 | + locale_str, | |
| 804 | + ) | |
| 805 | + .await, | |
| 806 | + reset_password_requirements: i18n_service | |
| 807 | + .translate("admin.users_list.reset_password_requirements", locale_str) | |
| 808 | + .await, | |
| 809 | + reset_invalidate_sessions: i18n_service | |
| 810 | + .translate("admin.users_list.reset_invalidate_sessions", locale_str) | |
| 811 | + .await, | |
| 812 | + reset_invalidate_sessions_desc: i18n_service | |
| 813 | + .translate( | |
| 814 | + "admin.users_list.reset_invalidate_sessions_desc", | |
| 815 | + locale_str, | |
| 816 | + ) | |
| 817 | + .await, | |
| 818 | + reset_button: i18n_service | |
| 819 | + .translate("admin.users_list.reset_button", locale_str) | |
| 820 | + .await, | |
| 821 | + delete_confirm_title: i18n_service | |
| 822 | + .translate("admin.users_list.delete_confirm_title", locale_str) | |
| 823 | + .await, | |
| 824 | + delete_confirm_message: i18n_service | |
| 825 | + .translate("admin.users_list.delete_confirm_message", locale_str) | |
| 826 | + .await, | |
| 827 | + delete_confirm_username: i18n_service | |
| 828 | + .translate("admin.users_list.delete_confirm_username", locale_str) | |
| 829 | + .await, | |
| 830 | + delete_confirm_email: i18n_service | |
| 831 | + .translate("admin.users_list.delete_confirm_email", locale_str) | |
| 832 | + .await, | |
| 833 | + delete_irreversible: i18n_service | |
| 834 | + .translate("admin.users_list.delete_irreversible", locale_str) | |
| 835 | + .await, | |
| 836 | + delete_button: i18n_service | |
| 837 | + .translate("admin.users_list.delete_button", locale_str) | |
| 838 | + .await, | |
| 839 | + delete_error: i18n_service | |
| 840 | + .translate("admin.users_list.delete_error", locale_str) | |
| 841 | + .await, | |
| 842 | + network_error: i18n_service | |
| 843 | + .translate("admin.users_list.network_error", locale_str) | |
| 844 | + .await, | |
| 687 | 845 | cancel: i18n_service.translate("common.cancel", locale_str).await, |
| 688 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 846 | + user_menu_profile: i18n_service | |
| 847 | + .translate("user_menu.profile", locale_str) | |
| 848 | + .await, | |
| 689 | 849 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 690 | 850 | |
| 691 | - resetting: i18n_service.translate("admin.users_list.resetting", locale_str).await, | |
| 692 | - reset_by_email_title: i18n_service.translate("admin.users_list.reset_by_email_title", locale_str).await, | |
| 693 | - close: i18n_service.translate("admin.users_list.close", locale_str).await, | |
| 694 | - password_reset_success_title: i18n_service.translate("admin.users_list.password_reset_success_title", locale_str).await, | |
| 695 | - password_warning_once: i18n_service.translate("admin.users_list.password_warning_once", locale_str).await, | |
| 696 | - new_password_label: i18n_service.translate("admin.users_list.new_password_label", locale_str).await, | |
| 697 | - copy: i18n_service.translate("admin.users_list.copy", locale_str).await, | |
| 698 | - password_copied: i18n_service.translate("admin.users_list.password_copied", locale_str).await, | |
| 699 | - password_set_success: i18n_service.translate("admin.users_list.password_set_success", locale_str).await, | |
| 700 | - reset_error: i18n_service.translate("admin.users_list.reset_error", locale_str).await, | |
| 701 | - pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db) | |
| 702 | - .await | |
| 703 | - .min_length, | |
| 851 | + resetting: i18n_service | |
| 852 | + .translate("admin.users_list.resetting", locale_str) | |
| 853 | + .await, | |
| 854 | + reset_by_email_title: i18n_service | |
| 855 | + .translate("admin.users_list.reset_by_email_title", locale_str) | |
| 856 | + .await, | |
| 857 | + close: i18n_service | |
| 858 | + .translate("admin.users_list.close", locale_str) | |
| 859 | + .await, | |
| 860 | + password_reset_success_title: i18n_service | |
| 861 | + .translate("admin.users_list.password_reset_success_title", locale_str) | |
| 862 | + .await, | |
| 863 | + password_warning_once: i18n_service | |
| 864 | + .translate("admin.users_list.password_warning_once", locale_str) | |
| 865 | + .await, | |
| 866 | + new_password_label: i18n_service | |
| 867 | + .translate("admin.users_list.new_password_label", locale_str) | |
| 868 | + .await, | |
| 869 | + copy: i18n_service | |
| 870 | + .translate("admin.users_list.copy", locale_str) | |
| 871 | + .await, | |
| 872 | + password_copied: i18n_service | |
| 873 | + .translate("admin.users_list.password_copied", locale_str) | |
| 874 | + .await, | |
| 875 | + password_set_success: i18n_service | |
| 876 | + .translate("admin.users_list.password_set_success", locale_str) | |
| 877 | + .await, | |
| 878 | + reset_error: i18n_service | |
| 879 | + .translate("admin.users_list.reset_error", locale_str) | |
| 880 | + .await, | |
| 881 | + pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db( | |
| 882 | + &db, | |
| 883 | + ) | |
| 884 | + .await | |
| 885 | + .min_length, | |
| 704 | 886 | }; |
| 705 | 887 | |
| 706 | 888 | Ok(template) |
@@ -734,7 +916,9 @@ pub async fn create_user_form(
| 734 | 916 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 735 | 917 | |
| 736 | 918 | let template = UserFormTemplate { |
| 737 | - page_title: i18n_service.translate("admin.users_list.create_user", locale_str).await, | |
| 919 | + page_title: i18n_service | |
| 920 | + .translate("admin.users_list.create_user", locale_str) | |
| 921 | + .await, | |
| 738 | 922 | csrf_token, |
| 739 | 923 | app_name: base_context.app_name, |
| 740 | 924 | app_version: base_context.app_version, |
@@ -754,30 +938,68 @@ pub async fn create_user_form(
| 754 | 938 | form_roles: Vec::new(), |
| 755 | 939 | available_roles: available_roles_display, |
| 756 | 940 | must_change_password: false, |
| 757 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 941 | + user_menu_profile: i18n_service | |
| 942 | + .translate("user_menu.profile", locale_str) | |
| 943 | + .await, | |
| 758 | 944 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 759 | 945 | |
| 760 | 946 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 761 | 947 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 762 | 948 | breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await, |
| 763 | - breadcrumb_mode: i18n_service.translate("admin.user_form.breadcrumb_create", locale_str).await, | |
| 764 | - form_title: i18n_service.translate("admin.user_form.title_create", locale_str).await, | |
| 765 | - back_to_list: i18n_service.translate("admin.user_form.back_to_list", locale_str).await, | |
| 766 | - username_label: i18n_service.translate("admin.user_form.username_label", locale_str).await, | |
| 767 | - username_placeholder: i18n_service.translate("admin.user_form.username_placeholder", locale_str).await, | |
| 768 | - username_readonly_hint: i18n_service.translate("admin.user_form.username_readonly_hint", locale_str).await, | |
| 769 | - email_label: i18n_service.translate("admin.user_form.email_label", locale_str).await, | |
| 770 | - email_placeholder: i18n_service.translate("admin.user_form.email_placeholder", locale_str).await, | |
| 771 | - password_label: i18n_service.translate("admin.user_form.password_label", locale_str).await, | |
| 772 | - password_optional: i18n_service.translate("admin.user_form.password_optional", locale_str).await, | |
| 773 | - password_placeholder: i18n_service.translate("admin.user_form.password_placeholder", locale_str).await, | |
| 774 | - password_hint: i18n_service.translate("admin.user_form.password_hint", locale_str).await, | |
| 775 | - roles_label: i18n_service.translate("admin.user_form.roles_label", locale_str).await, | |
| 776 | - roles_hint: i18n_service.translate("admin.user_form.roles_hint", locale_str).await, | |
| 777 | - must_change_password_label: i18n_service.translate("admin.user_form.must_change_password_label", locale_str).await, | |
| 778 | - must_change_password_desc: i18n_service.translate("admin.user_form.must_change_password_desc", locale_str).await, | |
| 779 | - cancel: i18n_service.translate("admin.user_form.cancel", locale_str).await, | |
| 780 | - submit_label: i18n_service.translate("admin.user_form.submit_create", locale_str).await, | |
| 949 | + breadcrumb_mode: i18n_service | |
| 950 | + .translate("admin.user_form.breadcrumb_create", locale_str) | |
| 951 | + .await, | |
| 952 | + form_title: i18n_service | |
| 953 | + .translate("admin.user_form.title_create", locale_str) | |
| 954 | + .await, | |
| 955 | + back_to_list: i18n_service | |
| 956 | + .translate("admin.user_form.back_to_list", locale_str) | |
| 957 | + .await, | |
| 958 | + username_label: i18n_service | |
| 959 | + .translate("admin.user_form.username_label", locale_str) | |
| 960 | + .await, | |
| 961 | + username_placeholder: i18n_service | |
| 962 | + .translate("admin.user_form.username_placeholder", locale_str) | |
| 963 | + .await, | |
| 964 | + username_readonly_hint: i18n_service | |
| 965 | + .translate("admin.user_form.username_readonly_hint", locale_str) | |
| 966 | + .await, | |
| 967 | + email_label: i18n_service | |
| 968 | + .translate("admin.user_form.email_label", locale_str) | |
| 969 | + .await, | |
| 970 | + email_placeholder: i18n_service | |
| 971 | + .translate("admin.user_form.email_placeholder", locale_str) | |
| 972 | + .await, | |
| 973 | + password_label: i18n_service | |
| 974 | + .translate("admin.user_form.password_label", locale_str) | |
| 975 | + .await, | |
| 976 | + password_optional: i18n_service | |
| 977 | + .translate("admin.user_form.password_optional", locale_str) | |
| 978 | + .await, | |
| 979 | + password_placeholder: i18n_service | |
| 980 | + .translate("admin.user_form.password_placeholder", locale_str) | |
| 981 | + .await, | |
| 982 | + password_hint: i18n_service | |
| 983 | + .translate("admin.user_form.password_hint", locale_str) | |
| 984 | + .await, | |
| 985 | + roles_label: i18n_service | |
| 986 | + .translate("admin.user_form.roles_label", locale_str) | |
| 987 | + .await, | |
| 988 | + roles_hint: i18n_service | |
| 989 | + .translate("admin.user_form.roles_hint", locale_str) | |
| 990 | + .await, | |
| 991 | + must_change_password_label: i18n_service | |
| 992 | + .translate("admin.user_form.must_change_password_label", locale_str) | |
| 993 | + .await, | |
| 994 | + must_change_password_desc: i18n_service | |
| 995 | + .translate("admin.user_form.must_change_password_desc", locale_str) | |
| 996 | + .await, | |
| 997 | + cancel: i18n_service | |
| 998 | + .translate("admin.user_form.cancel", locale_str) | |
| 999 | + .await, | |
| 1000 | + submit_label: i18n_service | |
| 1001 | + .translate("admin.user_form.submit_create", locale_str) | |
| 1002 | + .await, | |
| 781 | 1003 | }; |
| 782 | 1004 | |
| 783 | 1005 | Ok(template) |
@@ -803,7 +1025,10 @@ pub async fn create_user(
| 803 | 1025 | use rustwarden_core::services::user_service::UserService; |
| 804 | 1026 | |
| 805 | 1027 | // Check if username or email already exists |
| 806 | - if UserService::get_user_by_username(&db, &form_data.username).await?.is_some() { | |
| 1028 | + if UserService::get_user_by_username(&db, &form_data.username) | |
| 1029 | + .await? | |
| 1030 | + .is_some() | |
| 1031 | + { | |
| 807 | 1032 | let error_template = FormMessageTemplate { |
| 808 | 1033 | message_type: "error".to_string(), |
| 809 | 1034 | message: format!("Le nom d'utilisateur '{}' existe déjà", form_data.username), |
@@ -811,7 +1036,10 @@ pub async fn create_user(
| 811 | 1036 | return Ok(error_template.into_response()); |
| 812 | 1037 | } |
| 813 | 1038 | |
| 814 | - if UserService::get_user_by_email(&db, &form_data.email).await?.is_some() { | |
| 1039 | + if UserService::get_user_by_email(&db, &form_data.email) | |
| 1040 | + .await? | |
| 1041 | + .is_some() | |
| 1042 | + { | |
| 815 | 1043 | let error_template = FormMessageTemplate { |
| 816 | 1044 | message_type: "error".to_string(), |
| 817 | 1045 | message: format!("L'email '{}' est déjà utilisé", form_data.email), |
@@ -820,7 +1048,14 @@ pub async fn create_user(
| 820 | 1048 | } |
| 821 | 1049 | |
| 822 | 1050 | let password_hash = PasswordService::hash_password(&form_data.password).await?; |
| 823 | - let user = UserService::create_user(&db, form_data.username, form_data.email, password_hash, false).await?; | |
| 1051 | + let user = UserService::create_user( | |
| 1052 | + &db, | |
| 1053 | + form_data.username, | |
| 1054 | + form_data.email, | |
| 1055 | + password_hash, | |
| 1056 | + false, | |
| 1057 | + ) | |
| 1058 | + .await?; | |
| 824 | 1059 | |
| 825 | 1060 | for role_name in &form_data.roles { |
| 826 | 1061 | if let Some(r) = RoleService::get_role_by_name(&db, role_name).await? { |
@@ -913,30 +1148,68 @@ pub async fn edit_user_form(
| 913 | 1148 | form_roles: current_role_names, |
| 914 | 1149 | available_roles: available_roles_display, |
| 915 | 1150 | must_change_password: user.must_change_password, |
| 916 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 1151 | + user_menu_profile: i18n_service | |
| 1152 | + .translate("user_menu.profile", locale_str) | |
| 1153 | + .await, | |
| 917 | 1154 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 918 | 1155 | |
| 919 | 1156 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 920 | 1157 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 921 | 1158 | breadcrumb_users: i18n_service.translate("breadcrumb.users", locale_str).await, |
| 922 | - breadcrumb_mode: i18n_service.translate("admin.user_form.breadcrumb_edit", locale_str).await, | |
| 923 | - form_title: i18n_service.translate("admin.user_form.title_edit", locale_str).await, | |
| 924 | - back_to_list: i18n_service.translate("admin.user_form.back_to_list", locale_str).await, | |
| 925 | - username_label: i18n_service.translate("admin.user_form.username_label", locale_str).await, | |
| 926 | - username_placeholder: i18n_service.translate("admin.user_form.username_placeholder", locale_str).await, | |
| 927 | - username_readonly_hint: i18n_service.translate("admin.user_form.username_readonly_hint", locale_str).await, | |
| 928 | - email_label: i18n_service.translate("admin.user_form.email_label", locale_str).await, | |
| 929 | - email_placeholder: i18n_service.translate("admin.user_form.email_placeholder", locale_str).await, | |
| 930 | - password_label: i18n_service.translate("admin.user_form.password_label", locale_str).await, | |
| 931 | - password_optional: i18n_service.translate("admin.user_form.password_optional", locale_str).await, | |
| 932 | - password_placeholder: i18n_service.translate("admin.user_form.password_placeholder", locale_str).await, | |
| 933 | - password_hint: i18n_service.translate("admin.user_form.password_hint", locale_str).await, | |
| 934 | - roles_label: i18n_service.translate("admin.user_form.roles_label", locale_str).await, | |
| 935 | - roles_hint: i18n_service.translate("admin.user_form.roles_hint", locale_str).await, | |
| 936 | - must_change_password_label: i18n_service.translate("admin.user_form.must_change_password_label", locale_str).await, | |
| 937 | - must_change_password_desc: i18n_service.translate("admin.user_form.must_change_password_desc", locale_str).await, | |
| 938 | - cancel: i18n_service.translate("admin.user_form.cancel", locale_str).await, | |
| 939 | - submit_label: i18n_service.translate("admin.user_form.submit_edit", locale_str).await, | |
| 1159 | + breadcrumb_mode: i18n_service | |
| 1160 | + .translate("admin.user_form.breadcrumb_edit", locale_str) | |
| 1161 | + .await, | |
| 1162 | + form_title: i18n_service | |
| 1163 | + .translate("admin.user_form.title_edit", locale_str) | |
| 1164 | + .await, | |
| 1165 | + back_to_list: i18n_service | |
| 1166 | + .translate("admin.user_form.back_to_list", locale_str) | |
| 1167 | + .await, | |
| 1168 | + username_label: i18n_service | |
| 1169 | + .translate("admin.user_form.username_label", locale_str) | |
| 1170 | + .await, | |
| 1171 | + username_placeholder: i18n_service | |
| 1172 | + .translate("admin.user_form.username_placeholder", locale_str) | |
| 1173 | + .await, | |
| 1174 | + username_readonly_hint: i18n_service | |
| 1175 | + .translate("admin.user_form.username_readonly_hint", locale_str) | |
| 1176 | + .await, | |
| 1177 | + email_label: i18n_service | |
| 1178 | + .translate("admin.user_form.email_label", locale_str) | |
| 1179 | + .await, | |
| 1180 | + email_placeholder: i18n_service | |
| 1181 | + .translate("admin.user_form.email_placeholder", locale_str) | |
| 1182 | + .await, | |
| 1183 | + password_label: i18n_service | |
| 1184 | + .translate("admin.user_form.password_label", locale_str) | |
| 1185 | + .await, | |
| 1186 | + password_optional: i18n_service | |
| 1187 | + .translate("admin.user_form.password_optional", locale_str) | |
| 1188 | + .await, | |
| 1189 | + password_placeholder: i18n_service | |
| 1190 | + .translate("admin.user_form.password_placeholder", locale_str) | |
| 1191 | + .await, | |
| 1192 | + password_hint: i18n_service | |
| 1193 | + .translate("admin.user_form.password_hint", locale_str) | |
| 1194 | + .await, | |
| 1195 | + roles_label: i18n_service | |
| 1196 | + .translate("admin.user_form.roles_label", locale_str) | |
| 1197 | + .await, | |
| 1198 | + roles_hint: i18n_service | |
| 1199 | + .translate("admin.user_form.roles_hint", locale_str) | |
| 1200 | + .await, | |
| 1201 | + must_change_password_label: i18n_service | |
| 1202 | + .translate("admin.user_form.must_change_password_label", locale_str) | |
| 1203 | + .await, | |
| 1204 | + must_change_password_desc: i18n_service | |
| 1205 | + .translate("admin.user_form.must_change_password_desc", locale_str) | |
| 1206 | + .await, | |
| 1207 | + cancel: i18n_service | |
| 1208 | + .translate("admin.user_form.cancel", locale_str) | |
| 1209 | + .await, | |
| 1210 | + submit_label: i18n_service | |
| 1211 | + .translate("admin.user_form.submit_edit", locale_str) | |
| 1212 | + .await, | |
| 940 | 1213 | }; |
| 941 | 1214 | |
| 942 | 1215 | Ok(template) |
@@ -945,10 +1218,13 @@ pub async fn edit_user_form(
| 945 | 1218 | /// Form data for user update |
| 946 | 1219 | #[derive(serde::Deserialize)] |
| 947 | 1220 | pub struct UpdateUserFormData { |
| 1221 | + /// Updated email address. | |
| 948 | 1222 | pub email: String, |
| 1223 | + /// Updated list of roles. | |
| 949 | 1224 | #[serde(default)] |
| 950 | 1225 | #[serde(deserialize_with = "deserialize_string_or_vec")] |
| 951 | 1226 | pub roles: Vec<String>, |
| 1227 | + /// Whether to force a password change on next login. | |
| 952 | 1228 | #[serde(default)] |
| 953 | 1229 | pub must_change_password: Option<bool>, |
| 954 | 1230 | } |
@@ -961,10 +1237,14 @@ pub async fn update_user(
| 961 | 1237 | axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>, |
| 962 | 1238 | axum::Form(form_data): axum::Form<UpdateUserFormData>, |
| 963 | 1239 | ) -> Result<impl IntoResponse, AppError> { |
| 964 | - use rustwarden_core::services::user_service::UserService; | |
| 965 | 1240 | use rustwarden_core::services::role_service::RoleService; |
| 1241 | + use rustwarden_core::services::user_service::UserService; | |
| 966 | 1242 | |
| 967 | - let email = if form_data.email.is_empty() { None } else { Some(form_data.email) }; | |
| 1243 | + let email = if form_data.email.is_empty() { | |
| 1244 | + None | |
| 1245 | + } else { | |
| 1246 | + Some(form_data.email) | |
| 1247 | + }; | |
| 968 | 1248 | |
| 969 | 1249 | // Update email if provided |
| 970 | 1250 | if let Some(ref new_email) = email { |
@@ -973,9 +1253,11 @@ pub async fn update_user(
| 973 | 1253 | |
| 974 | 1254 | // Update must_change_password if provided |
| 975 | 1255 | if let Some(must_change) = form_data.must_change_password { |
| 976 | - use sea_orm::{ActiveModelTrait, Set}; | |
| 977 | 1256 | use rustwarden_core::models::user; |
| 978 | - let user_model = user::Entity::find_by_id(user_id).one(&db).await? | |
| 1257 | + use sea_orm::{ActiveModelTrait, Set}; | |
| 1258 | + let user_model = user::Entity::find_by_id(user_id) | |
| 1259 | + .one(&db) | |
| 1260 | + .await? | |
| 979 | 1261 | .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; |
| 980 | 1262 | let mut active: user::ActiveModel = user_model.into(); |
| 981 | 1263 | active.must_change_password = Set(must_change); |
@@ -1020,7 +1302,9 @@ pub async fn delete_user(
| 1020 | 1302 | .await?; |
| 1021 | 1303 | let is_admin = admin_user_roles.iter().any(|ur| ur.user_id == user_id); |
| 1022 | 1304 | if is_admin && admin_user_roles.len() <= 1 { |
| 1023 | - return Err(AppError::Validation("Cannot delete the last admin user".to_string())); | |
| 1305 | + return Err(AppError::Validation( | |
| 1306 | + "Cannot delete the last admin user".to_string(), | |
| 1307 | + )); | |
| 1024 | 1308 | } |
| 1025 | 1309 | } |
| 1026 | 1310 |
@@ -1036,7 +1320,9 @@ pub async fn delete_user(
| 1036 | 1320 | |
| 1037 | 1321 | // Delete user |
| 1038 | 1322 | use rustwarden_core::models::user; |
| 1039 | - let user_model = user::Entity::find_by_id(user_id).one(&db).await? | |
| 1323 | + let user_model = user::Entity::find_by_id(user_id) | |
| 1324 | + .one(&db) | |
| 1325 | + .await? | |
| 1040 | 1326 | .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; |
| 1041 | 1327 | use sea_orm::ModelTrait; |
| 1042 | 1328 | user_model.delete(&db).await?; |
@@ -1116,7 +1402,9 @@ pub async fn reset_user_password(
| 1116 | 1402 | |
| 1117 | 1403 | use rustwarden_core::models::user; |
| 1118 | 1404 | use sea_orm::{ActiveModelTrait, Set}; |
| 1119 | - let user_model = user::Entity::find_by_id(user_id).one(&db).await? | |
| 1405 | + let user_model = user::Entity::find_by_id(user_id) | |
| 1406 | + .one(&db) | |
| 1407 | + .await? | |
| 1120 | 1408 | .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; |
| 1121 | 1409 | let mut active: user::ActiveModel = user_model.into(); |
| 1122 | 1410 | active.password_hash = Set(password_hash); |
@@ -1127,7 +1415,8 @@ pub async fn reset_user_password(
| 1127 | 1415 | |
| 1128 | 1416 | let response = ResetPasswordResponse { |
| 1129 | 1417 | password: Some(new_password), |
| 1130 | - message: "Password reset successfully. This password will only be displayed once.".to_string(), | |
| 1418 | + message: "Password reset successfully. This password will only be displayed once." | |
| 1419 | + .to_string(), | |
| 1131 | 1420 | }; |
| 1132 | 1421 | |
| 1133 | 1422 | Ok(axum::Json(response)) |
@@ -1282,6 +1571,8 @@ struct AppSettingsTemplateContext {
| 1282 | 1571 | oauth_microsoft_client_id: String, |
| 1283 | 1572 | oauth_microsoft_has_secret: bool, |
| 1284 | 1573 | oauth_microsoft_tenant: String, |
| 1574 | + // TOTP 2FA | |
| 1575 | + totp_force_enabled: String, | |
| 1285 | 1576 | } |
| 1286 | 1577 | |
| 1287 | 1578 | /// GET /admin/settings - Application settings page |
@@ -1324,7 +1615,10 @@ pub async fn get_app_settings(
| 1324 | 1615 | password_expiration_enabled: get_val("password_expiration_enabled", "false"), |
| 1325 | 1616 | password_expiration_days: get_val("password_expiration_days", "90"), |
| 1326 | 1617 | password_expiration_alert_enabled: get_val("password_expiration_alert_enabled", "false"), |
| 1327 | - password_expiration_alert_days_before: get_val("password_expiration_alert_days_before", "7"), | |
| 1618 | + password_expiration_alert_days_before: get_val( | |
| 1619 | + "password_expiration_alert_days_before", | |
| 1620 | + "7", | |
| 1621 | + ), | |
| 1328 | 1622 | audit_log_level: get_val("audit_log_level", "INFO"), |
| 1329 | 1623 | audit_log_retention_days: get_val("audit_log_retention_days", "365"), |
| 1330 | 1624 | // OAuth |
@@ -1345,12 +1639,16 @@ pub async fn get_app_settings(
| 1345 | 1639 | oauth_microsoft_client_id: get_val("oauth_microsoft_client_id", ""), |
| 1346 | 1640 | oauth_microsoft_has_secret: !get_val("oauth_microsoft_client_secret", "").is_empty(), |
| 1347 | 1641 | oauth_microsoft_tenant: get_val("oauth_microsoft_tenant", ""), |
| 1642 | + // TOTP 2FA | |
| 1643 | + totp_force_enabled: get_val("totp_force_enabled", "false"), | |
| 1348 | 1644 | }; |
| 1349 | 1645 | |
| 1350 | 1646 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 1351 | 1647 | |
| 1352 | 1648 | let template = AdminSettingsTemplate { |
| 1353 | - page_title: i18n_service.translate("admin.settings.title", locale_str).await, | |
| 1649 | + page_title: i18n_service | |
| 1650 | + .translate("admin.settings.title", locale_str) | |
| 1651 | + .await, | |
| 1354 | 1652 | csrf_token, |
| 1355 | 1653 | app_name: base_context.app_name, |
| 1356 | 1654 | app_version: base_context.app_version, |
@@ -1364,86 +1662,239 @@ pub async fn get_app_settings(
| 1364 | 1662 | nav_items, |
| 1365 | 1663 | has_pending_password_change: false, |
| 1366 | 1664 | settings: settings_ctx, |
| 1367 | - title: i18n_service.translate("admin.settings.title", locale_str).await, | |
| 1368 | - subtitle: i18n_service.translate("admin.settings.subtitle", locale_str).await, | |
| 1369 | - general: i18n_service.translate("admin.settings.general", locale_str).await, | |
| 1370 | - app_name_label: i18n_service.translate("admin.settings.app_name", locale_str).await, | |
| 1371 | - app_domain_label: i18n_service.translate("admin.settings.app_domain", locale_str).await, | |
| 1372 | - app_domain_placeholder: i18n_service.translate("admin.settings.app_domain_placeholder", locale_str).await, | |
| 1373 | - app_description_label: i18n_service.translate("admin.settings.app_description", locale_str).await, | |
| 1374 | - allow_registration_label: i18n_service.translate("admin.settings.allow_registration", locale_str).await, | |
| 1375 | - allow_registration_desc: i18n_service.translate("admin.settings.allow_registration_desc", locale_str).await, | |
| 1376 | - email_validation_required_label: i18n_service.translate("admin.settings.email_validation_required", locale_str).await, | |
| 1377 | - email_validation_required_desc: i18n_service.translate("admin.settings.email_validation_required_desc", locale_str).await, | |
| 1378 | - save_button: i18n_service.translate("admin.settings.save", locale_str).await, | |
| 1379 | - cancel: i18n_service.translate("admin.settings.cancel", locale_str).await, | |
| 1380 | - saved: i18n_service.translate("admin.settings.saved", locale_str).await, | |
| 1381 | - error: i18n_service.translate("admin.settings.error", locale_str).await, | |
| 1382 | - network_error: i18n_service.translate("admin.settings.network_error", locale_str).await, | |
| 1665 | + title: i18n_service | |
| 1666 | + .translate("admin.settings.title", locale_str) | |
| 1667 | + .await, | |
| 1668 | + subtitle: i18n_service | |
| 1669 | + .translate("admin.settings.subtitle", locale_str) | |
| 1670 | + .await, | |
| 1671 | + general: i18n_service | |
| 1672 | + .translate("admin.settings.general", locale_str) | |
| 1673 | + .await, | |
| 1674 | + app_name_label: i18n_service | |
| 1675 | + .translate("admin.settings.app_name", locale_str) | |
| 1676 | + .await, | |
| 1677 | + app_domain_label: i18n_service | |
| 1678 | + .translate("admin.settings.app_domain", locale_str) | |
| 1679 | + .await, | |
| 1680 | + app_domain_placeholder: i18n_service | |
| 1681 | + .translate("admin.settings.app_domain_placeholder", locale_str) | |
| 1682 | + .await, | |
| 1683 | + app_description_label: i18n_service | |
| 1684 | + .translate("admin.settings.app_description", locale_str) | |
| 1685 | + .await, | |
| 1686 | + allow_registration_label: i18n_service | |
| 1687 | + .translate("admin.settings.allow_registration", locale_str) | |
| 1688 | + .await, | |
| 1689 | + allow_registration_desc: i18n_service | |
| 1690 | + .translate("admin.settings.allow_registration_desc", locale_str) | |
| 1691 | + .await, | |
| 1692 | + email_validation_required_label: i18n_service | |
| 1693 | + .translate("admin.settings.email_validation_required", locale_str) | |
| 1694 | + .await, | |
| 1695 | + email_validation_required_desc: i18n_service | |
| 1696 | + .translate("admin.settings.email_validation_required_desc", locale_str) | |
| 1697 | + .await, | |
| 1698 | + save_button: i18n_service | |
| 1699 | + .translate("admin.settings.save", locale_str) | |
| 1700 | + .await, | |
| 1701 | + cancel: i18n_service | |
| 1702 | + .translate("admin.settings.cancel", locale_str) | |
| 1703 | + .await, | |
| 1704 | + saved: i18n_service | |
| 1705 | + .translate("admin.settings.saved", locale_str) | |
| 1706 | + .await, | |
| 1707 | + error: i18n_service | |
| 1708 | + .translate("admin.settings.error", locale_str) | |
| 1709 | + .await, | |
| 1710 | + network_error: i18n_service | |
| 1711 | + .translate("admin.settings.network_error", locale_str) | |
| 1712 | + .await, | |
| 1383 | 1713 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 1384 | 1714 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 1385 | - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await, | |
| 1386 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 1715 | + breadcrumb_settings: i18n_service | |
| 1716 | + .translate("breadcrumb.settings", locale_str) | |
| 1717 | + .await, | |
| 1718 | + user_menu_profile: i18n_service | |
| 1719 | + .translate("user_menu.profile", locale_str) | |
| 1720 | + .await, | |
| 1387 | 1721 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 1388 | 1722 | |
| 1389 | - password_security: i18n_service.translate("admin.settings.password_security", locale_str).await, | |
| 1390 | - password_min_length: i18n_service.translate("admin.settings.password_min_length", locale_str).await, | |
| 1391 | - password_min_length_hint: i18n_service.translate("admin.settings.password_min_length_hint", locale_str).await, | |
| 1392 | - password_complexity: i18n_service.translate("admin.settings.password_complexity", locale_str).await, | |
| 1393 | - require_uppercase: i18n_service.translate("admin.settings.require_uppercase", locale_str).await, | |
| 1394 | - require_lowercase: i18n_service.translate("admin.settings.require_lowercase", locale_str).await, | |
| 1395 | - require_digits: i18n_service.translate("admin.settings.require_digits", locale_str).await, | |
| 1396 | - require_special: i18n_service.translate("admin.settings.require_special", locale_str).await, | |
| 1397 | - password_change_require_email: i18n_service.translate("admin.settings.password_change_require_email", locale_str).await, | |
| 1398 | - password_change_require_email_desc: i18n_service.translate("admin.settings.password_change_require_email_desc", locale_str).await, | |
| 1399 | - password_expiration: i18n_service.translate("admin.settings.password_expiration", locale_str).await, | |
| 1400 | - password_expiration_desc: i18n_service.translate("admin.settings.password_expiration_desc", locale_str).await, | |
| 1401 | - expiration_days: i18n_service.translate("admin.settings.expiration_days", locale_str).await, | |
| 1402 | - expiration_alert: i18n_service.translate("admin.settings.expiration_alert", locale_str).await, | |
| 1403 | - expiration_alert_days: i18n_service.translate("admin.settings.expiration_alert_days", locale_str).await, | |
| 1404 | - audit_logging: i18n_service.translate("admin.settings.audit_logging", locale_str).await, | |
| 1405 | - log_level: i18n_service.translate("admin.settings.log_level", locale_str).await, | |
| 1406 | - log_level_debug: i18n_service.translate("admin.settings.log_level_debug", locale_str).await, | |
| 1407 | - log_level_info: i18n_service.translate("admin.settings.log_level_info", locale_str).await, | |
| 1408 | - log_level_warn: i18n_service.translate("admin.settings.log_level_warn", locale_str).await, | |
| 1409 | - log_level_error: i18n_service.translate("admin.settings.log_level_error", locale_str).await, | |
| 1410 | - log_level_hint: i18n_service.translate("admin.settings.log_level_hint", locale_str).await, | |
| 1411 | - log_retention: i18n_service.translate("admin.settings.log_retention", locale_str).await, | |
| 1412 | - log_retention_hint: i18n_service.translate("admin.settings.log_retention_hint", locale_str).await, | |
| 1413 | - audit_info_message: i18n_service.translate("admin.settings.audit_info_message", locale_str).await, | |
| 1414 | - save_changes: i18n_service.translate("admin.settings.save_changes", locale_str).await, | |
| 1415 | - save_changes_desc: i18n_service.translate("admin.settings.save_changes_desc", locale_str).await, | |
| 1416 | - saving: i18n_service.translate("admin.settings.saving", locale_str).await, | |
| 1417 | - save_success: i18n_service.translate("admin.settings.save_success", locale_str).await, | |
| 1418 | - save_error_prefix: i18n_service.translate("admin.settings.save_error_prefix", locale_str).await, | |
| 1723 | + password_security: i18n_service | |
| 1724 | + .translate("admin.settings.password_security", locale_str) | |
| 1725 | + .await, | |
| 1726 | + password_min_length: i18n_service | |
| 1727 | + .translate("admin.settings.password_min_length", locale_str) | |
| 1728 | + .await, | |
| 1729 | + password_min_length_hint: i18n_service | |
| 1730 | + .translate("admin.settings.password_min_length_hint", locale_str) | |
| 1731 | + .await, | |
| 1732 | + password_complexity: i18n_service | |
| 1733 | + .translate("admin.settings.password_complexity", locale_str) | |
| 1734 | + .await, | |
| 1735 | + require_uppercase: i18n_service | |
| 1736 | + .translate("admin.settings.require_uppercase", locale_str) | |
| 1737 | + .await, | |
| 1738 | + require_lowercase: i18n_service | |
| 1739 | + .translate("admin.settings.require_lowercase", locale_str) | |
| 1740 | + .await, | |
| 1741 | + require_digits: i18n_service | |
| 1742 | + .translate("admin.settings.require_digits", locale_str) | |
| 1743 | + .await, | |
| 1744 | + require_special: i18n_service | |
| 1745 | + .translate("admin.settings.require_special", locale_str) | |
| 1746 | + .await, | |
| 1747 | + password_change_require_email: i18n_service | |
| 1748 | + .translate("admin.settings.password_change_require_email", locale_str) | |
| 1749 | + .await, | |
| 1750 | + password_change_require_email_desc: i18n_service | |
| 1751 | + .translate( | |
| 1752 | + "admin.settings.password_change_require_email_desc", | |
| 1753 | + locale_str, | |
| 1754 | + ) | |
| 1755 | + .await, | |
| 1756 | + password_expiration: i18n_service | |
| 1757 | + .translate("admin.settings.password_expiration", locale_str) | |
| 1758 | + .await, | |
| 1759 | + password_expiration_desc: i18n_service | |
| 1760 | + .translate("admin.settings.password_expiration_desc", locale_str) | |
| 1761 | + .await, | |
| 1762 | + expiration_days: i18n_service | |
| 1763 | + .translate("admin.settings.expiration_days", locale_str) | |
| 1764 | + .await, | |
| 1765 | + expiration_alert: i18n_service | |
| 1766 | + .translate("admin.settings.expiration_alert", locale_str) | |
| 1767 | + .await, | |
| 1768 | + expiration_alert_days: i18n_service | |
| 1769 | + .translate("admin.settings.expiration_alert_days", locale_str) | |
| 1770 | + .await, | |
| 1771 | + audit_logging: i18n_service | |
| 1772 | + .translate("admin.settings.audit_logging", locale_str) | |
| 1773 | + .await, | |
| 1774 | + log_level: i18n_service | |
| 1775 | + .translate("admin.settings.log_level", locale_str) | |
| 1776 | + .await, | |
| 1777 | + log_level_debug: i18n_service | |
| 1778 | + .translate("admin.settings.log_level_debug", locale_str) | |
| 1779 | + .await, | |
| 1780 | + log_level_info: i18n_service | |
| 1781 | + .translate("admin.settings.log_level_info", locale_str) | |
| 1782 | + .await, | |
| 1783 | + log_level_warn: i18n_service | |
| 1784 | + .translate("admin.settings.log_level_warn", locale_str) | |
| 1785 | + .await, | |
| 1786 | + log_level_error: i18n_service | |
| 1787 | + .translate("admin.settings.log_level_error", locale_str) | |
| 1788 | + .await, | |
| 1789 | + log_level_hint: i18n_service | |
| 1790 | + .translate("admin.settings.log_level_hint", locale_str) | |
| 1791 | + .await, | |
| 1792 | + log_retention: i18n_service | |
| 1793 | + .translate("admin.settings.log_retention", locale_str) | |
| 1794 | + .await, | |
| 1795 | + log_retention_hint: i18n_service | |
| 1796 | + .translate("admin.settings.log_retention_hint", locale_str) | |
| 1797 | + .await, | |
| 1798 | + audit_info_message: i18n_service | |
| 1799 | + .translate("admin.settings.audit_info_message", locale_str) | |
| 1800 | + .await, | |
| 1801 | + save_changes: i18n_service | |
| 1802 | + .translate("admin.settings.save_changes", locale_str) | |
| 1803 | + .await, | |
| 1804 | + save_changes_desc: i18n_service | |
| 1805 | + .translate("admin.settings.save_changes_desc", locale_str) | |
| 1806 | + .await, | |
| 1807 | + saving: i18n_service | |
| 1808 | + .translate("admin.settings.saving", locale_str) | |
| 1809 | + .await, | |
| 1810 | + save_success: i18n_service | |
| 1811 | + .translate("admin.settings.save_success", locale_str) | |
| 1812 | + .await, | |
| 1813 | + save_error_prefix: i18n_service | |
| 1814 | + .translate("admin.settings.save_error_prefix", locale_str) | |
| 1815 | + .await, | |
| 1419 | 1816 | // OAuth / SSO |
| 1420 | - oauth_sso_title: i18n_service.translate("admin.settings.oauth_sso_title", locale_str).await, | |
| 1421 | - oauth_enabled_label: i18n_service.translate("admin.settings.oauth_enabled_label", locale_str).await, | |
| 1422 | - oauth_enabled_desc: i18n_service.translate("admin.settings.oauth_enabled_desc", locale_str).await, | |
| 1423 | - oauth_redirect_url_label: i18n_service.translate("admin.settings.oauth_redirect_url_label", locale_str).await, | |
| 1424 | - oauth_redirect_url_desc: i18n_service.translate("admin.settings.oauth_redirect_url_desc", locale_str).await, | |
| 1425 | - oauth_auto_register_label: i18n_service.translate("admin.settings.oauth_auto_register_label", locale_str).await, | |
| 1426 | - oauth_auto_register_desc: i18n_service.translate("admin.settings.oauth_auto_register_desc", locale_str).await, | |
| 1427 | - oauth_link_existing_label: i18n_service.translate("admin.settings.oauth_link_existing_label", locale_str).await, | |
| 1428 | - oauth_link_existing_desc: i18n_service.translate("admin.settings.oauth_link_existing_desc", locale_str).await, | |
| 1429 | - oauth_client_id: i18n_service.translate("admin.settings.oauth_client_id", locale_str).await, | |
| 1430 | - oauth_client_secret: i18n_service.translate("admin.settings.oauth_client_secret", locale_str).await, | |
| 1431 | - oauth_tenant: i18n_service.translate("admin.settings.oauth_tenant", locale_str).await, | |
| 1432 | - oauth_redirect_uri: i18n_service.translate("admin.settings.oauth_redirect_uri", locale_str).await, | |
| 1433 | - oauth_copy_uri: i18n_service.translate("admin.settings.oauth_copy_uri", locale_str).await, | |
| 1434 | - oauth_secret_configured: i18n_service.translate("admin.settings.oauth_secret_configured", locale_str).await, | |
| 1435 | - oauth_secret_not_configured: i18n_service.translate("admin.settings.oauth_secret_not_configured", locale_str).await, | |
| 1436 | - oauth_secret_change: i18n_service.translate("admin.settings.oauth_secret_change", locale_str).await, | |
| 1437 | - oauth_restart_warning: i18n_service.translate("admin.settings.oauth_restart_warning", locale_str).await, | |
| 1438 | - oauth_env_info: i18n_service.translate("admin.settings.oauth_env_info", locale_str).await, | |
| 1439 | - oauth_google_label: i18n_service.translate("admin.settings.oauth_google_label", locale_str).await, | |
| 1440 | - oauth_google_desc: i18n_service.translate("admin.settings.oauth_google_desc", locale_str).await, | |
| 1441 | - oauth_github_label: i18n_service.translate("admin.settings.oauth_github_label", locale_str).await, | |
| 1442 | - oauth_github_desc: i18n_service.translate("admin.settings.oauth_github_desc", locale_str).await, | |
| 1443 | - oauth_discord_label: i18n_service.translate("admin.settings.oauth_discord_label", locale_str).await, | |
| 1444 | - oauth_discord_desc: i18n_service.translate("admin.settings.oauth_discord_desc", locale_str).await, | |
| 1445 | - oauth_microsoft_label: i18n_service.translate("admin.settings.oauth_microsoft_label", locale_str).await, | |
| 1446 | - oauth_microsoft_desc: i18n_service.translate("admin.settings.oauth_microsoft_desc", locale_str).await, | |
| 1817 | + oauth_sso_title: i18n_service | |
| 1818 | + .translate("admin.settings.oauth_sso_title", locale_str) | |
| 1819 | + .await, | |
| 1820 | + oauth_enabled_label: i18n_service | |
| 1821 | + .translate("admin.settings.oauth_enabled_label", locale_str) | |
| 1822 | + .await, | |
| 1823 | + oauth_enabled_desc: i18n_service | |
| 1824 | + .translate("admin.settings.oauth_enabled_desc", locale_str) | |
| 1825 | + .await, | |
| 1826 | + oauth_redirect_url_label: i18n_service | |
| 1827 | + .translate("admin.settings.oauth_redirect_url_label", locale_str) | |
| 1828 | + .await, | |
| 1829 | + oauth_redirect_url_desc: i18n_service | |
| 1830 | + .translate("admin.settings.oauth_redirect_url_desc", locale_str) | |
| 1831 | + .await, | |
| 1832 | + oauth_auto_register_label: i18n_service | |
| 1833 | + .translate("admin.settings.oauth_auto_register_label", locale_str) | |
| 1834 | + .await, | |
| 1835 | + oauth_auto_register_desc: i18n_service | |
| 1836 | + .translate("admin.settings.oauth_auto_register_desc", locale_str) | |
| 1837 | + .await, | |
| 1838 | + oauth_link_existing_label: i18n_service | |
| 1839 | + .translate("admin.settings.oauth_link_existing_label", locale_str) | |
| 1840 | + .await, | |
| 1841 | + oauth_link_existing_desc: i18n_service | |
| 1842 | + .translate("admin.settings.oauth_link_existing_desc", locale_str) | |
| 1843 | + .await, | |
| 1844 | + oauth_client_id: i18n_service | |
| 1845 | + .translate("admin.settings.oauth_client_id", locale_str) | |
| 1846 | + .await, | |
| 1847 | + oauth_client_secret: i18n_service | |
| 1848 | + .translate("admin.settings.oauth_client_secret", locale_str) | |
| 1849 | + .await, | |
| 1850 | + oauth_tenant: i18n_service | |
| 1851 | + .translate("admin.settings.oauth_tenant", locale_str) | |
| 1852 | + .await, | |
| 1853 | + oauth_redirect_uri: i18n_service | |
| 1854 | + .translate("admin.settings.oauth_redirect_uri", locale_str) | |
| 1855 | + .await, | |
| 1856 | + oauth_copy_uri: i18n_service | |
| 1857 | + .translate("admin.settings.oauth_copy_uri", locale_str) | |
| 1858 | + .await, | |
| 1859 | + oauth_secret_configured: i18n_service | |
| 1860 | + .translate("admin.settings.oauth_secret_configured", locale_str) | |
| 1861 | + .await, | |
| 1862 | + oauth_secret_not_configured: i18n_service | |
| 1863 | + .translate("admin.settings.oauth_secret_not_configured", locale_str) | |
| 1864 | + .await, | |
| 1865 | + oauth_secret_change: i18n_service | |
| 1866 | + .translate("admin.settings.oauth_secret_change", locale_str) | |
| 1867 | + .await, | |
| 1868 | + oauth_restart_warning: i18n_service | |
| 1869 | + .translate("admin.settings.oauth_restart_warning", locale_str) | |
| 1870 | + .await, | |
| 1871 | + oauth_env_info: i18n_service | |
| 1872 | + .translate("admin.settings.oauth_env_info", locale_str) | |
| 1873 | + .await, | |
| 1874 | + oauth_google_label: i18n_service | |
| 1875 | + .translate("admin.settings.oauth_google_label", locale_str) | |
| 1876 | + .await, | |
| 1877 | + oauth_google_desc: i18n_service | |
| 1878 | + .translate("admin.settings.oauth_google_desc", locale_str) | |
| 1879 | + .await, | |
| 1880 | + oauth_github_label: i18n_service | |
| 1881 | + .translate("admin.settings.oauth_github_label", locale_str) | |
| 1882 | + .await, | |
| 1883 | + oauth_github_desc: i18n_service | |
| 1884 | + .translate("admin.settings.oauth_github_desc", locale_str) | |
| 1885 | + .await, | |
| 1886 | + oauth_discord_label: i18n_service | |
| 1887 | + .translate("admin.settings.oauth_discord_label", locale_str) | |
| 1888 | + .await, | |
| 1889 | + oauth_discord_desc: i18n_service | |
| 1890 | + .translate("admin.settings.oauth_discord_desc", locale_str) | |
| 1891 | + .await, | |
| 1892 | + oauth_microsoft_label: i18n_service | |
| 1893 | + .translate("admin.settings.oauth_microsoft_label", locale_str) | |
| 1894 | + .await, | |
| 1895 | + oauth_microsoft_desc: i18n_service | |
| 1896 | + .translate("admin.settings.oauth_microsoft_desc", locale_str) | |
| 1897 | + .await, | |
| 1447 | 1898 | }; |
| 1448 | 1899 | |
| 1449 | 1900 | Ok(template) |
@@ -1452,42 +1903,80 @@ pub async fn get_app_settings(
| 1452 | 1903 | /// Form data received from admin settings page (url-encoded) |
| 1453 | 1904 | #[derive(Debug, serde::Deserialize)] |
| 1454 | 1905 | pub struct AdminSettingsFormData { |
| 1906 | + /// CSRF protection token. | |
| 1455 | 1907 | pub csrf_token: Option<String>, |
| 1908 | + /// Application display name. | |
| 1456 | 1909 | pub app_name: Option<String>, |
| 1910 | + /// Public domain of the application. | |
| 1457 | 1911 | pub app_domain: Option<String>, |
| 1912 | + /// Short description of the application. | |
| 1458 | 1913 | pub app_description: Option<String>, |
| 1914 | + /// Whether public self-registration is allowed (`"on"` / absent). | |
| 1459 | 1915 | pub allow_registration: Option<String>, |
| 1916 | + /// Whether email validation is required on registration. | |
| 1460 | 1917 | pub validation_email_requise: Option<String>, |
| 1918 | + /// Minimum password length. | |
| 1461 | 1919 | pub password_min_length: Option<String>, |
| 1920 | + /// Whether passwords must contain at least one uppercase letter. | |
| 1462 | 1921 | pub password_require_uppercase: Option<String>, |
| 1922 | + /// Whether passwords must contain at least one lowercase letter. | |
| 1463 | 1923 | pub password_require_lowercase: Option<String>, |
| 1924 | + /// Whether passwords must contain at least one digit. | |
| 1464 | 1925 | pub password_require_digits: Option<String>, |
| 1926 | + /// Whether passwords must contain at least one special character. | |
| 1465 | 1927 | pub password_require_special: Option<String>, |
| 1928 | + /// Whether a password-change confirmation email is required. | |
| 1466 | 1929 | pub password_change_require_email: Option<String>, |
| 1930 | + /// Whether periodic password expiration is enabled. | |
| 1467 | 1931 | pub password_expiration_enabled: Option<String>, |
| 1932 | + /// Number of days before a password expires. | |
| 1468 | 1933 | pub password_expiration_days: Option<String>, |
| 1934 | + /// Whether an alert email is sent before password expiry. | |
| 1469 | 1935 | pub password_expiration_alert_enabled: Option<String>, |
| 1936 | + /// Number of days before expiry at which the alert is sent. | |
| 1470 | 1937 | pub password_expiration_alert_days: Option<String>, |
| 1938 | + /// Minimum severity level for audit log entries. | |
| 1471 | 1939 | pub audit_log_level: Option<String>, |
| 1940 | + /// Number of days to retain audit log entries. | |
| 1472 | 1941 | pub audit_log_retention_days: Option<String>, |
| 1473 | 1942 | // OAuth |
| 1943 | + /// Whether OAuth social login is globally enabled. | |
| 1474 | 1944 | pub oauth_enabled: Option<String>, |
| 1945 | + /// Base URL used to build OAuth redirect URIs. | |
| 1475 | 1946 | pub oauth_redirect_base_url: Option<String>, |
| 1947 | + /// Whether new users are registered automatically on OAuth login. | |
| 1476 | 1948 | pub oauth_auto_register: Option<String>, |
| 1949 | + /// Whether OAuth login can be linked to an existing local account. | |
| 1477 | 1950 | pub oauth_link_existing_account: Option<String>, |
| 1951 | + /// Whether Google OAuth provider is enabled. | |
| 1478 | 1952 | pub oauth_google_enabled: Option<String>, |
| 1953 | + /// Google OAuth client ID. | |
| 1479 | 1954 | pub oauth_google_client_id: Option<String>, |
| 1955 | + /// Google OAuth client secret. | |
| 1480 | 1956 | pub oauth_google_client_secret: Option<String>, |
| 1957 | + /// Whether GitHub OAuth provider is enabled. | |
| 1481 | 1958 | pub oauth_github_enabled: Option<String>, |
| 1959 | + /// GitHub OAuth client ID. | |
| 1482 | 1960 | pub oauth_github_client_id: Option<String>, |
| 1961 | + /// GitHub OAuth client secret. | |
| 1483 | 1962 | pub oauth_github_client_secret: Option<String>, |
| 1963 | + /// Whether Discord OAuth provider is enabled. | |
| 1484 | 1964 | pub oauth_discord_enabled: Option<String>, |
| 1965 | + /// Discord OAuth client ID. | |
| 1485 | 1966 | pub oauth_discord_client_id: Option<String>, |
| 1967 | + /// Discord OAuth client secret. | |
| 1486 | 1968 | pub oauth_discord_client_secret: Option<String>, |
| 1969 | + /// Whether Microsoft OAuth provider is enabled. | |
| 1487 | 1970 | pub oauth_microsoft_enabled: Option<String>, |
| 1971 | + /// Microsoft OAuth client ID. | |
| 1488 | 1972 | pub oauth_microsoft_client_id: Option<String>, |
| 1973 | + /// Microsoft OAuth client secret. | |
| 1489 | 1974 | pub oauth_microsoft_client_secret: Option<String>, |
| 1975 | + /// Microsoft Azure AD tenant ID or `"common"`. | |
| 1490 | 1976 | pub oauth_microsoft_tenant: Option<String>, |
| 1977 | + // TOTP 2FA | |
| 1978 | + /// Whether TOTP two-factor authentication is enforced for all users. | |
| 1979 | + pub totp_force_enabled: Option<String>, | |
| 1491 | 1980 | } |
| 1492 | 1981 | |
| 1493 | 1982 | /// PUT /admin/settings - Update app settings |
@@ -1510,33 +1999,113 @@ pub async fn update_app_settings(
| 1510 | 1999 | ("app_domain", form.app_domain.unwrap_or_default()), |
| 1511 | 2000 | ("app_description", form.app_description.unwrap_or_default()), |
| 1512 | 2001 | ("allow_registration", checkbox_val(&form.allow_registration)), |
| 1513 | - ("validation_email_required", checkbox_val(&form.validation_email_requise)), | |
| 1514 | - ("password_min_length", form.password_min_length.unwrap_or_else(|| "8".to_string())), | |
| 1515 | - ("password_require_uppercase", checkbox_val(&form.password_require_uppercase)), | |
| 1516 | - ("password_require_lowercase", checkbox_val(&form.password_require_lowercase)), | |
| 1517 | - ("password_require_digits", checkbox_val(&form.password_require_digits)), | |
| 1518 | - ("password_require_special", checkbox_val(&form.password_require_special)), | |
| 1519 | - ("password_change_require_email", checkbox_val(&form.password_change_require_email)), | |
| 1520 | - ("password_expiration_enabled", checkbox_val(&form.password_expiration_enabled)), | |
| 1521 | - ("password_expiration_days", form.password_expiration_days.unwrap_or_else(|| "90".to_string())), | |
| 1522 | - ("password_expiration_alert_enabled", checkbox_val(&form.password_expiration_alert_enabled)), | |
| 1523 | - ("password_expiration_alert_days_before", form.password_expiration_alert_days.unwrap_or_else(|| "7".to_string())), | |
| 1524 | - ("audit_log_level", form.audit_log_level.unwrap_or_else(|| "INFO".to_string())), | |
| 1525 | - ("audit_log_retention_days", form.audit_log_retention_days.unwrap_or_else(|| "365".to_string())), | |
| 2002 | + ( | |
| 2003 | + "validation_email_required", | |
| 2004 | + checkbox_val(&form.validation_email_requise), | |
| 2005 | + ), | |
| 2006 | + ( | |
| 2007 | + "password_min_length", | |
| 2008 | + form.password_min_length.unwrap_or_else(|| "8".to_string()), | |
| 2009 | + ), | |
| 2010 | + ( | |
| 2011 | + "password_require_uppercase", | |
| 2012 | + checkbox_val(&form.password_require_uppercase), | |
| 2013 | + ), | |
| 2014 | + ( | |
| 2015 | + "password_require_lowercase", | |
| 2016 | + checkbox_val(&form.password_require_lowercase), | |
| 2017 | + ), | |
| 2018 | + ( | |
| 2019 | + "password_require_digits", | |
| 2020 | + checkbox_val(&form.password_require_digits), | |
| 2021 | + ), | |
| 2022 | + ( | |
| 2023 | + "password_require_special", | |
| 2024 | + checkbox_val(&form.password_require_special), | |
| 2025 | + ), | |
| 2026 | + ( | |
| 2027 | + "password_change_require_email", | |
| 2028 | + checkbox_val(&form.password_change_require_email), | |
| 2029 | + ), | |
| 2030 | + ( | |
| 2031 | + "password_expiration_enabled", | |
| 2032 | + checkbox_val(&form.password_expiration_enabled), | |
| 2033 | + ), | |
| 2034 | + ( | |
| 2035 | + "password_expiration_days", | |
| 2036 | + form.password_expiration_days | |
| 2037 | + .unwrap_or_else(|| "90".to_string()), | |
| 2038 | + ), | |
| 2039 | + ( | |
| 2040 | + "password_expiration_alert_enabled", | |
| 2041 | + checkbox_val(&form.password_expiration_alert_enabled), | |
| 2042 | + ), | |
| 2043 | + ( | |
| 2044 | + "password_expiration_alert_days_before", | |
| 2045 | + form.password_expiration_alert_days | |
| 2046 | + .unwrap_or_else(|| "7".to_string()), | |
| 2047 | + ), | |
| 2048 | + ( | |
| 2049 | + "audit_log_level", | |
| 2050 | + form.audit_log_level.unwrap_or_else(|| "INFO".to_string()), | |
| 2051 | + ), | |
| 2052 | + ( | |
| 2053 | + "audit_log_retention_days", | |
| 2054 | + form.audit_log_retention_days | |
| 2055 | + .unwrap_or_else(|| "365".to_string()), | |
| 2056 | + ), | |
| 1526 | 2057 | // OAuth toggles & non-secret fields |
| 1527 | 2058 | ("oauth_enabled", checkbox_val(&form.oauth_enabled)), |
| 1528 | - ("oauth_redirect_base_url", form.oauth_redirect_base_url.unwrap_or_default()), | |
| 1529 | - ("oauth_auto_register", checkbox_val(&form.oauth_auto_register)), | |
| 1530 | - ("oauth_link_existing_account", checkbox_val(&form.oauth_link_existing_account)), | |
| 1531 | - ("oauth_google_enabled", checkbox_val(&form.oauth_google_enabled)), | |
| 1532 | - ("oauth_google_client_id", form.oauth_google_client_id.unwrap_or_default()), | |
| 1533 | - ("oauth_github_enabled", checkbox_val(&form.oauth_github_enabled)), | |
| 1534 | - ("oauth_github_client_id", form.oauth_github_client_id.unwrap_or_default()), | |
| 1535 | - ("oauth_discord_enabled", checkbox_val(&form.oauth_discord_enabled)), | |
| 1536 | - ("oauth_discord_client_id", form.oauth_discord_client_id.unwrap_or_default()), | |
| 1537 | - ("oauth_microsoft_enabled", checkbox_val(&form.oauth_microsoft_enabled)), | |
| 1538 | - ("oauth_microsoft_client_id", form.oauth_microsoft_client_id.unwrap_or_default()), | |
| 1539 | - ("oauth_microsoft_tenant", form.oauth_microsoft_tenant.unwrap_or_default()), | |
| 2059 | + ( | |
| 2060 | + "oauth_redirect_base_url", | |
| 2061 | + form.oauth_redirect_base_url.unwrap_or_default(), | |
| 2062 | + ), | |
| 2063 | + ( | |
| 2064 | + "oauth_auto_register", | |
| 2065 | + checkbox_val(&form.oauth_auto_register), | |
| 2066 | + ), | |
| 2067 | + ( | |
| 2068 | + "oauth_link_existing_account", | |
| 2069 | + checkbox_val(&form.oauth_link_existing_account), | |
| 2070 | + ), | |
| 2071 | + ( | |
| 2072 | + "oauth_google_enabled", | |
| 2073 | + checkbox_val(&form.oauth_google_enabled), | |
| 2074 | + ), | |
| 2075 | + ( | |
| 2076 | + "oauth_google_client_id", | |
| 2077 | + form.oauth_google_client_id.unwrap_or_default(), | |
| 2078 | + ), | |
| 2079 | + ( | |
| 2080 | + "oauth_github_enabled", | |
| 2081 | + checkbox_val(&form.oauth_github_enabled), | |
| 2082 | + ), | |
| 2083 | + ( | |
| 2084 | + "oauth_github_client_id", | |
| 2085 | + form.oauth_github_client_id.unwrap_or_default(), | |
| 2086 | + ), | |
| 2087 | + ( | |
| 2088 | + "oauth_discord_enabled", | |
| 2089 | + checkbox_val(&form.oauth_discord_enabled), | |
| 2090 | + ), | |
| 2091 | + ( | |
| 2092 | + "oauth_discord_client_id", | |
| 2093 | + form.oauth_discord_client_id.unwrap_or_default(), | |
| 2094 | + ), | |
| 2095 | + ( | |
| 2096 | + "oauth_microsoft_enabled", | |
| 2097 | + checkbox_val(&form.oauth_microsoft_enabled), | |
| 2098 | + ), | |
| 2099 | + ( | |
| 2100 | + "oauth_microsoft_client_id", | |
| 2101 | + form.oauth_microsoft_client_id.unwrap_or_default(), | |
| 2102 | + ), | |
| 2103 | + ( | |
| 2104 | + "oauth_microsoft_tenant", | |
| 2105 | + form.oauth_microsoft_tenant.unwrap_or_default(), | |
| 2106 | + ), | |
| 2107 | + // TOTP 2FA | |
| 2108 | + ("totp_force_enabled", checkbox_val(&form.totp_force_enabled)), | |
| 1540 | 2109 | ]; |
| 1541 | 2110 | |
| 1542 | 2111 | let mut updated_count = 0u32; |
@@ -1548,10 +2117,22 @@ pub async fn update_app_settings(
| 1548 | 2117 | // OAuth secrets: only update if a non-empty value was submitted (otherwise keep existing) |
| 1549 | 2118 | use rustwarden_core::services::crypto_service::CryptoService; |
| 1550 | 2119 | let secret_fields = [ |
| 1551 | - ("oauth_google_client_secret", &form.oauth_google_client_secret), | |
| 1552 | - ("oauth_github_client_secret", &form.oauth_github_client_secret), | |
| 1553 | - ("oauth_discord_client_secret", &form.oauth_discord_client_secret), | |
| 1554 | - ("oauth_microsoft_client_secret", &form.oauth_microsoft_client_secret), | |
| 2120 | + ( | |
| 2121 | + "oauth_google_client_secret", | |
| 2122 | + &form.oauth_google_client_secret, | |
| 2123 | + ), | |
| 2124 | + ( | |
| 2125 | + "oauth_github_client_secret", | |
| 2126 | + &form.oauth_github_client_secret, | |
| 2127 | + ), | |
| 2128 | + ( | |
| 2129 | + "oauth_discord_client_secret", | |
| 2130 | + &form.oauth_discord_client_secret, | |
| 2131 | + ), | |
| 2132 | + ( | |
| 2133 | + "oauth_microsoft_client_secret", | |
| 2134 | + &form.oauth_microsoft_client_secret, | |
| 2135 | + ), | |
| 1555 | 2136 | ]; |
| 1556 | 2137 | for (key, value) in &secret_fields { |
| 1557 | 2138 | if let Some(v) = value { |
@@ -1732,7 +2313,9 @@ pub async fn list_roles(
| 1732 | 2313 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 1733 | 2314 | |
| 1734 | 2315 | let template = RolesListTemplate { |
| 1735 | - page_title: i18n_service.translate("admin.roles_list.page_title", locale_str).await, | |
| 2316 | + page_title: i18n_service | |
| 2317 | + .translate("admin.roles_list.page_title", locale_str) | |
| 2318 | + .await, | |
| 1736 | 2319 | csrf_token, |
| 1737 | 2320 | app_name: base_context.app_name, |
| 1738 | 2321 | app_version: base_context.app_version, |
@@ -1746,26 +2329,58 @@ pub async fn list_roles(
| 1746 | 2329 | nav_items, |
| 1747 | 2330 | has_pending_password_change: false, |
| 1748 | 2331 | roles: roles_display, |
| 1749 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 2332 | + user_menu_profile: i18n_service | |
| 2333 | + .translate("user_menu.profile", locale_str) | |
| 2334 | + .await, | |
| 1750 | 2335 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 1751 | 2336 | |
| 1752 | 2337 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 1753 | 2338 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 1754 | - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await, | |
| 1755 | - title: i18n_service.translate("admin.roles_list.title", locale_str).await, | |
| 1756 | - create_role: i18n_service.translate("admin.roles_list.create_role", locale_str).await, | |
| 1757 | - table_name: i18n_service.translate("admin.roles_list.table_name", locale_str).await, | |
| 1758 | - table_description: i18n_service.translate("admin.roles_list.table_description", locale_str).await, | |
| 1759 | - table_users: i18n_service.translate("admin.roles_list.table_users", locale_str).await, | |
| 1760 | - table_permissions: i18n_service.translate("admin.roles_list.table_permissions", locale_str).await, | |
| 1761 | - table_actions: i18n_service.translate("admin.roles_list.table_actions", locale_str).await, | |
| 1762 | - edit: i18n_service.translate("admin.roles_list.edit", locale_str).await, | |
| 1763 | - delete_label: i18n_service.translate("admin.roles_list.delete", locale_str).await, | |
| 1764 | - no_roles: i18n_service.translate("admin.roles_list.no_roles", locale_str).await, | |
| 1765 | - protected_hint: i18n_service.translate("admin.roles_list.protected_hint", locale_str).await, | |
| 1766 | - delete_confirm_title: i18n_service.translate("admin.roles_list.delete_confirm_title", locale_str).await, | |
| 1767 | - delete_confirm_message: i18n_service.translate("admin.roles_list.delete_confirm_message", locale_str).await, | |
| 1768 | - delete_button: i18n_service.translate("admin.roles_list.delete_button", locale_str).await, | |
| 2339 | + breadcrumb_roles: i18n_service | |
| 2340 | + .translate("admin.roles_list.breadcrumb_roles", locale_str) | |
| 2341 | + .await, | |
| 2342 | + title: i18n_service | |
| 2343 | + .translate("admin.roles_list.title", locale_str) | |
| 2344 | + .await, | |
| 2345 | + create_role: i18n_service | |
| 2346 | + .translate("admin.roles_list.create_role", locale_str) | |
| 2347 | + .await, | |
| 2348 | + table_name: i18n_service | |
| 2349 | + .translate("admin.roles_list.table_name", locale_str) | |
| 2350 | + .await, | |
| 2351 | + table_description: i18n_service | |
| 2352 | + .translate("admin.roles_list.table_description", locale_str) | |
| 2353 | + .await, | |
| 2354 | + table_users: i18n_service | |
| 2355 | + .translate("admin.roles_list.table_users", locale_str) | |
| 2356 | + .await, | |
| 2357 | + table_permissions: i18n_service | |
| 2358 | + .translate("admin.roles_list.table_permissions", locale_str) | |
| 2359 | + .await, | |
| 2360 | + table_actions: i18n_service | |
| 2361 | + .translate("admin.roles_list.table_actions", locale_str) | |
| 2362 | + .await, | |
| 2363 | + edit: i18n_service | |
| 2364 | + .translate("admin.roles_list.edit", locale_str) | |
| 2365 | + .await, | |
| 2366 | + delete_label: i18n_service | |
| 2367 | + .translate("admin.roles_list.delete", locale_str) | |
| 2368 | + .await, | |
| 2369 | + no_roles: i18n_service | |
| 2370 | + .translate("admin.roles_list.no_roles", locale_str) | |
| 2371 | + .await, | |
| 2372 | + protected_hint: i18n_service | |
| 2373 | + .translate("admin.roles_list.protected_hint", locale_str) | |
| 2374 | + .await, | |
| 2375 | + delete_confirm_title: i18n_service | |
| 2376 | + .translate("admin.roles_list.delete_confirm_title", locale_str) | |
| 2377 | + .await, | |
| 2378 | + delete_confirm_message: i18n_service | |
| 2379 | + .translate("admin.roles_list.delete_confirm_message", locale_str) | |
| 2380 | + .await, | |
| 2381 | + delete_button: i18n_service | |
| 2382 | + .translate("admin.roles_list.delete_button", locale_str) | |
| 2383 | + .await, | |
| 1769 | 2384 | cancel: i18n_service.translate("common.cancel", locale_str).await, |
| 1770 | 2385 | }; |
| 1771 | 2386 |
@@ -1819,7 +2434,9 @@ pub async fn create_role_form(
| 1819 | 2434 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 1820 | 2435 | |
| 1821 | 2436 | let template = RoleFormTemplate { |
| 1822 | - page_title: i18n_service.translate("admin.role_form.title_create", locale_str).await, | |
| 2437 | + page_title: i18n_service | |
| 2438 | + .translate("admin.role_form.title_create", locale_str) | |
| 2439 | + .await, | |
| 1823 | 2440 | csrf_token, |
| 1824 | 2441 | app_name: base_context.app_name, |
| 1825 | 2442 | app_version: base_context.app_version, |
@@ -1838,23 +2455,49 @@ pub async fn create_role_form(
| 1838 | 2455 | form_description: String::new(), |
| 1839 | 2456 | is_protected: false, |
| 1840 | 2457 | permission_groups, |
| 1841 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 2458 | + user_menu_profile: i18n_service | |
| 2459 | + .translate("user_menu.profile", locale_str) | |
| 2460 | + .await, | |
| 1842 | 2461 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 1843 | 2462 | |
| 1844 | 2463 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 1845 | 2464 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 1846 | - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await, | |
| 1847 | - breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_create", locale_str).await, | |
| 1848 | - form_title: i18n_service.translate("admin.role_form.title_create", locale_str).await, | |
| 1849 | - back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await, | |
| 1850 | - name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await, | |
| 1851 | - name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await, | |
| 1852 | - description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await, | |
| 1853 | - description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await, | |
| 1854 | - permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await, | |
| 1855 | - protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await, | |
| 1856 | - cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await, | |
| 1857 | - submit_label: i18n_service.translate("admin.role_form.submit_create", locale_str).await, | |
| 2465 | + breadcrumb_roles: i18n_service | |
| 2466 | + .translate("admin.roles_list.breadcrumb_roles", locale_str) | |
| 2467 | + .await, | |
| 2468 | + breadcrumb_mode: i18n_service | |
| 2469 | + .translate("admin.role_form.breadcrumb_create", locale_str) | |
| 2470 | + .await, | |
| 2471 | + form_title: i18n_service | |
| 2472 | + .translate("admin.role_form.title_create", locale_str) | |
| 2473 | + .await, | |
| 2474 | + back_to_list: i18n_service | |
| 2475 | + .translate("admin.role_form.back_to_list", locale_str) | |
| 2476 | + .await, | |
| 2477 | + name_label: i18n_service | |
| 2478 | + .translate("admin.role_form.name_label", locale_str) | |
| 2479 | + .await, | |
| 2480 | + name_placeholder: i18n_service | |
| 2481 | + .translate("admin.role_form.name_placeholder", locale_str) | |
| 2482 | + .await, | |
| 2483 | + description_label: i18n_service | |
| 2484 | + .translate("admin.role_form.description_label", locale_str) | |
| 2485 | + .await, | |
| 2486 | + description_placeholder: i18n_service | |
| 2487 | + .translate("admin.role_form.description_placeholder", locale_str) | |
| 2488 | + .await, | |
| 2489 | + permissions_label: i18n_service | |
| 2490 | + .translate("admin.role_form.permissions_label", locale_str) | |
| 2491 | + .await, | |
| 2492 | + protected_name_hint: i18n_service | |
| 2493 | + .translate("admin.role_form.protected_name_hint", locale_str) | |
| 2494 | + .await, | |
| 2495 | + cancel: i18n_service | |
| 2496 | + .translate("admin.role_form.cancel", locale_str) | |
| 2497 | + .await, | |
| 2498 | + submit_label: i18n_service | |
| 2499 | + .translate("admin.role_form.submit_create", locale_str) | |
| 2500 | + .await, | |
| 1858 | 2501 | }; |
| 1859 | 2502 | |
| 1860 | 2503 | Ok(template) |
@@ -1863,8 +2506,11 @@ pub async fn create_role_form(
| 1863 | 2506 | /// Form data for role create/update |
| 1864 | 2507 | #[derive(Debug, serde::Deserialize)] |
| 1865 | 2508 | pub struct RoleFormData { |
| 2509 | + /// Unique role name (e.g. `"admin"`, `"user"`). | |
| 1866 | 2510 | pub name: String, |
| 2511 | + /// Optional human-readable description of the role. | |
| 1867 | 2512 | pub description: Option<String>, |
| 2513 | + /// List of permission keys granted by this role. | |
| 1868 | 2514 | #[serde(default)] |
| 1869 | 2515 | pub permissions: Vec<String>, |
| 1870 | 2516 | } |
@@ -1887,7 +2533,10 @@ pub async fn create_role(
| 1887 | 2533 | .into_response()); |
| 1888 | 2534 | } |
| 1889 | 2535 | |
| 1890 | - if RoleService::get_role_by_name(&db, &form_data.name).await?.is_some() { | |
| 2536 | + if RoleService::get_role_by_name(&db, &form_data.name) | |
| 2537 | + .await? | |
| 2538 | + .is_some() | |
| 2539 | + { | |
| 1891 | 2540 | return Ok(FormMessageTemplate { |
| 1892 | 2541 | message_type: "error".to_string(), |
| 1893 | 2542 | message: format!("Role '{}' already exists", form_data.name), |
@@ -1948,7 +2597,9 @@ pub async fn edit_role_form(
| 1948 | 2597 | let template = RoleFormTemplate { |
| 1949 | 2598 | page_title: format!( |
| 1950 | 2599 | "{} {}", |
| 1951 | - i18n_service.translate("admin.role_form.title_edit", locale_str).await, | |
| 2600 | + i18n_service | |
| 2601 | + .translate("admin.role_form.title_edit", locale_str) | |
| 2602 | + .await, | |
| 1952 | 2603 | role.name |
| 1953 | 2604 | ), |
| 1954 | 2605 | csrf_token, |
@@ -1969,23 +2620,49 @@ pub async fn edit_role_form(
| 1969 | 2620 | form_description: role.description.unwrap_or_default(), |
| 1970 | 2621 | is_protected, |
| 1971 | 2622 | permission_groups, |
| 1972 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 2623 | + user_menu_profile: i18n_service | |
| 2624 | + .translate("user_menu.profile", locale_str) | |
| 2625 | + .await, | |
| 1973 | 2626 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 1974 | 2627 | |
| 1975 | 2628 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 1976 | 2629 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 1977 | - breadcrumb_roles: i18n_service.translate("admin.roles_list.breadcrumb_roles", locale_str).await, | |
| 1978 | - breadcrumb_mode: i18n_service.translate("admin.role_form.breadcrumb_edit", locale_str).await, | |
| 1979 | - form_title: i18n_service.translate("admin.role_form.title_edit", locale_str).await, | |
| 1980 | - back_to_list: i18n_service.translate("admin.role_form.back_to_list", locale_str).await, | |
| 1981 | - name_label: i18n_service.translate("admin.role_form.name_label", locale_str).await, | |
| 1982 | - name_placeholder: i18n_service.translate("admin.role_form.name_placeholder", locale_str).await, | |
| 1983 | - description_label: i18n_service.translate("admin.role_form.description_label", locale_str).await, | |
| 1984 | - description_placeholder: i18n_service.translate("admin.role_form.description_placeholder", locale_str).await, | |
| 1985 | - permissions_label: i18n_service.translate("admin.role_form.permissions_label", locale_str).await, | |
| 1986 | - protected_name_hint: i18n_service.translate("admin.role_form.protected_name_hint", locale_str).await, | |
| 1987 | - cancel: i18n_service.translate("admin.role_form.cancel", locale_str).await, | |
| 1988 | - submit_label: i18n_service.translate("admin.role_form.submit_edit", locale_str).await, | |
| 2630 | + breadcrumb_roles: i18n_service | |
| 2631 | + .translate("admin.roles_list.breadcrumb_roles", locale_str) | |
| 2632 | + .await, | |
| 2633 | + breadcrumb_mode: i18n_service | |
| 2634 | + .translate("admin.role_form.breadcrumb_edit", locale_str) | |
| 2635 | + .await, | |
| 2636 | + form_title: i18n_service | |
| 2637 | + .translate("admin.role_form.title_edit", locale_str) | |
| 2638 | + .await, | |
| 2639 | + back_to_list: i18n_service | |
| 2640 | + .translate("admin.role_form.back_to_list", locale_str) | |
| 2641 | + .await, | |
| 2642 | + name_label: i18n_service | |
| 2643 | + .translate("admin.role_form.name_label", locale_str) | |
| 2644 | + .await, | |
| 2645 | + name_placeholder: i18n_service | |
| 2646 | + .translate("admin.role_form.name_placeholder", locale_str) | |
| 2647 | + .await, | |
| 2648 | + description_label: i18n_service | |
| 2649 | + .translate("admin.role_form.description_label", locale_str) | |
| 2650 | + .await, | |
| 2651 | + description_placeholder: i18n_service | |
| 2652 | + .translate("admin.role_form.description_placeholder", locale_str) | |
| 2653 | + .await, | |
| 2654 | + permissions_label: i18n_service | |
| 2655 | + .translate("admin.role_form.permissions_label", locale_str) | |
| 2656 | + .await, | |
| 2657 | + protected_name_hint: i18n_service | |
| 2658 | + .translate("admin.role_form.protected_name_hint", locale_str) | |
| 2659 | + .await, | |
| 2660 | + cancel: i18n_service | |
| 2661 | + .translate("admin.role_form.cancel", locale_str) | |
| 2662 | + .await, | |
| 2663 | + submit_label: i18n_service | |
| 2664 | + .translate("admin.role_form.submit_edit", locale_str) | |
| 2665 | + .await, | |
| 1989 | 2666 | }; |
| 1990 | 2667 | |
| 1991 | 2668 | Ok(template) |
@@ -2128,18 +2805,102 @@ pub async fn admin_errors(
| 2128 | 2805 | } |
| 2129 | 2806 | |
| 2130 | 2807 | let error_defs = vec![ |
| 2131 | - ErrorDef { code_enum: ErrorCode::Auth001, desc_key: "admin.errors.error_auth001_desc", cat_key: "admin.errors.error_auth001_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth001_context", ex_key: "admin.errors.error_auth001_example" }, | |
| 2132 | - ErrorDef { code_enum: ErrorCode::Auth002, desc_key: "admin.errors.error_auth002_desc", cat_key: "admin.errors.error_auth002_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth002_context", ex_key: "admin.errors.error_auth002_example" }, | |
| 2133 | - ErrorDef { code_enum: ErrorCode::Auth003, desc_key: "admin.errors.error_auth003_desc", cat_key: "admin.errors.error_auth003_category", middleware: "auth_required", ctx_key: "admin.errors.error_auth003_context", ex_key: "admin.errors.error_auth003_example" }, | |
| 2134 | - ErrorDef { code_enum: ErrorCode::Csrf001, desc_key: "admin.errors.error_csrf001_desc", cat_key: "admin.errors.error_csrf001_category", middleware: "csrf", ctx_key: "admin.errors.error_csrf001_context", ex_key: "admin.errors.error_csrf001_example" }, | |
| 2135 | - ErrorDef { code_enum: ErrorCode::Csrf002, desc_key: "admin.errors.error_csrf002_desc", cat_key: "admin.errors.error_csrf002_category", middleware: "csrf", ctx_key: "admin.errors.error_csrf002_context", ex_key: "admin.errors.error_csrf002_example" }, | |
| 2136 | - ErrorDef { code_enum: ErrorCode::Rate001, desc_key: "admin.errors.error_rate001_desc", cat_key: "admin.errors.error_rate001_category", middleware: "rate_limit", ctx_key: "admin.errors.error_rate001_context", ex_key: "admin.errors.error_rate001_example" }, | |
| 2137 | - ErrorDef { code_enum: ErrorCode::Valid001, desc_key: "admin.errors.error_valid001_desc", cat_key: "admin.errors.error_valid001_category", middleware: "validation", ctx_key: "admin.errors.error_valid001_context", ex_key: "admin.errors.error_valid001_example" }, | |
| 2138 | - ErrorDef { code_enum: ErrorCode::Valid002, desc_key: "admin.errors.error_valid002_desc", cat_key: "admin.errors.error_valid002_category", middleware: "password_complexity", ctx_key: "admin.errors.error_valid002_context", ex_key: "admin.errors.error_valid002_example" }, | |
| 2139 | - ErrorDef { code_enum: ErrorCode::Rbac001, desc_key: "admin.errors.error_rbac001_desc", cat_key: "admin.errors.error_rbac001_category", middleware: "admin_required", ctx_key: "admin.errors.error_rbac001_context", ex_key: "admin.errors.error_rbac001_example" }, | |
| 2140 | - ErrorDef { code_enum: ErrorCode::Rbac002, desc_key: "admin.errors.error_rbac002_desc", cat_key: "admin.errors.error_rbac002_category", middleware: "admin_required", ctx_key: "admin.errors.error_rbac002_context", ex_key: "admin.errors.error_rbac002_example" }, | |
| 2141 | - ErrorDef { code_enum: ErrorCode::Db001, desc_key: "admin.errors.error_db001_desc", cat_key: "admin.errors.error_db001_category", middleware: "database", ctx_key: "admin.errors.error_db001_context", ex_key: "admin.errors.error_db001_example" }, | |
| 2142 | - ErrorDef { code_enum: ErrorCode::Db002, desc_key: "admin.errors.error_db002_desc", cat_key: "admin.errors.error_db002_category", middleware: "database", ctx_key: "admin.errors.error_db002_context", ex_key: "admin.errors.error_db002_example" }, | |
| 2808 | + ErrorDef { | |
| 2809 | + code_enum: ErrorCode::Auth001, | |
| 2810 | + desc_key: "admin.errors.error_auth001_desc", | |
| 2811 | + cat_key: "admin.errors.error_auth001_category", | |
| 2812 | + middleware: "auth_required", | |
| 2813 | + ctx_key: "admin.errors.error_auth001_context", | |
| 2814 | + ex_key: "admin.errors.error_auth001_example", | |
| 2815 | + }, | |
| 2816 | + ErrorDef { | |
| 2817 | + code_enum: ErrorCode::Auth002, | |
| 2818 | + desc_key: "admin.errors.error_auth002_desc", | |
| 2819 | + cat_key: "admin.errors.error_auth002_category", | |
| 2820 | + middleware: "auth_required", | |
| 2821 | + ctx_key: "admin.errors.error_auth002_context", | |
| 2822 | + ex_key: "admin.errors.error_auth002_example", | |
| 2823 | + }, | |
| 2824 | + ErrorDef { | |
| 2825 | + code_enum: ErrorCode::Auth003, | |
| 2826 | + desc_key: "admin.errors.error_auth003_desc", | |
| 2827 | + cat_key: "admin.errors.error_auth003_category", | |
| 2828 | + middleware: "auth_required", | |
| 2829 | + ctx_key: "admin.errors.error_auth003_context", | |
| 2830 | + ex_key: "admin.errors.error_auth003_example", | |
| 2831 | + }, | |
| 2832 | + ErrorDef { | |
| 2833 | + code_enum: ErrorCode::Csrf001, | |
| 2834 | + desc_key: "admin.errors.error_csrf001_desc", | |
| 2835 | + cat_key: "admin.errors.error_csrf001_category", | |
| 2836 | + middleware: "csrf", | |
| 2837 | + ctx_key: "admin.errors.error_csrf001_context", | |
| 2838 | + ex_key: "admin.errors.error_csrf001_example", | |
| 2839 | + }, | |
| 2840 | + ErrorDef { | |
| 2841 | + code_enum: ErrorCode::Csrf002, | |
| 2842 | + desc_key: "admin.errors.error_csrf002_desc", | |
| 2843 | + cat_key: "admin.errors.error_csrf002_category", | |
| 2844 | + middleware: "csrf", | |
| 2845 | + ctx_key: "admin.errors.error_csrf002_context", | |
| 2846 | + ex_key: "admin.errors.error_csrf002_example", | |
| 2847 | + }, | |
| 2848 | + ErrorDef { | |
| 2849 | + code_enum: ErrorCode::Rate001, | |
| 2850 | + desc_key: "admin.errors.error_rate001_desc", | |
| 2851 | + cat_key: "admin.errors.error_rate001_category", | |
| 2852 | + middleware: "rate_limit", | |
| 2853 | + ctx_key: "admin.errors.error_rate001_context", | |
| 2854 | + ex_key: "admin.errors.error_rate001_example", | |
| 2855 | + }, | |
| 2856 | + ErrorDef { | |
| 2857 | + code_enum: ErrorCode::Valid001, | |
| 2858 | + desc_key: "admin.errors.error_valid001_desc", | |
| 2859 | + cat_key: "admin.errors.error_valid001_category", | |
| 2860 | + middleware: "validation", | |
| 2861 | + ctx_key: "admin.errors.error_valid001_context", | |
| 2862 | + ex_key: "admin.errors.error_valid001_example", | |
| 2863 | + }, | |
| 2864 | + ErrorDef { | |
| 2865 | + code_enum: ErrorCode::Valid002, | |
| 2866 | + desc_key: "admin.errors.error_valid002_desc", | |
| 2867 | + cat_key: "admin.errors.error_valid002_category", | |
| 2868 | + middleware: "password_complexity", | |
| 2869 | + ctx_key: "admin.errors.error_valid002_context", | |
| 2870 | + ex_key: "admin.errors.error_valid002_example", | |
| 2871 | + }, | |
| 2872 | + ErrorDef { | |
| 2873 | + code_enum: ErrorCode::Rbac001, | |
| 2874 | + desc_key: "admin.errors.error_rbac001_desc", | |
| 2875 | + cat_key: "admin.errors.error_rbac001_category", | |
| 2876 | + middleware: "admin_required", | |
| 2877 | + ctx_key: "admin.errors.error_rbac001_context", | |
| 2878 | + ex_key: "admin.errors.error_rbac001_example", | |
| 2879 | + }, | |
| 2880 | + ErrorDef { | |
| 2881 | + code_enum: ErrorCode::Rbac002, | |
| 2882 | + desc_key: "admin.errors.error_rbac002_desc", | |
| 2883 | + cat_key: "admin.errors.error_rbac002_category", | |
| 2884 | + middleware: "admin_required", | |
| 2885 | + ctx_key: "admin.errors.error_rbac002_context", | |
| 2886 | + ex_key: "admin.errors.error_rbac002_example", | |
| 2887 | + }, | |
| 2888 | + ErrorDef { | |
| 2889 | + code_enum: ErrorCode::Db001, | |
| 2890 | + desc_key: "admin.errors.error_db001_desc", | |
| 2891 | + cat_key: "admin.errors.error_db001_category", | |
| 2892 | + middleware: "database", | |
| 2893 | + ctx_key: "admin.errors.error_db001_context", | |
| 2894 | + ex_key: "admin.errors.error_db001_example", | |
| 2895 | + }, | |
| 2896 | + ErrorDef { | |
| 2897 | + code_enum: ErrorCode::Db002, | |
| 2898 | + desc_key: "admin.errors.error_db002_desc", | |
| 2899 | + cat_key: "admin.errors.error_db002_category", | |
| 2900 | + middleware: "database", | |
| 2901 | + ctx_key: "admin.errors.error_db002_context", | |
| 2902 | + ex_key: "admin.errors.error_db002_example", | |
| 2903 | + }, | |
| 2143 | 2904 | ]; |
| 2144 | 2905 | |
| 2145 | 2906 | let mut error_codes = Vec::new(); |
@@ -2157,7 +2918,9 @@ pub async fn admin_errors(
| 2157 | 2918 | let nav_items = build_admin_nav_items(&base_context.roles, &i18n_service, &locale, &db).await; |
| 2158 | 2919 | |
| 2159 | 2920 | let template = AdminErrorsTemplate { |
| 2160 | - page_title: i18n_service.translate("admin.errors.title", locale_str).await, | |
| 2921 | + page_title: i18n_service | |
| 2922 | + .translate("admin.errors.title", locale_str) | |
| 2923 | + .await, | |
| 2161 | 2924 | csrf_token, |
| 2162 | 2925 | app_name: base_context.app_name, |
| 2163 | 2926 | app_version: base_context.app_version, |
@@ -2171,28 +2934,90 @@ pub async fn admin_errors(
| 2171 | 2934 | nav_items, |
| 2172 | 2935 | has_pending_password_change: false, |
| 2173 | 2936 | error_codes, |
| 2174 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 2937 | + user_menu_profile: i18n_service | |
| 2938 | + .translate("user_menu.profile", locale_str) | |
| 2939 | + .await, | |
| 2175 | 2940 | user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, |
| 2176 | 2941 | |
| 2177 | 2942 | breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, |
| 2178 | 2943 | breadcrumb_admin: i18n_service.translate("breadcrumb.admin", locale_str).await, |
| 2179 | - breadcrumb_errors: i18n_service.translate("admin.errors.breadcrumb_errors", locale_str).await, | |
| 2180 | - title: i18n_service.translate("admin.errors.title", locale_str).await, | |
| 2181 | - description: i18n_service.translate("admin.errors.description", locale_str).await, | |
| 2182 | - table_code: i18n_service.translate("admin.errors.table_code", locale_str).await, | |
| 2183 | - table_description: i18n_service.translate("admin.errors.table_description", locale_str).await, | |
| 2184 | - table_category: i18n_service.translate("admin.errors.table_category", locale_str).await, | |
| 2185 | - table_middleware: i18n_service.translate("admin.errors.table_middleware", locale_str).await, | |
| 2186 | - table_context: i18n_service.translate("admin.errors.table_context", locale_str).await, | |
| 2187 | - table_example: i18n_service.translate("admin.errors.table_example", locale_str).await, | |
| 2188 | - legend_title: i18n_service.translate("admin.errors.legend_title", locale_str).await, | |
| 2189 | - legend_code: i18n_service.translate("admin.errors.legend_code", locale_str).await, | |
| 2190 | - legend_description: i18n_service.translate("admin.errors.legend_description", locale_str).await, | |
| 2191 | - legend_category: i18n_service.translate("admin.errors.legend_category", locale_str).await, | |
| 2192 | - legend_middleware: i18n_service.translate("admin.errors.legend_middleware", locale_str).await, | |
| 2193 | - legend_context: i18n_service.translate("admin.errors.legend_context", locale_str).await, | |
| 2194 | - legend_example: i18n_service.translate("admin.errors.legend_example", locale_str).await, | |
| 2944 | + breadcrumb_errors: i18n_service | |
| 2945 | + .translate("admin.errors.breadcrumb_errors", locale_str) | |
| 2946 | + .await, | |
| 2947 | + title: i18n_service | |
| 2948 | + .translate("admin.errors.title", locale_str) | |
| 2949 | + .await, | |
| 2950 | + description: i18n_service | |
| 2951 | + .translate("admin.errors.description", locale_str) | |
| 2952 | + .await, | |
| 2953 | + table_code: i18n_service | |
| 2954 | + .translate("admin.errors.table_code", locale_str) | |
| 2955 | + .await, | |
| 2956 | + table_description: i18n_service | |
| 2957 | + .translate("admin.errors.table_description", locale_str) | |
| 2958 | + .await, | |
| 2959 | + table_category: i18n_service | |
| 2960 | + .translate("admin.errors.table_category", locale_str) | |
| 2961 | + .await, | |
| 2962 | + table_middleware: i18n_service | |
| 2963 | + .translate("admin.errors.table_middleware", locale_str) | |
| 2964 | + .await, | |
| 2965 | + table_context: i18n_service | |
| 2966 | + .translate("admin.errors.table_context", locale_str) | |
| 2967 | + .await, | |
| 2968 | + table_example: i18n_service | |
| 2969 | + .translate("admin.errors.table_example", locale_str) | |
| 2970 | + .await, | |
| 2971 | + legend_title: i18n_service | |
| 2972 | + .translate("admin.errors.legend_title", locale_str) | |
| 2973 | + .await, | |
| 2974 | + legend_code: i18n_service | |
| 2975 | + .translate("admin.errors.legend_code", locale_str) | |
| 2976 | + .await, | |
| 2977 | + legend_description: i18n_service | |
| 2978 | + .translate("admin.errors.legend_description", locale_str) | |
| 2979 | + .await, | |
| 2980 | + legend_category: i18n_service | |
| 2981 | + .translate("admin.errors.legend_category", locale_str) | |
| 2982 | + .await, | |
| 2983 | + legend_middleware: i18n_service | |
| 2984 | + .translate("admin.errors.legend_middleware", locale_str) | |
| 2985 | + .await, | |
| 2986 | + legend_context: i18n_service | |
| 2987 | + .translate("admin.errors.legend_context", locale_str) | |
| 2988 | + .await, | |
| 2989 | + legend_example: i18n_service | |
| 2990 | + .translate("admin.errors.legend_example", locale_str) | |
| 2991 | + .await, | |
| 2195 | 2992 | }; |
| 2196 | 2993 | |
| 2197 | 2994 | Ok(template) |
| 2198 | 2995 | } |
| 2996 | + | |
| 2997 | +/// POST /admin/users/{id}/reset-totp — Admin resets a user's 2FA | |
| 2998 | +pub async fn reset_user_totp( | |
| 2999 | + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>, | |
| 3000 | + State(db): State<sea_orm::DatabaseConnection>, | |
| 3001 | + axum::extract::Path(user_id): axum::extract::Path<uuid::Uuid>, | |
| 3002 | +) -> Result<axum::response::Response, rustwarden_core::error::AppError> { | |
| 3003 | + use axum::response::IntoResponse; | |
| 3004 | + use rustwarden_core::services::totp_service::TotpService; | |
| 3005 | + | |
| 3006 | + // Verify the target user exists | |
| 3007 | + let _user = rustwarden_core::services::user_service::UserService::get_user_by_id(&db, user_id) | |
| 3008 | + .await? | |
| 3009 | + .ok_or_else(|| rustwarden_core::error::AppError::NotFound("User not found".to_string()))?; | |
| 3010 | + | |
| 3011 | + // Disable TOTP (delete the row) | |
| 3012 | + match TotpService::disable_totp(&db, user_id).await { | |
| 3013 | + Ok(()) => {} | |
| 3014 | + Err(rustwarden_core::error::AppError::TotpNotEnabled) => { | |
| 3015 | + // Already disabled, not an error | |
| 3016 | + } | |
| 3017 | + Err(e) => return Err(e), | |
| 3018 | + } | |
| 3019 | + | |
| 3020 | + tracing::info!(admin = %claims.username, target_user = %user_id, "TOTP_DISABLED_BY_ADMIN"); | |
| 3021 | + | |
| 3022 | + Ok(axum::Json(serde_json::json!({ "success": true })).into_response()) | |
| 3023 | +} |
M
crates/rustwarden-ui/src/handlers/dashboard_handler.rs
+6
-0
@@ -9,11 +9,17 @@ use serde::Serialize;
| 9 | 9 | /// DTO for a dashboard widget (API response) |
| 10 | 10 | #[derive(Debug, Clone, Serialize)] |
| 11 | 11 | pub struct DashboardWidgetDto { |
| 12 | + /// Unique widget identifier. | |
| 12 | 13 | pub id: String, |
| 14 | + /// Identifier of the application that owns this widget. | |
| 13 | 15 | pub app_id: String, |
| 16 | + /// Display title shown in the widget header. | |
| 14 | 17 | pub title: String, |
| 18 | + /// Front-end component name used to render the widget. | |
| 15 | 19 | pub component: String, |
| 20 | + /// Display order relative to other widgets. | |
| 16 | 21 | pub order: i32, |
| 22 | + /// Size class of the widget (e.g. `"small"`, `"large"`). | |
| 17 | 23 | pub size: String, |
| 18 | 24 | } |
| 19 | 25 |
M
crates/rustwarden-ui/src/handlers/email_handler.rs
+2
-2
@@ -9,8 +9,6 @@ use crate::dto::{
| 9 | 9 | EmailDeliveryStatusListResponse, EmailDeliveryStatusResponse, EmailQueueDetailResponse, |
| 10 | 10 | EmailQueueEntry, EmailQueueListResponse, RetryEmailResponse, |
| 11 | 11 | }; |
| 12 | -use rustwarden_core::error::AppError; | |
| 13 | -use rustwarden_core::services::email_queue_service::EmailQueueService; | |
| 14 | 12 | use axum::{ |
| 15 | 13 | extract::{Path, Query, State}, |
| 16 | 14 | http::StatusCode, |
@@ -18,6 +16,8 @@ use axum::{
| 18 | 16 | Json, |
| 19 | 17 | }; |
| 20 | 18 | use chrono::{DateTime, Utc}; |
| 19 | +use rustwarden_core::error::AppError; | |
| 20 | +use rustwarden_core::services::email_queue_service::EmailQueueService; | |
| 21 | 21 | use sea_orm::DatabaseConnection; |
| 22 | 22 | use serde::Deserialize; |
| 23 | 23 | use uuid::Uuid; |
M
crates/rustwarden-ui/src/handlers/home_handler.rs
+1
-13
@@ -44,7 +44,6 @@ struct HomeTemplate {
| 44 | 44 | account_stats: Vec<AccountStatDTO>, |
| 45 | 45 | user_menu_profile: String, |
| 46 | 46 | user_menu_logout: String, |
| 47 | - | |
| 48 | 47 | } |
| 49 | 48 | |
| 50 | 49 | /// Simplified navigation item for templates |
@@ -120,10 +119,7 @@ pub async fn home(
| 120 | 119 | let user_menu_profile = i18n_service |
| 121 | 120 | .translate("user_menu.profile", locale_str) |
| 122 | 121 | .await; |
| 123 | - let user_menu_logout = i18n_service | |
| 124 | - .translate("user_menu.logout", locale_str) | |
| 125 | - .await; | |
| 126 | - | |
| 122 | + let user_menu_logout = i18n_service.translate("user_menu.logout", locale_str).await; | |
| 127 | 123 | |
| 128 | 124 | let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; |
| 129 | 125 | let nav_items: Vec<NavItem> = navigation |
@@ -180,11 +176,3 @@ pub async fn home(
| 180 | 176 | |
| 181 | 177 | Ok(template) |
| 182 | 178 | } |
| 183 | - | |
| 184 | -#[cfg(test)] | |
| 185 | -mod tests { | |
| 186 | - use super::*; | |
| 187 | - | |
| 188 | - #[test] | |
| 189 | - fn test_home_template_compiles() {} | |
| 190 | -} |
M
crates/rustwarden-ui/src/handlers/login_handler.rs
+133
-23
@@ -130,7 +130,7 @@ pub async fn login_page(
| 130 | 130 | .get("locale") |
| 131 | 131 | .and_then(|c| { |
| 132 | 132 | let value = c.value(); |
| 133 | - if matches!(value, "fr" | "en" | "de" | "es") { | |
| 133 | + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 134 | 134 | Some(value.to_string()) |
| 135 | 135 | } else { |
| 136 | 136 | None |
@@ -159,6 +159,16 @@ pub async fn login_page(
| 159 | 159 | "Español".to_string(), |
| 160 | 160 | current_locale == "es", |
| 161 | 161 | ), |
| 162 | + ( | |
| 163 | + "it".to_string(), | |
| 164 | + "Italiano".to_string(), | |
| 165 | + current_locale == "it", | |
| 166 | + ), | |
| 167 | + ( | |
| 168 | + "pt".to_string(), | |
| 169 | + "Português".to_string(), | |
| 170 | + current_locale == "pt", | |
| 171 | + ), | |
| 162 | 172 | ]; |
| 163 | 173 | |
| 164 | 174 | let locale_str = ¤t_locale; |
@@ -177,7 +187,9 @@ pub async fn login_page(
| 177 | 187 | .await; |
| 178 | 188 | let username_label = i18n_service.translate("login.username", locale_str).await; |
| 179 | 189 | let password_label = i18n_service.translate("login.password", locale_str).await; |
| 180 | - let email_label = i18n_service.translate("login.email_label", locale_str).await; | |
| 190 | + let email_label = i18n_service | |
| 191 | + .translate("login.email_label", locale_str) | |
| 192 | + .await; | |
| 181 | 193 | let username_placeholder = i18n_service |
| 182 | 194 | .translate("login.username_placeholder", locale_str) |
| 183 | 195 | .await; |
@@ -286,9 +298,11 @@ pub async fn login_page(
| 286 | 298 | resend_prompt, |
| 287 | 299 | register_success, |
| 288 | 300 | register_error, |
| 289 | - pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db) | |
| 290 | - .await | |
| 291 | - .min_length, | |
| 301 | + pw_min_length: rustwarden_core::middleware::password_complexity::PasswordRules::from_db( | |
| 302 | + &db, | |
| 303 | + ) | |
| 304 | + .await | |
| 305 | + .min_length, | |
| 292 | 306 | }; |
| 293 | 307 | |
| 294 | 308 | Ok(template) |
@@ -307,7 +321,7 @@ pub async fn resend_verification_page(
| 307 | 321 | .get("locale") |
| 308 | 322 | .and_then(|c| { |
| 309 | 323 | let value = c.value(); |
| 310 | - if matches!(value, "fr" | "en" | "de" | "es") { | |
| 324 | + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 311 | 325 | Some(value.to_string()) |
| 312 | 326 | } else { |
| 313 | 327 | None |
@@ -316,17 +330,39 @@ pub async fn resend_verification_page(
| 316 | 330 | .unwrap_or_else(|| "fr".to_string()); |
| 317 | 331 | |
| 318 | 332 | let locale_str = ¤t_locale; |
| 319 | - let page_title = i18n_service.translate("resend_verification.page_title", locale_str).await; | |
| 320 | - let title = i18n_service.translate("resend_verification.title", locale_str).await; | |
| 321 | - let subtitle = i18n_service.translate("resend_verification.subtitle", locale_str).await; | |
| 322 | - let email_label = i18n_service.translate("resend_verification.email_label", locale_str).await; | |
| 323 | - let email_placeholder = i18n_service.translate("resend_verification.email_placeholder", locale_str).await; | |
| 324 | - let submit = i18n_service.translate("resend_verification.submit", locale_str).await; | |
| 325 | - let back_to_login = i18n_service.translate("resend_verification.back_to_login", locale_str).await; | |
| 326 | - let error_default = i18n_service.translate("resend_verification.error_default", locale_str).await; | |
| 327 | - let success_default = i18n_service.translate("resend_verification.success_default", locale_str).await; | |
| 328 | - let error_missing_email = i18n_service.translate("resend_verification.error_missing_email", locale_str).await; | |
| 329 | - let error_cannot_send = i18n_service.translate("resend_verification.error_cannot_send", locale_str).await; | |
| 333 | + let page_title = i18n_service | |
| 334 | + .translate("resend_verification.page_title", locale_str) | |
| 335 | + .await; | |
| 336 | + let title = i18n_service | |
| 337 | + .translate("resend_verification.title", locale_str) | |
| 338 | + .await; | |
| 339 | + let subtitle = i18n_service | |
| 340 | + .translate("resend_verification.subtitle", locale_str) | |
| 341 | + .await; | |
| 342 | + let email_label = i18n_service | |
| 343 | + .translate("resend_verification.email_label", locale_str) | |
| 344 | + .await; | |
| 345 | + let email_placeholder = i18n_service | |
| 346 | + .translate("resend_verification.email_placeholder", locale_str) | |
| 347 | + .await; | |
| 348 | + let submit = i18n_service | |
| 349 | + .translate("resend_verification.submit", locale_str) | |
| 350 | + .await; | |
| 351 | + let back_to_login = i18n_service | |
| 352 | + .translate("resend_verification.back_to_login", locale_str) | |
| 353 | + .await; | |
| 354 | + let error_default = i18n_service | |
| 355 | + .translate("resend_verification.error_default", locale_str) | |
| 356 | + .await; | |
| 357 | + let success_default = i18n_service | |
| 358 | + .translate("resend_verification.success_default", locale_str) | |
| 359 | + .await; | |
| 360 | + let error_missing_email = i18n_service | |
| 361 | + .translate("resend_verification.error_missing_email", locale_str) | |
| 362 | + .await; | |
| 363 | + let error_cannot_send = i18n_service | |
| 364 | + .translate("resend_verification.error_cannot_send", locale_str) | |
| 365 | + .await; | |
| 330 | 366 | |
| 331 | 367 | let layout = LayoutContext::new("", ""); |
| 332 | 368 |
@@ -383,7 +419,7 @@ pub async fn reset_password_page(
| 383 | 419 | .get("locale") |
| 384 | 420 | .and_then(|c| { |
| 385 | 421 | let value = c.value(); |
| 386 | - if matches!(value, "fr" | "en" | "de" | "es") { | |
| 422 | + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 387 | 423 | Some(value.to_string()) |
| 388 | 424 | } else { |
| 389 | 425 | None |
@@ -490,10 +526,84 @@ pub async fn reset_password_page(
| 490 | 526 | Ok(template) |
| 491 | 527 | } |
| 492 | 528 | |
| 493 | -#[cfg(test)] | |
| 494 | -mod tests { | |
| 495 | - use super::*; | |
| 529 | +/// Login 2FA page template | |
| 530 | +#[derive(Template)] | |
| 531 | +#[template(path = "login_2fa.html")] | |
| 532 | +#[allow(dead_code)] | |
| 533 | +struct Login2faTemplate { | |
| 534 | + app_name: String, | |
| 535 | + app_version: String, | |
| 536 | + page_title: String, | |
| 537 | + title: String, | |
| 538 | + subtitle: String, | |
| 539 | + code_label: String, | |
| 540 | + backup_code_label: String, | |
| 541 | + verify_button: String, | |
| 542 | + use_backup_code: String, | |
| 543 | + use_totp_code: String, | |
| 544 | + back_to_login: String, | |
| 545 | + error_invalid_code: String, | |
| 546 | + error_network: String, | |
| 547 | +} | |
| 548 | + | |
| 549 | +rustwarden_core::impl_template_response!(Login2faTemplate); | |
| 550 | + | |
| 551 | +/// GET /login/2fa — 2FA verification page | |
| 552 | +pub async fn login_2fa_page( | |
| 553 | + headers: HeaderMap, | |
| 554 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 555 | +) -> Result<impl IntoResponse, rustwarden_core::error::AppError> { | |
| 556 | + let jar = CookieJar::from_headers(&headers); | |
| 557 | + let current_locale = jar | |
| 558 | + .get("locale") | |
| 559 | + .and_then(|c| { | |
| 560 | + let value = c.value(); | |
| 561 | + if matches!(value, "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 562 | + Some(value.to_string()) | |
| 563 | + } else { | |
| 564 | + None | |
| 565 | + } | |
| 566 | + }) | |
| 567 | + .unwrap_or_else(|| "fr".to_string()); | |
| 568 | + | |
| 569 | + let locale_str = ¤t_locale; | |
| 570 | + let layout = LayoutContext::new("", ""); | |
| 571 | + | |
| 572 | + let template = Login2faTemplate { | |
| 573 | + app_name: layout.app_name.clone(), | |
| 574 | + app_version: layout.app_version.clone(), | |
| 575 | + page_title: i18n_service | |
| 576 | + .translate("login_2fa.page_title", locale_str) | |
| 577 | + .await, | |
| 578 | + title: i18n_service.translate("login_2fa.title", locale_str).await, | |
| 579 | + subtitle: i18n_service | |
| 580 | + .translate("login_2fa.subtitle", locale_str) | |
| 581 | + .await, | |
| 582 | + code_label: i18n_service | |
| 583 | + .translate("login_2fa.code_label", locale_str) | |
| 584 | + .await, | |
| 585 | + backup_code_label: i18n_service | |
| 586 | + .translate("login_2fa.backup_code_label", locale_str) | |
| 587 | + .await, | |
| 588 | + verify_button: i18n_service | |
| 589 | + .translate("login_2fa.verify_button", locale_str) | |
| 590 | + .await, | |
| 591 | + use_backup_code: i18n_service | |
| 592 | + .translate("login_2fa.use_backup_code", locale_str) | |
| 593 | + .await, | |
| 594 | + use_totp_code: i18n_service | |
| 595 | + .translate("login_2fa.use_totp_code", locale_str) | |
| 596 | + .await, | |
| 597 | + back_to_login: i18n_service | |
| 598 | + .translate("login_2fa.back_to_login", locale_str) | |
| 599 | + .await, | |
| 600 | + error_invalid_code: i18n_service | |
| 601 | + .translate("login_2fa.error_invalid_code", locale_str) | |
| 602 | + .await, | |
| 603 | + error_network: i18n_service | |
| 604 | + .translate("login_2fa.error_network", locale_str) | |
| 605 | + .await, | |
| 606 | + }; | |
| 496 | 607 | |
| 497 | - #[test] | |
| 498 | - fn test_login_template_compiles() {} | |
| 608 | + Ok(template) | |
| 499 | 609 | } |
M
crates/rustwarden-ui/src/handlers/mod.rs
+14
-1
@@ -1,13 +1,26 @@
| 1 | 1 | /// API handlers module |
| 2 | 2 | /// |
| 3 | 3 | /// Axum request handlers for the Rustwarden framework. |
| 4 | + | |
| 5 | +/// Admin panel handlers (users, roles, settings). | |
| 4 | 6 | pub mod admin_handler; |
| 7 | +/// Dashboard widget handlers. | |
| 5 | 8 | pub mod dashboard_handler; |
| 9 | +/// Email delivery status handlers. | |
| 6 | 10 | pub mod email_handler; |
| 11 | +/// Health-check endpoint handler. | |
| 7 | 12 | pub mod health; |
| 13 | +/// Home page handler. | |
| 8 | 14 | pub mod home_handler; |
| 15 | +/// Login and logout handlers. | |
| 9 | 16 | pub mod login_handler; |
| 17 | +/// Navigation menu handler. | |
| 10 | 18 | pub mod navigation_handler; |
| 19 | +/// Root redirect handler. | |
| 11 | 20 | pub mod root_handler; |
| 12 | -pub mod settings_handler; | |
| 21 | +/// Settings sub-handlers (profile, security, preferences). | |
| 22 | +pub mod settings; | |
| 23 | +/// TOTP two-factor authentication handlers. | |
| 24 | +pub mod totp_handler; | |
| 25 | +/// User management handlers. | |
| 13 | 26 | pub mod user_handler; |
M
crates/rustwarden-ui/src/handlers/navigation_handler.rs
+6
-0
@@ -9,11 +9,17 @@ use serde::Serialize;
| 9 | 9 | /// DTO for a navigation item (API response) |
| 10 | 10 | #[derive(Debug, Clone, Serialize)] |
| 11 | 11 | pub struct NavigationItemDto { |
| 12 | + /// Unique identifier for this navigation item. | |
| 12 | 13 | pub id: String, |
| 14 | + /// Display label shown in the navigation menu. | |
| 13 | 15 | pub name: String, |
| 16 | + /// Icon class or name used by the front-end. | |
| 14 | 17 | pub icon: String, |
| 18 | + /// Target route path for this navigation item. | |
| 15 | 19 | pub route: String, |
| 20 | + /// Display order relative to sibling items. | |
| 16 | 21 | pub order: i32, |
| 22 | + /// Parent item identifier for nested menus. | |
| 17 | 23 | #[serde(skip_serializing_if = "Option::is_none")] |
| 18 | 24 | pub parent_id: Option<String>, |
| 19 | 25 | } |
M
crates/rustwarden-ui/src/handlers/root_handler.rs
+0
-11
@@ -76,14 +76,3 @@ pub async fn root_redirect(request: Request) -> impl IntoResponse {
| 76 | 76 | } |
| 77 | 77 | } |
| 78 | 78 | } |
| 79 | - | |
| 80 | -#[cfg(test)] | |
| 81 | -mod tests { | |
| 82 | - use super::*; | |
| 83 | - | |
| 84 | - #[test] | |
| 85 | - fn test_root_handler_compiles() { | |
| 86 | - // This test verifies the handler compiles | |
| 87 | - // Integration tests will test actual behavior | |
| 88 | - } | |
| 89 | -} |
A
crates/rustwarden-ui/src/handlers/settings/mod.rs
+84
-0
@@ -0,0 +1,84 @@
| 1 | +//! Settings handlers — split by domain: profile, security, preferences. | |
| 2 | +//! | |
| 3 | +//! Since the module split (see `docs/CHANGELOG_SETTINGS_SPLIT.md`), the | |
| 4 | +//! handlers that were in `handlers::settings_handler` are distributed across | |
| 5 | +//! `handlers::settings::profile`, `handlers::settings::security` and | |
| 6 | +//! `handlers::settings::preferences`. Consumer apps must update their import | |
| 7 | +//! paths accordingly. | |
| 8 | + | |
| 9 | +pub mod preferences; | |
| 10 | +pub mod profile; | |
| 11 | +pub mod security; | |
| 12 | + | |
| 13 | +use crate::dto::navigation_dto::NavigationDTO; | |
| 14 | + | |
| 15 | +/// Custom filters shared by Askama templates in settings pages. | |
| 16 | +/// | |
| 17 | +/// Exposed as `pub mod` so that the `Template` derive expansion in | |
| 18 | +/// sub-modules can resolve `filters::default` / `filters::length` after | |
| 19 | +/// a simple `use super::filters;` import. | |
| 20 | +#[allow(dead_code, missing_docs)] | |
| 21 | +pub mod filters { | |
| 22 | + use askama::Result; | |
| 23 | + | |
| 24 | + /// Askama filter: returns `value` as a string, or `default_value` if it is empty. | |
| 25 | + #[allow(dead_code)] | |
| 26 | + #[askama::filter_fn] | |
| 27 | + pub fn default<T: std::fmt::Display>( | |
| 28 | + value: T, | |
| 29 | + _values: &dyn askama::Values, | |
| 30 | + default_value: &str, | |
| 31 | + ) -> Result<String> { | |
| 32 | + let value_str = value.to_string(); | |
| 33 | + if value_str.is_empty() { | |
| 34 | + Ok(default_value.to_string()) | |
| 35 | + } else { | |
| 36 | + Ok(value_str) | |
| 37 | + } | |
| 38 | + } | |
| 39 | + | |
| 40 | + /// Askama filter: returns the number of elements in an iterable. | |
| 41 | + #[askama::filter_fn] | |
| 42 | + pub fn length<I: IntoIterator>(value: I, _values: &dyn askama::Values) -> Result<usize> { | |
| 43 | + Ok(value.into_iter().count()) | |
| 44 | + } | |
| 45 | +} | |
| 46 | + | |
| 47 | +/// Simplified navigation item for templates | |
| 48 | +#[derive(Clone)] | |
| 49 | +#[allow(dead_code)] | |
| 50 | +pub(crate) struct NavItem { | |
| 51 | + pub(crate) id: String, | |
| 52 | + pub(crate) label: String, | |
| 53 | + pub(crate) icon: String, | |
| 54 | + pub(crate) url: String, | |
| 55 | + pub(crate) active: bool, | |
| 56 | + pub(crate) enabled: bool, | |
| 57 | + pub(crate) badge: String, | |
| 58 | + pub(crate) has_badge: bool, | |
| 59 | + pub(crate) disabled_message: String, | |
| 60 | + pub(crate) has_disabled_message: bool, | |
| 61 | +} | |
| 62 | + | |
| 63 | +/// Helper to build NavItems from NavigationDTO | |
| 64 | +pub(crate) fn build_nav_items_from_dto( | |
| 65 | + navigation: &NavigationDTO, | |
| 66 | + active_id: &str, | |
| 67 | +) -> Vec<NavItem> { | |
| 68 | + navigation | |
| 69 | + .items | |
| 70 | + .iter() | |
| 71 | + .map(|item| NavItem { | |
| 72 | + id: item.id.clone(), | |
| 73 | + label: item.label.clone(), | |
| 74 | + icon: item.icon.clone(), | |
| 75 | + url: item.url.clone(), | |
| 76 | + active: item.id == active_id, | |
| 77 | + enabled: item.enabled, | |
| 78 | + badge: item.badge.clone(), | |
| 79 | + has_badge: item.has_badge, | |
| 80 | + disabled_message: item.disabled_message.clone(), | |
| 81 | + has_disabled_message: item.has_disabled_message, | |
| 82 | + }) | |
| 83 | + .collect() | |
| 84 | +} |
A
crates/rustwarden-ui/src/handlers/settings/preferences.rs
+450
-0
@@ -0,0 +1,450 @@
| 1 | +//! Settings - User preferences (theme, language). | |
| 2 | + | |
| 3 | +use askama::Template; | |
| 4 | +use axum::{extract::State, response::IntoResponse, Extension, Json}; | |
| 5 | +use sea_orm::DatabaseConnection; | |
| 6 | +use serde::Deserialize; | |
| 7 | +use std::sync::Arc; | |
| 8 | +use tower_sessions::Session; | |
| 9 | + | |
| 10 | +use super::{build_nav_items_from_dto, NavItem}; | |
| 11 | +use crate::dto::navigation_dto::NavigationDTO; | |
| 12 | +use crate::dto::user_profile_dto::UserProfileDTO; | |
| 13 | +use rustwarden_core::auth::jwt::Claims; | |
| 14 | +use rustwarden_core::error::AppError; | |
| 15 | +use rustwarden_core::i18n::extractor::Locale; | |
| 16 | +use rustwarden_core::i18n::service::I18nService; | |
| 17 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 18 | +use rustwarden_core::services::user_service::UserService; | |
| 19 | + | |
| 20 | +/// Inline DTO: Update preferences request | |
| 21 | +#[derive(Debug, Deserialize)] | |
| 22 | +pub struct UpdatePreferencesRequest { | |
| 23 | + /// UI theme preference (e.g. `"dark"`, `"light"`). | |
| 24 | + pub theme: Option<String>, | |
| 25 | + /// Interface language code (e.g. `"en"`, `"fr"`). | |
| 26 | + pub language: Option<String>, | |
| 27 | +} | |
| 28 | +/// GET /settings/preferences - Display user preferences (JSON) | |
| 29 | +pub async fn get_preferences( | |
| 30 | + State(db): State<DatabaseConnection>, | |
| 31 | + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>, | |
| 32 | +) -> Result<impl IntoResponse, AppError> { | |
| 33 | + use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 34 | + | |
| 35 | + let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 36 | + Some(prefs) => prefs, | |
| 37 | + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?, | |
| 38 | + }; | |
| 39 | + | |
| 40 | + Ok(Json(serde_json::json!({ | |
| 41 | + "theme": preferences.theme, | |
| 42 | + "language": preferences.language, | |
| 43 | + }))) | |
| 44 | +} | |
| 45 | + | |
| 46 | +/// PUT /settings/preferences - Update user preferences | |
| 47 | +pub async fn update_preferences( | |
| 48 | + State(db): State<DatabaseConnection>, | |
| 49 | + Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>, | |
| 50 | + axum::extract::Form(form): axum::extract::Form<UpdatePreferencesRequest>, | |
| 51 | +) -> Result<impl IntoResponse, AppError> { | |
| 52 | + use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 53 | + | |
| 54 | + let current_prefs = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 55 | + Some(prefs) => prefs, | |
| 56 | + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?, | |
| 57 | + }; | |
| 58 | + | |
| 59 | + let theme = form.theme.unwrap_or(current_prefs.theme.clone()); | |
| 60 | + let language = form.language.unwrap_or(current_prefs.language.clone()); | |
| 61 | + let language_changed = language != current_prefs.language; | |
| 62 | + | |
| 63 | + let updated_prefs = | |
| 64 | + UserPreferencesService::update_preferences(&db, claims.sub, theme, language.clone()) | |
| 65 | + .await?; | |
| 66 | + | |
| 67 | + tracing::info!( | |
| 68 | + "User {} updated preferences: theme={}, language={}", | |
| 69 | + claims.sub, | |
| 70 | + updated_prefs.theme, | |
| 71 | + updated_prefs.language | |
| 72 | + ); | |
| 73 | + | |
| 74 | + let json_response = Json(serde_json::json!({ | |
| 75 | + "message": "Preferences updated successfully", | |
| 76 | + "theme": updated_prefs.theme, | |
| 77 | + "language": updated_prefs.language, | |
| 78 | + "language_changed": language_changed, | |
| 79 | + "reload_required": language_changed, | |
| 80 | + })); | |
| 81 | + | |
| 82 | + use axum::http::header; | |
| 83 | + use time::Duration as TimeDuration; | |
| 84 | + | |
| 85 | + let theme_cookie = | |
| 86 | + axum_extra::extract::cookie::Cookie::build(("theme", updated_prefs.theme.clone())) | |
| 87 | + .http_only(false) | |
| 88 | + .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 89 | + .path("/") | |
| 90 | + .max_age(TimeDuration::seconds(31536000)); | |
| 91 | + | |
| 92 | + let mut response = json_response.into_response(); | |
| 93 | + | |
| 94 | + if let Ok(hv) = theme_cookie.to_string().parse() { | |
| 95 | + response.headers_mut().append(header::SET_COOKIE, hv); | |
| 96 | + } | |
| 97 | + | |
| 98 | + if language_changed && matches!(language.as_str(), "fr" | "en" | "de" | "es" | "it" | "pt") { | |
| 99 | + let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", language)) | |
| 100 | + .http_only(false) | |
| 101 | + .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 102 | + .path("/") | |
| 103 | + .max_age(TimeDuration::seconds(31536000)); | |
| 104 | + | |
| 105 | + if let Ok(hv) = locale_cookie.to_string().parse() { | |
| 106 | + response.headers_mut().append(header::SET_COOKIE, hv); | |
| 107 | + } | |
| 108 | + } | |
| 109 | + | |
| 110 | + Ok(response) | |
| 111 | +} | |
| 112 | + | |
| 113 | +// ============================================================================ | |
| 114 | +// GET Handlers for HTML Pages (with i18n) | |
| 115 | +// ============================================================================ | |
| 116 | + | |
| 117 | +#[derive(Template)] | |
| 118 | +#[template(path = "settings/preferences.html")] | |
| 119 | +#[allow(dead_code)] | |
| 120 | +struct PreferencesTemplate { | |
| 121 | + page_title: String, | |
| 122 | + csrf_token: String, | |
| 123 | + app_name: String, | |
| 124 | + app_version: String, | |
| 125 | + app_domain: String, | |
| 126 | + app_description: String, | |
| 127 | + username: String, | |
| 128 | + email: String, | |
| 129 | + roles_text: String, | |
| 130 | + initials: String, | |
| 131 | + avatar_color: String, | |
| 132 | + theme: String, | |
| 133 | + is_theme_light: bool, | |
| 134 | + is_theme_dark: bool, | |
| 135 | + is_theme_auto: bool, | |
| 136 | + language: String, | |
| 137 | + is_language_fr: bool, | |
| 138 | + is_language_en: bool, | |
| 139 | + is_language_de: bool, | |
| 140 | + is_language_es: bool, | |
| 141 | + is_language_it: bool, | |
| 142 | + is_language_pt: bool, | |
| 143 | + current_locale: String, | |
| 144 | + supported_languages: Vec<(String, String, bool)>, | |
| 145 | + nav_items: Vec<NavItem>, | |
| 146 | + has_pending_password_change: bool, | |
| 147 | + user_menu_profile: String, | |
| 148 | + user_menu_logout: String, | |
| 149 | + | |
| 150 | + // Translated strings (hardcoded FR) | |
| 151 | + title: String, | |
| 152 | + subtitle: String, | |
| 153 | + theme_label: String, | |
| 154 | + theme_light: String, | |
| 155 | + theme_light_desc: String, | |
| 156 | + theme_dark: String, | |
| 157 | + theme_dark_desc: String, | |
| 158 | + theme_auto: String, | |
| 159 | + theme_auto_desc: String, | |
| 160 | + theme_preview: String, | |
| 161 | + language_label: String, | |
| 162 | + language_fr: String, | |
| 163 | + language_fr_desc: String, | |
| 164 | + language_en: String, | |
| 165 | + language_en_desc: String, | |
| 166 | + language_de: String, | |
| 167 | + language_de_desc: String, | |
| 168 | + language_es: String, | |
| 169 | + language_es_desc: String, | |
| 170 | + language_it: String, | |
| 171 | + language_it_desc: String, | |
| 172 | + language_pt: String, | |
| 173 | + language_pt_desc: String, | |
| 174 | + translation_warning: String, | |
| 175 | + modified: String, | |
| 176 | + modified_desc: String, | |
| 177 | + save_button: String, | |
| 178 | + saving: String, | |
| 179 | + cancel: String, | |
| 180 | + saved_success: String, | |
| 181 | + saved_reload: String, | |
| 182 | + saved_reload_json: String, | |
| 183 | + saved_success_json: String, | |
| 184 | + error_prefix: String, | |
| 185 | + error_prefix_json: String, | |
| 186 | + network_error: String, | |
| 187 | + network_error_json: String, | |
| 188 | + save_button_json: String, | |
| 189 | + sync_title: String, | |
| 190 | + sync_desc: String, | |
| 191 | + preview_dark: String, | |
| 192 | + preview_light: String, | |
| 193 | + preview_auto: String, | |
| 194 | + breadcrumb_home: String, | |
| 195 | + breadcrumb_settings: String, | |
| 196 | + breadcrumb_preferences: String, | |
| 197 | +} | |
| 198 | + | |
| 199 | +rustwarden_core::impl_template_response!(PreferencesTemplate); | |
| 200 | + | |
| 201 | +/// Handler for GET /profile - Display profile page (PROTECTED) | |
| 202 | +pub async fn get_preferences_page( | |
| 203 | + Extension(claims): Extension<Claims>, | |
| 204 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 205 | + Extension(locale): Extension<Locale>, | |
| 206 | + State(db): State<DatabaseConnection>, | |
| 207 | + session: Session, | |
| 208 | +) -> Result<impl IntoResponse, AppError> { | |
| 209 | + let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 210 | + .await? | |
| 211 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 212 | + | |
| 213 | + let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 214 | + let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 215 | + | |
| 216 | + let locale_str = locale.as_str(); | |
| 217 | + | |
| 218 | + use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 219 | + let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 220 | + Some(prefs) => prefs, | |
| 221 | + None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?, | |
| 222 | + }; | |
| 223 | + | |
| 224 | + let current_locale = locale_str.to_string(); | |
| 225 | + let supported_languages: Vec<(String, String, bool)> = vec![ | |
| 226 | + ( | |
| 227 | + "fr".to_string(), | |
| 228 | + "Français".to_string(), | |
| 229 | + current_locale == "fr", | |
| 230 | + ), | |
| 231 | + ( | |
| 232 | + "en".to_string(), | |
| 233 | + "English".to_string(), | |
| 234 | + current_locale == "en", | |
| 235 | + ), | |
| 236 | + ( | |
| 237 | + "de".to_string(), | |
| 238 | + "Deutsch".to_string(), | |
| 239 | + current_locale == "de", | |
| 240 | + ), | |
| 241 | + ( | |
| 242 | + "es".to_string(), | |
| 243 | + "Español".to_string(), | |
| 244 | + current_locale == "es", | |
| 245 | + ), | |
| 246 | + ( | |
| 247 | + "it".to_string(), | |
| 248 | + "Italiano".to_string(), | |
| 249 | + current_locale == "it", | |
| 250 | + ), | |
| 251 | + ( | |
| 252 | + "pt".to_string(), | |
| 253 | + "Português".to_string(), | |
| 254 | + current_locale == "pt", | |
| 255 | + ), | |
| 256 | + ]; | |
| 257 | + | |
| 258 | + let layout = rustwarden_core::templates::LayoutContext::new("Préférences", ""); | |
| 259 | + use crate::template_helpers::csrf_token_helper; | |
| 260 | + let csrf_token = csrf_token_helper(&session).await; | |
| 261 | + | |
| 262 | + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 263 | + let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 264 | + | |
| 265 | + let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 266 | + .await? | |
| 267 | + .map(|s| s.value) | |
| 268 | + .unwrap_or_else(|| "localhost".to_string()); | |
| 269 | + let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 270 | + .await? | |
| 271 | + .map(|s| s.value) | |
| 272 | + .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 273 | + | |
| 274 | + let save_button = i18n_service.translate("preferences.save", locale_str).await; | |
| 275 | + let saved_success = i18n_service | |
| 276 | + .translate("preferences.saved_success", locale_str) | |
| 277 | + .await; | |
| 278 | + let saved_reload = i18n_service | |
| 279 | + .translate("preferences.saved_reload", locale_str) | |
| 280 | + .await; | |
| 281 | + let error_prefix = i18n_service | |
| 282 | + .translate("preferences.error", locale_str) | |
| 283 | + .await; | |
| 284 | + let network_error = i18n_service | |
| 285 | + .translate("preferences.network_error", locale_str) | |
| 286 | + .await; | |
| 287 | + | |
| 288 | + let saved_reload_json = | |
| 289 | + serde_json::to_string(&saved_reload).unwrap_or_else(|_| "\"\"".to_string()); | |
| 290 | + let saved_success_json = | |
| 291 | + serde_json::to_string(&saved_success).unwrap_or_else(|_| "\"\"".to_string()); | |
| 292 | + let error_prefix_json = | |
| 293 | + serde_json::to_string(&error_prefix).unwrap_or_else(|_| "\"\"".to_string()); | |
| 294 | + let network_error_json = | |
| 295 | + serde_json::to_string(&network_error).unwrap_or_else(|_| "\"\"".to_string()); | |
| 296 | + let save_button_json = | |
| 297 | + serde_json::to_string(&save_button).unwrap_or_else(|_| "\"\"".to_string()); | |
| 298 | + | |
| 299 | + let template = PreferencesTemplate { | |
| 300 | + page_title: layout.page_title.clone(), | |
| 301 | + csrf_token, | |
| 302 | + app_name: layout.app_name.clone(), | |
| 303 | + app_version: layout.app_version.clone(), | |
| 304 | + app_domain, | |
| 305 | + app_description, | |
| 306 | + username: user.username.clone(), | |
| 307 | + email: user.email.clone(), | |
| 308 | + roles_text: user.roles.join(", "), | |
| 309 | + initials: user.initials.clone(), | |
| 310 | + avatar_color: user.avatar_color.clone(), | |
| 311 | + theme: preferences.theme.clone(), | |
| 312 | + is_theme_light: preferences.theme == "light", | |
| 313 | + is_theme_dark: preferences.theme == "dark", | |
| 314 | + is_theme_auto: preferences.theme == "auto", | |
| 315 | + language: preferences.language.clone(), | |
| 316 | + is_language_fr: preferences.language == "fr", | |
| 317 | + is_language_en: preferences.language == "en", | |
| 318 | + is_language_de: preferences.language == "de", | |
| 319 | + is_language_es: preferences.language == "es", | |
| 320 | + is_language_it: preferences.language == "it", | |
| 321 | + is_language_pt: preferences.language == "pt", | |
| 322 | + current_locale, | |
| 323 | + supported_languages, | |
| 324 | + nav_items, | |
| 325 | + has_pending_password_change: false, | |
| 326 | + user_menu_profile: i18n_service | |
| 327 | + .translate("user_menu.profile", locale_str) | |
| 328 | + .await, | |
| 329 | + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 330 | + | |
| 331 | + title: i18n_service | |
| 332 | + .translate("preferences.title", locale_str) | |
| 333 | + .await, | |
| 334 | + subtitle: i18n_service | |
| 335 | + .translate("preferences.subtitle", locale_str) | |
| 336 | + .await, | |
| 337 | + theme_label: i18n_service | |
| 338 | + .translate("preferences.theme", locale_str) | |
| 339 | + .await, | |
| 340 | + theme_light: i18n_service | |
| 341 | + .translate("preferences.theme_light", locale_str) | |
| 342 | + .await, | |
| 343 | + theme_light_desc: i18n_service | |
| 344 | + .translate("preferences.theme_light_desc", locale_str) | |
| 345 | + .await, | |
| 346 | + theme_dark: i18n_service | |
| 347 | + .translate("preferences.theme_dark", locale_str) | |
| 348 | + .await, | |
| 349 | + theme_dark_desc: i18n_service | |
| 350 | + .translate("preferences.theme_dark_desc", locale_str) | |
| 351 | + .await, | |
| 352 | + theme_auto: i18n_service | |
| 353 | + .translate("preferences.theme_auto", locale_str) | |
| 354 | + .await, | |
| 355 | + theme_auto_desc: i18n_service | |
| 356 | + .translate("preferences.theme_auto_desc", locale_str) | |
| 357 | + .await, | |
| 358 | + theme_preview: i18n_service | |
| 359 | + .translate("preferences.theme_preview", locale_str) | |
| 360 | + .await, | |
| 361 | + language_label: i18n_service | |
| 362 | + .translate("preferences.language", locale_str) | |
| 363 | + .await, | |
| 364 | + language_fr: i18n_service | |
| 365 | + .translate("preferences.language_fr", locale_str) | |
| 366 | + .await, | |
| 367 | + language_fr_desc: i18n_service | |
| 368 | + .translate("preferences.language_fr_desc", locale_str) | |
| 369 | + .await, | |
| 370 | + language_en: i18n_service | |
| 371 | + .translate("preferences.language_en", locale_str) | |
| 372 | + .await, | |
| 373 | + language_en_desc: i18n_service | |
| 374 | + .translate("preferences.language_en_desc", locale_str) | |
| 375 | + .await, | |
| 376 | + language_de: i18n_service | |
| 377 | + .translate("preferences.language_de", locale_str) | |
| 378 | + .await, | |
| 379 | + language_de_desc: i18n_service | |
| 380 | + .translate("preferences.language_de_desc", locale_str) | |
| 381 | + .await, | |
| 382 | + language_es: i18n_service | |
| 383 | + .translate("preferences.language_es", locale_str) | |
| 384 | + .await, | |
| 385 | + language_es_desc: i18n_service | |
| 386 | + .translate("preferences.language_es_desc", locale_str) | |
| 387 | + .await, | |
| 388 | + language_it: i18n_service | |
| 389 | + .translate("preferences.language_it", locale_str) | |
| 390 | + .await, | |
| 391 | + language_it_desc: i18n_service | |
| 392 | + .translate("preferences.language_it_desc", locale_str) | |
| 393 | + .await, | |
| 394 | + language_pt: i18n_service | |
| 395 | + .translate("preferences.language_pt", locale_str) | |
| 396 | + .await, | |
| 397 | + language_pt_desc: i18n_service | |
| 398 | + .translate("preferences.language_pt_desc", locale_str) | |
| 399 | + .await, | |
| 400 | + translation_warning: i18n_service | |
| 401 | + .translate("preferences.translation_warning", locale_str) | |
| 402 | + .await, | |
| 403 | + modified: i18n_service | |
| 404 | + .translate("preferences.modified", locale_str) | |
| 405 | + .await, | |
| 406 | + modified_desc: i18n_service | |
| 407 | + .translate("preferences.modified_desc", locale_str) | |
| 408 | + .await, | |
| 409 | + save_button, | |
| 410 | + saving: i18n_service | |
| 411 | + .translate("preferences.saving", locale_str) | |
| 412 | + .await, | |
| 413 | + cancel: i18n_service | |
| 414 | + .translate("preferences.cancel", locale_str) | |
| 415 | + .await, | |
| 416 | + saved_success, | |
| 417 | + saved_reload, | |
| 418 | + saved_reload_json, | |
| 419 | + saved_success_json, | |
| 420 | + error_prefix, | |
| 421 | + error_prefix_json, | |
| 422 | + network_error, | |
| 423 | + network_error_json, | |
| 424 | + save_button_json, | |
| 425 | + sync_title: i18n_service | |
| 426 | + .translate("preferences.sync_title", locale_str) | |
| 427 | + .await, | |
| 428 | + sync_desc: i18n_service | |
| 429 | + .translate("preferences.sync_desc", locale_str) | |
| 430 | + .await, | |
| 431 | + preview_dark: i18n_service | |
| 432 | + .translate("preferences.preview_dark", locale_str) | |
| 433 | + .await, | |
| 434 | + preview_light: i18n_service | |
| 435 | + .translate("preferences.preview_light", locale_str) | |
| 436 | + .await, | |
| 437 | + preview_auto: i18n_service | |
| 438 | + .translate("preferences.preview_auto", locale_str) | |
| 439 | + .await, | |
| 440 | + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 441 | + breadcrumb_settings: i18n_service | |
| 442 | + .translate("breadcrumb.settings", locale_str) | |
| 443 | + .await, | |
| 444 | + breadcrumb_preferences: i18n_service | |
| 445 | + .translate("breadcrumb.preferences", locale_str) | |
| 446 | + .await, | |
| 447 | + }; | |
| 448 | + | |
| 449 | + Ok(template) | |
| 450 | +} |
A
crates/rustwarden-ui/src/handlers/settings/profile.rs
+324
-0
@@ -0,0 +1,324 @@
| 1 | +//! Settings - Profile handlers (GET /settings/profile, PUT /settings/profile). | |
| 2 | + | |
| 3 | +use askama::Template; | |
| 4 | +use axum::{extract::State, response::IntoResponse, Extension, Json}; | |
| 5 | +use chrono::Utc; | |
| 6 | +use sea_orm::DatabaseConnection; | |
| 7 | +use serde::Deserialize; | |
| 8 | +use std::sync::Arc; | |
| 9 | +use tower_sessions::Session; | |
| 10 | + | |
| 11 | +use super::{build_nav_items_from_dto, NavItem}; | |
| 12 | +use crate::dto::navigation_dto::NavigationDTO; | |
| 13 | +use crate::dto::user_profile_dto::UserProfileDTO; | |
| 14 | +use rustwarden_core::auth::jwt::Claims; | |
| 15 | +use rustwarden_core::config::email::EmailConfig; | |
| 16 | +use rustwarden_core::error::AppError; | |
| 17 | +use rustwarden_core::i18n::extractor::Locale; | |
| 18 | +use rustwarden_core::i18n::service::I18nService; | |
| 19 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 20 | +use rustwarden_core::services::user_service::UserService; | |
| 21 | + | |
| 22 | +/// Inline DTO: Update profile request | |
| 23 | +#[derive(Debug, Deserialize)] | |
| 24 | +pub struct UpdateProfileRequest { | |
| 25 | + /// New username, if the user wants to change it. | |
| 26 | + pub username: Option<String>, | |
| 27 | + /// New email address, if the user wants to change it. | |
| 28 | + pub email: Option<String>, | |
| 29 | +} | |
| 30 | +/// PUT /settings/profile — Update the authenticated user's profile. | |
| 31 | +pub async fn update_profile( | |
| 32 | + Extension(claims): Extension<Claims>, | |
| 33 | + State(db): State<DatabaseConnection>, | |
| 34 | + _session: Session, | |
| 35 | + Json(request): Json<UpdateProfileRequest>, | |
| 36 | +) -> Result<impl IntoResponse, AppError> { | |
| 37 | + let old_user = UserService::get_user_by_id(&db, claims.sub) | |
| 38 | + .await? | |
| 39 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 40 | + | |
| 41 | + let old_email = old_user.email.clone(); | |
| 42 | + let new_email = request.email.clone(); | |
| 43 | + let new_username = request.username.clone(); | |
| 44 | + let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false); | |
| 45 | + | |
| 46 | + let updated_user = | |
| 47 | + UserService::update_profile(&db, claims.sub, request.username, request.email).await?; | |
| 48 | + | |
| 49 | + tracing::info!("Profile updated for user {}", updated_user.username); | |
| 50 | + | |
| 51 | + if email_was_changed { | |
| 52 | + use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 53 | + let password_change_service = PasswordChangeService::new(); | |
| 54 | + let invalidated = password_change_service | |
| 55 | + .invalidate_on_email_change(&db, claims.sub) | |
| 56 | + .await?; | |
| 57 | + if invalidated > 0 { | |
| 58 | + tracing::warn!( | |
| 59 | + "Invalidated {} pending password change request(s) for user {} due to email change", | |
| 60 | + invalidated, | |
| 61 | + updated_user.username | |
| 62 | + ); | |
| 63 | + } | |
| 64 | + } | |
| 65 | + | |
| 66 | + if let Ok(email_config) = EmailConfig::from_env() { | |
| 67 | + let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 68 | + let app_name = email_config | |
| 69 | + .smtp_from_name | |
| 70 | + .clone() | |
| 71 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 72 | + | |
| 73 | + let mut changes = Vec::new(); | |
| 74 | + if new_username.is_some() { | |
| 75 | + changes.push(rustwarden_core::templates::email_templates::AccountChange { | |
| 76 | + field: "Username".to_string(), | |
| 77 | + old_value: Some(old_user.username.clone()), | |
| 78 | + 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 | + } | |
| 90 | + | |
| 91 | + if !changes.is_empty() { | |
| 92 | + let template = | |
| 93 | + rustwarden_core::templates::email_templates::AccountModifiedTemplate::new( | |
| 94 | + Some("fr".to_string()), | |
| 95 | + updated_user.username.clone(), | |
| 96 | + modified_at, | |
| 97 | + changes, | |
| 98 | + Some(app_name.clone()), | |
| 99 | + ); | |
| 100 | + | |
| 101 | + if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) { | |
| 102 | + let subject = "Compte modifié".to_string(); | |
| 103 | + use rustwarden_core::services::email_queue_service::EmailQueueService; | |
| 104 | + | |
| 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( | |
| 143 | + &db, | |
| 144 | + updated_user.email.clone(), | |
| 145 | + Some(updated_user.username.clone()), | |
| 146 | + email_config.smtp_from.clone(), | |
| 147 | + email_config.smtp_from_name.clone(), | |
| 148 | + subject, | |
| 149 | + body_html, | |
| 150 | + body_text, | |
| 151 | + "account_modified".to_string(), | |
| 152 | + ) | |
| 153 | + .await | |
| 154 | + { | |
| 155 | + tracing::warn!("Failed to enqueue account modified notification: {}", e); | |
| 156 | + } | |
| 157 | + } | |
| 158 | + } | |
| 159 | + } | |
| 160 | + | |
| 161 | + Ok(Json(serde_json::json!({ | |
| 162 | + "message": "Profile updated successfully", | |
| 163 | + "username": updated_user.username, | |
| 164 | + "email": updated_user.email, | |
| 165 | + }))) | |
| 166 | +} | |
| 167 | +#[derive(Template)] | |
| 168 | +#[template(path = "profile.html")] | |
| 169 | +#[allow(dead_code)] | |
| 170 | +struct ProfileTemplate { | |
| 171 | + page_title: String, | |
| 172 | + csrf_token: String, | |
| 173 | + app_name: String, | |
| 174 | + app_version: String, | |
| 175 | + app_domain: String, | |
| 176 | + app_description: String, | |
| 177 | + username: String, | |
| 178 | + email: String, | |
| 179 | + user_id: String, | |
| 180 | + created_at: String, | |
| 181 | + roles: String, | |
| 182 | + roles_text: String, | |
| 183 | + initials: String, | |
| 184 | + avatar_color: String, | |
| 185 | + nav_items: Vec<NavItem>, | |
| 186 | + has_pending_password_change: bool, | |
| 187 | + user_menu_profile: String, | |
| 188 | + user_menu_logout: String, | |
| 189 | + | |
| 190 | + // Translated strings (hardcoded FR) | |
| 191 | + title: String, | |
| 192 | + subtitle: String, | |
| 193 | + account_info: String, | |
| 194 | + username_label: String, | |
| 195 | + username_public: String, | |
| 196 | + username_placeholder: String, | |
| 197 | + username_pattern: String, | |
| 198 | + username_help: String, | |
| 199 | + email_label: String, | |
| 200 | + email_used_for: String, | |
| 201 | + email_warning: String, | |
| 202 | + email_placeholder: String, | |
| 203 | + email_change_info: String, | |
| 204 | + save_button: String, | |
| 205 | + cancel: String, | |
| 206 | + additional_info: String, | |
| 207 | + account_created: String, | |
| 208 | + roles_label: String, | |
| 209 | + breadcrumb_home: String, | |
| 210 | + breadcrumb_settings: String, | |
| 211 | + breadcrumb_profile: String, | |
| 212 | +} | |
| 213 | + | |
| 214 | +rustwarden_core::impl_template_response!(ProfileTemplate); | |
| 215 | + | |
| 216 | +/// GET /settings/profile — Display the user profile settings page. | |
| 217 | +pub async fn get_profile_page( | |
| 218 | + Extension(claims): Extension<Claims>, | |
| 219 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 220 | + Extension(locale): Extension<Locale>, | |
| 221 | + State(db): State<DatabaseConnection>, | |
| 222 | + session: Session, | |
| 223 | +) -> Result<impl IntoResponse, AppError> { | |
| 224 | + let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 225 | + .await? | |
| 226 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 227 | + | |
| 228 | + let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 229 | + let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 230 | + | |
| 231 | + let locale_str = locale.as_str(); | |
| 232 | + | |
| 233 | + let layout = rustwarden_core::templates::LayoutContext::new("Profil", ""); | |
| 234 | + | |
| 235 | + use crate::template_helpers::csrf_token_helper; | |
| 236 | + let csrf_token = csrf_token_helper(&session).await; | |
| 237 | + | |
| 238 | + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 239 | + let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 240 | + | |
| 241 | + let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 242 | + .await? | |
| 243 | + .map(|s| s.value) | |
| 244 | + .unwrap_or_else(|| "localhost".to_string()); | |
| 245 | + let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 246 | + .await? | |
| 247 | + .map(|s| s.value) | |
| 248 | + .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 249 | + | |
| 250 | + let template = ProfileTemplate { | |
| 251 | + page_title: layout.page_title.clone(), | |
| 252 | + csrf_token, | |
| 253 | + app_name: layout.app_name.clone(), | |
| 254 | + app_version: layout.app_version.clone(), | |
| 255 | + app_domain, | |
| 256 | + app_description, | |
| 257 | + username: user.username.clone(), | |
| 258 | + email: user.email.clone(), | |
| 259 | + user_id: user.id.to_string(), | |
| 260 | + created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 261 | + roles: user.roles.join(", "), | |
| 262 | + roles_text: user.roles.join(", "), | |
| 263 | + initials: user.initials.clone(), | |
| 264 | + avatar_color: user.avatar_color.clone(), | |
| 265 | + nav_items, | |
| 266 | + has_pending_password_change: false, | |
| 267 | + user_menu_profile: i18n_service | |
| 268 | + .translate("user_menu.profile", locale_str) | |
| 269 | + .await, | |
| 270 | + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 271 | + | |
| 272 | + title: i18n_service.translate("profile.title", locale_str).await, | |
| 273 | + subtitle: i18n_service.translate("profile.subtitle", locale_str).await, | |
| 274 | + account_info: i18n_service | |
| 275 | + .translate("profile.account_info", locale_str) | |
| 276 | + .await, | |
| 277 | + username_label: i18n_service.translate("profile.username", locale_str).await, | |
| 278 | + username_public: i18n_service | |
| 279 | + .translate("profile.username_public", locale_str) | |
| 280 | + .await, | |
| 281 | + username_placeholder: i18n_service | |
| 282 | + .translate("profile.username_placeholder", locale_str) | |
| 283 | + .await, | |
| 284 | + username_pattern: i18n_service | |
| 285 | + .translate("profile.username_pattern", locale_str) | |
| 286 | + .await, | |
| 287 | + username_help: i18n_service | |
| 288 | + .translate("profile.username_help", locale_str) | |
| 289 | + .await, | |
| 290 | + email_label: i18n_service | |
| 291 | + .translate("profile.email_label", locale_str) | |
| 292 | + .await, | |
| 293 | + email_used_for: i18n_service | |
| 294 | + .translate("profile.email_used_for", locale_str) | |
| 295 | + .await, | |
| 296 | + email_warning: i18n_service | |
| 297 | + .translate("profile.email_warning", locale_str) | |
| 298 | + .await, | |
| 299 | + email_placeholder: i18n_service | |
| 300 | + .translate("profile.email_placeholder", locale_str) | |
| 301 | + .await, | |
| 302 | + email_change_info: i18n_service | |
| 303 | + .translate("profile.email_change_info", locale_str) | |
| 304 | + .await, | |
| 305 | + save_button: i18n_service.translate("profile.save", locale_str).await, | |
| 306 | + cancel: i18n_service.translate("common.cancel", locale_str).await, | |
| 307 | + additional_info: i18n_service | |
| 308 | + .translate("profile.additional_info", locale_str) | |
| 309 | + .await, | |
| 310 | + account_created: i18n_service | |
| 311 | + .translate("profile.account_created", locale_str) | |
| 312 | + .await, | |
| 313 | + roles_label: i18n_service.translate("profile.roles", locale_str).await, | |
| 314 | + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 315 | + breadcrumb_settings: i18n_service | |
| 316 | + .translate("breadcrumb.settings", locale_str) | |
| 317 | + .await, | |
| 318 | + breadcrumb_profile: i18n_service | |
| 319 | + .translate("breadcrumb.profile", locale_str) | |
| 320 | + .await, | |
| 321 | + }; | |
| 322 | + | |
| 323 | + Ok(template) | |
| 324 | +} |
A
crates/rustwarden-ui/src/handlers/settings/security.rs
+871
-0
@@ -0,0 +1,871 @@
| 1 | +//! Settings - Security handlers (password change, active sessions). | |
| 2 | + | |
| 3 | +use askama::Template; | |
| 4 | +use axum::{ | |
| 5 | + extract::{Query, State}, | |
| 6 | + response::IntoResponse, | |
| 7 | + Extension, Json, | |
| 8 | +}; | |
| 9 | +use sea_orm::DatabaseConnection; | |
| 10 | +use serde::Deserialize; | |
| 11 | +use std::sync::Arc; | |
| 12 | +use tower_sessions::Session; | |
| 13 | + | |
| 14 | +use super::{build_nav_items_from_dto, filters, NavItem}; | |
| 15 | +use crate::dto::navigation_dto::NavigationDTO; | |
| 16 | +use crate::dto::user_profile_dto::UserProfileDTO; | |
| 17 | +use rustwarden_core::auth::jwt::Claims; | |
| 18 | +use rustwarden_core::config::email::EmailConfig; | |
| 19 | +use rustwarden_core::error::AppError; | |
| 20 | +use rustwarden_core::i18n::extractor::Locale; | |
| 21 | +use rustwarden_core::i18n::service::I18nService; | |
| 22 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 23 | +use rustwarden_core::services::refresh_token_service::RefreshTokenService; | |
| 24 | +use rustwarden_core::services::user_service::UserService; | |
| 25 | + | |
| 26 | +/// Inline DTO: Change password request | |
| 27 | +#[derive(Debug, Deserialize)] | |
| 28 | +pub struct ChangePasswordRequest { | |
| 29 | + /// Current password for verification. | |
| 30 | + pub old_password: String, | |
| 31 | + /// Desired new password. | |
| 32 | + pub new_password: String, | |
| 33 | + /// Confirmation of the new password (must match `new_password`). | |
| 34 | + pub confirm_password: String, | |
| 35 | +} | |
| 36 | + | |
| 37 | +/// Inline DTO: Change password response | |
| 38 | +#[derive(Debug, serde::Serialize)] | |
| 39 | +pub struct ChangePasswordResponse { | |
| 40 | + /// Human-readable confirmation message. | |
| 41 | + pub message: String, | |
| 42 | +} | |
| 43 | + | |
| 44 | +/// Inline DTO: Active sessions response | |
| 45 | +#[derive(Debug, serde::Serialize)] | |
| 46 | +pub struct ActiveSessionsResponse { | |
| 47 | + /// List of currently active refresh-token sessions for the user. | |
| 48 | + pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>, | |
| 49 | +} | |
| 50 | + | |
| 51 | +/// Query parameters for password page | |
| 52 | +#[derive(Deserialize)] | |
| 53 | +pub struct PasswordPageQuery { | |
| 54 | + must_change: Option<String>, | |
| 55 | +} | |
| 56 | + | |
| 57 | +/// POST /settings/security/password — Change the authenticated user's password. | |
| 58 | +pub async fn change_password( | |
| 59 | + Extension(claims): Extension<Claims>, | |
| 60 | + State(db): State<DatabaseConnection>, | |
| 61 | + session: Session, | |
| 62 | + Json(request): Json<ChangePasswordRequest>, | |
| 63 | +) -> Result<impl IntoResponse, AppError> { | |
| 64 | + if request.new_password != request.confirm_password { | |
| 65 | + return Err(AppError::Validation( | |
| 66 | + "New password and confirmation do not match".to_string(), | |
| 67 | + )); | |
| 68 | + } | |
| 69 | + | |
| 70 | + let require_email = | |
| 71 | + AppSettingsService::get_bool(&db, "password_change_require_email", true).await?; | |
| 72 | + | |
| 73 | + if require_email { | |
| 74 | + let current_token_id: Option<uuid::Uuid> = | |
| 75 | + session.get("refresh_token_id").await.ok().flatten(); | |
| 76 | + | |
| 77 | + let (_request_id, token) = UserService::change_password( | |
| 78 | + &db, | |
| 79 | + claims.sub, | |
| 80 | + &request.old_password, | |
| 81 | + &request.new_password, | |
| 82 | + current_token_id, | |
| 83 | + ) | |
| 84 | + .await?; | |
| 85 | + | |
| 86 | + tracing::info!( | |
| 87 | + "Created pending password change request for user {}", | |
| 88 | + claims.sub | |
| 89 | + ); | |
| 90 | + | |
| 91 | + let user = UserService::get_user_by_id(&db, claims.sub) | |
| 92 | + .await? | |
| 93 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 94 | + | |
| 95 | + let email_config = EmailConfig::from_env() | |
| 96 | + .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?; | |
| 97 | + | |
| 98 | + let confirmation_url = format!( | |
| 99 | + "{}/confirm-password-change?token={}", | |
| 100 | + email_config.email_base_url, token | |
| 101 | + ); | |
| 102 | + | |
| 103 | + let template = | |
| 104 | + rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new( | |
| 105 | + Some("fr".to_string()), | |
| 106 | + user.username.clone(), | |
| 107 | + confirmation_url, | |
| 108 | + Some( | |
| 109 | + email_config | |
| 110 | + .smtp_from_name | |
| 111 | + .clone() | |
| 112 | + .unwrap_or_else(rustwarden_core::config::app::default_app_name), | |
| 113 | + ), | |
| 114 | + ); | |
| 115 | + | |
| 116 | + let body_html = template | |
| 117 | + .render_html() | |
| 118 | + .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?; | |
| 119 | + let body_text = template.render_text(); | |
| 120 | + | |
| 121 | + let subject = "Confirmation de changement de mot de passe".to_string(); | |
| 122 | + | |
| 123 | + let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config) | |
| 124 | + .map_err(|e| { | |
| 125 | + AppError::Internal(format!("Failed to create email service: {}", e)) | |
| 126 | + })?; | |
| 127 | + email_svc | |
| 128 | + .send_email( | |
| 129 | + &user.email, | |
| 130 | + Some(&user.username), | |
| 131 | + &subject, | |
| 132 | + &body_html, | |
| 133 | + &body_text, | |
| 134 | + ) | |
| 135 | + .await | |
| 136 | + .map_err(|e| { | |
| 137 | + tracing::error!( | |
| 138 | + "Failed to send password change confirmation email for user {}: {}", | |
| 139 | + user.username, | |
| 140 | + e | |
| 141 | + ); | |
| 142 | + AppError::Internal( | |
| 143 | + "Failed to send confirmation email. Please try again later.".to_string(), | |
| 144 | + ) | |
| 145 | + })?; | |
| 146 | + | |
| 147 | + tracing::info!( | |
| 148 | + "Password change confirmation email sent for user {}", | |
| 149 | + user.username | |
| 150 | + ); | |
| 151 | + | |
| 152 | + Ok(Json(ChangePasswordResponse { | |
| 153 | + message: "email_confirmation_sent".to_string(), | |
| 154 | + })) | |
| 155 | + } else { | |
| 156 | + use rustwarden_core::services::password_service::PasswordService; | |
| 157 | + | |
| 158 | + let user = UserService::get_user_by_id(&db, claims.sub) | |
| 159 | + .await? | |
| 160 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 161 | + | |
| 162 | + if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? { | |
| 163 | + return Err(AppError::Validation( | |
| 164 | + "Old password is incorrect".to_string(), | |
| 165 | + )); | |
| 166 | + } | |
| 167 | + | |
| 168 | + rustwarden_core::middleware::password_complexity::validate_password_complexity( | |
| 169 | + &db, | |
| 170 | + &request.new_password, | |
| 171 | + ) | |
| 172 | + .await?; | |
| 173 | + | |
| 174 | + let new_hash = PasswordService::hash_password(&request.new_password).await?; | |
| 175 | + UserService::update_password_hash(&db, claims.sub, &new_hash).await?; | |
| 176 | + RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?; | |
| 177 | + | |
| 178 | + tracing::info!( | |
| 179 | + "Password changed directly (no email confirmation) for user {}", | |
| 180 | + claims.sub | |
| 181 | + ); | |
| 182 | + | |
| 183 | + Ok(Json(ChangePasswordResponse { | |
| 184 | + message: "password_changed_directly".to_string(), | |
| 185 | + })) | |
| 186 | + } | |
| 187 | +} | |
| 188 | + | |
| 189 | +/// Query parameters for password change confirmation | |
| 190 | +#[derive(Deserialize)] | |
| 191 | +pub struct ConfirmPasswordChangeQuery { | |
| 192 | + token: String, | |
| 193 | +} | |
| 194 | + | |
| 195 | +/// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC) | |
| 196 | +pub async fn confirm_password_change( | |
| 197 | + State(db): State<DatabaseConnection>, | |
| 198 | + Query(query): Query<ConfirmPasswordChangeQuery>, | |
| 199 | +) -> Result<impl IntoResponse, AppError> { | |
| 200 | + use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 201 | + | |
| 202 | + let password_change_service = PasswordChangeService::new(); | |
| 203 | + | |
| 204 | + let _request = password_change_service | |
| 205 | + .find_by_token(&db, &query.token) | |
| 206 | + .await?; | |
| 207 | + | |
| 208 | + let user_id = password_change_service | |
| 209 | + .confirm_request(&db, &query.token) | |
| 210 | + .await?; | |
| 211 | + | |
| 212 | + RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?; | |
| 213 | + | |
| 214 | + tracing::info!( | |
| 215 | + "Password change confirmed and applied for user {}. All sessions invalidated.", | |
| 216 | + user_id | |
| 217 | + ); | |
| 218 | + | |
| 219 | + Ok(Json(serde_json::json!({ | |
| 220 | + "success": true, | |
| 221 | + "message": "Password change confirmed successfully. Please log in with your new password." | |
| 222 | + }))) | |
| 223 | +} | |
| 224 | + | |
| 225 | +/// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED) | |
| 226 | +pub async fn cancel_pending_password_change( | |
| 227 | + Extension(claims): Extension<Claims>, | |
| 228 | + State(db): State<DatabaseConnection>, | |
| 229 | +) -> Result<impl IntoResponse, AppError> { | |
| 230 | + use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 231 | + | |
| 232 | + let password_change_service = PasswordChangeService::new(); | |
| 233 | + | |
| 234 | + password_change_service | |
| 235 | + .cancel_pending_request(&db, claims.sub) | |
| 236 | + .await?; | |
| 237 | + | |
| 238 | + tracing::info!( | |
| 239 | + "Canceled pending password change request for user {}", | |
| 240 | + claims.sub | |
| 241 | + ); | |
| 242 | + | |
| 243 | + Ok(Json(serde_json::json!({ | |
| 244 | + "success": true, | |
| 245 | + "message": "Pending password change request canceled successfully." | |
| 246 | + }))) | |
| 247 | +} | |
| 248 | + | |
| 249 | +/// Handler for GET /settings/sessions - List active sessions (PROTECTED) | |
| 250 | +pub async fn list_sessions( | |
| 251 | + Extension(claims): Extension<Claims>, | |
| 252 | + State(db): State<DatabaseConnection>, | |
| 253 | + session: Session, | |
| 254 | +) -> Result<impl IntoResponse, AppError> { | |
| 255 | + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 256 | + | |
| 257 | + let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; | |
| 258 | + | |
| 259 | + if let Some(current_id) = current_token_id { | |
| 260 | + for session in &mut sessions { | |
| 261 | + if session.id == current_id { | |
| 262 | + session.is_current = true; | |
| 263 | + break; | |
| 264 | + } | |
| 265 | + } | |
| 266 | + } | |
| 267 | + | |
| 268 | + Ok(Json(ActiveSessionsResponse { sessions })) | |
| 269 | +} | |
| 270 | + | |
| 271 | +/// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED) | |
| 272 | +pub async fn revoke_session( | |
| 273 | + Extension(claims): Extension<Claims>, | |
| 274 | + State(db): State<DatabaseConnection>, | |
| 275 | + session: Session, | |
| 276 | + axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>, | |
| 277 | +) -> Result<impl IntoResponse, AppError> { | |
| 278 | + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 279 | + | |
| 280 | + if Some(session_id) == current_token_id { | |
| 281 | + return Err(AppError::Validation( | |
| 282 | + "Cannot revoke current session. Please log out instead.".to_string(), | |
| 283 | + )); | |
| 284 | + } | |
| 285 | + | |
| 286 | + use rustwarden_core::models::refresh_token; | |
| 287 | + use sea_orm::EntityTrait; | |
| 288 | + | |
| 289 | + let token = refresh_token::Entity::find_by_id(session_id) | |
| 290 | + .one(&db) | |
| 291 | + .await? | |
| 292 | + .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?; | |
| 293 | + | |
| 294 | + if token.user_id != claims.sub { | |
| 295 | + return Err(AppError::Validation( | |
| 296 | + "You can only revoke your own sessions".to_string(), | |
| 297 | + )); | |
| 298 | + } | |
| 299 | + | |
| 300 | + RefreshTokenService::revoke_session(&db, session_id).await?; | |
| 301 | + | |
| 302 | + tracing::info!("Session {} revoked by user {}", session_id, claims.sub); | |
| 303 | + | |
| 304 | + Ok(Json(serde_json::json!({ | |
| 305 | + "message": "Session revoked successfully", | |
| 306 | + }))) | |
| 307 | +} | |
| 308 | +#[derive(Template)] | |
| 309 | +#[template(path = "settings/password.html")] | |
| 310 | +#[allow(dead_code)] | |
| 311 | +struct PasswordTemplate { | |
| 312 | + page_title: String, | |
| 313 | + csrf_token: String, | |
| 314 | + app_name: String, | |
| 315 | + app_version: String, | |
| 316 | + app_domain: String, | |
| 317 | + app_description: String, | |
| 318 | + username: String, | |
| 319 | + email: String, | |
| 320 | + roles_text: String, | |
| 321 | + initials: String, | |
| 322 | + avatar_color: String, | |
| 323 | + nav_items: Vec<NavItem>, | |
| 324 | + user_menu_profile: String, | |
| 325 | + user_menu_logout: String, | |
| 326 | + | |
| 327 | + must_change: bool, | |
| 328 | + must_change_message: String, | |
| 329 | + has_pending_request: bool, | |
| 330 | + pending_request_message: String, | |
| 331 | + pending_request_info: String, | |
| 332 | + pending_request_cancel: String, | |
| 333 | + // Translated strings (hardcoded FR) | |
| 334 | + title: String, | |
| 335 | + subtitle: String, | |
| 336 | + new_password_label: String, | |
| 337 | + current_password_label: String, | |
| 338 | + current_password_placeholder: String, | |
| 339 | + new_password_placeholder: String, | |
| 340 | + required: String, | |
| 341 | + requirements: String, | |
| 342 | + strength_label: String, | |
| 343 | + strength_weak: String, | |
| 344 | + strength_medium: String, | |
| 345 | + strength_strong: String, | |
| 346 | + strength_too_short: String, | |
| 347 | + strength_missing_lowercase: String, | |
| 348 | + strength_missing_uppercase: String, | |
| 349 | + strength_missing_digit: String, | |
| 350 | + strength_missing_special: String, | |
| 351 | + confirm_password_label: String, | |
| 352 | + confirm_password_placeholder: String, | |
| 353 | + passwords_match: String, | |
| 354 | + passwords_not_match: String, | |
| 355 | + change_button: String, | |
| 356 | + changing: String, | |
| 357 | + logout_warning_title: String, | |
| 358 | + logout_warning_desc: String, | |
| 359 | + saved: String, | |
| 360 | + error_all_fields_required: String, | |
| 361 | + error_empty_data: String, | |
| 362 | + error_invalid_data: String, | |
| 363 | + error_incorrect_current: String, | |
| 364 | + error_reading_response: String, | |
| 365 | + error_415: String, | |
| 366 | + network_error: String, | |
| 367 | + security_tips_title: String, | |
| 368 | + security_tip_unique: String, | |
| 369 | + security_tip_share: String, | |
| 370 | + security_tip_regular: String, | |
| 371 | + security_tip_2fa: String, | |
| 372 | + security_tip_manager: String, | |
| 373 | + breadcrumb_home: String, | |
| 374 | + breadcrumb_settings: String, | |
| 375 | + breadcrumb_password: String, | |
| 376 | + cancel: String, | |
| 377 | + logout_label: String, | |
| 378 | + has_pending_password_change: bool, | |
| 379 | + // Password complexity rules (from app_settings) | |
| 380 | + pw_min_length: usize, | |
| 381 | + pw_require_uppercase: bool, | |
| 382 | + pw_require_lowercase: bool, | |
| 383 | + pw_require_digits: bool, | |
| 384 | + pw_require_special: bool, | |
| 385 | + password_change_require_email: bool, | |
| 386 | + subtitle_no_email: String, | |
| 387 | + saved_direct: String, | |
| 388 | +} | |
| 389 | + | |
| 390 | +rustwarden_core::impl_template_response!(PasswordTemplate); | |
| 391 | + | |
| 392 | +/// Session item for template | |
| 393 | +#[derive(Clone, Debug)] | |
| 394 | +#[allow(dead_code)] | |
| 395 | +struct SessionItem { | |
| 396 | + id: String, | |
| 397 | + device_info: String, | |
| 398 | + ip_address: String, | |
| 399 | + last_activity: String, | |
| 400 | + created_at: String, | |
| 401 | + is_current: bool, | |
| 402 | +} | |
| 403 | + | |
| 404 | +/// Sessions page template | |
| 405 | +#[derive(Template)] | |
| 406 | +#[template(path = "settings/sessions.html")] | |
| 407 | +#[allow(dead_code)] | |
| 408 | +struct SessionsTemplate { | |
| 409 | + page_title: String, | |
| 410 | + csrf_token: String, | |
| 411 | + app_name: String, | |
| 412 | + app_version: String, | |
| 413 | + app_domain: String, | |
| 414 | + app_description: String, | |
| 415 | + username: String, | |
| 416 | + email: String, | |
| 417 | + roles_text: String, | |
| 418 | + initials: String, | |
| 419 | + avatar_color: String, | |
| 420 | + sessions: Vec<SessionItem>, | |
| 421 | + nav_items: Vec<NavItem>, | |
| 422 | + has_pending_password_change: bool, | |
| 423 | + user_menu_profile: String, | |
| 424 | + user_menu_logout: String, | |
| 425 | + | |
| 426 | + // Translated strings (hardcoded FR) | |
| 427 | + title: String, | |
| 428 | + subtitle: String, | |
| 429 | + current_session: String, | |
| 430 | + current_badge: String, | |
| 431 | + unknown_device: String, | |
| 432 | + ip_address: String, | |
| 433 | + created_at: String, | |
| 434 | + last_activity: String, | |
| 435 | + session_id: String, | |
| 436 | + revoke: String, | |
| 437 | + revoke_confirm_template: String, | |
| 438 | + revoke_success: String, | |
| 439 | + revoke_error: String, | |
| 440 | + no_sessions: String, | |
| 441 | + no_sessions_desc: String, | |
| 442 | + danger_zone: String, | |
| 443 | + danger_zone_desc: String, | |
| 444 | + revoke_all: String, | |
| 445 | + revoke_all_confirm: String, | |
| 446 | + revoke_all_success: String, | |
| 447 | + revoke_all_error: String, | |
| 448 | + about_title: String, | |
| 449 | + about_desc: String, | |
| 450 | + back_to_settings: String, | |
| 451 | + breadcrumb_home: String, | |
| 452 | + breadcrumb_settings: String, | |
| 453 | + breadcrumb_sessions: String, | |
| 454 | +} | |
| 455 | + | |
| 456 | +rustwarden_core::impl_template_response!(SessionsTemplate); | |
| 457 | + | |
| 458 | +/// Preferences page template | |
| 459 | +pub async fn get_password_page( | |
| 460 | + Extension(claims): Extension<Claims>, | |
| 461 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 462 | + Extension(locale): Extension<Locale>, | |
| 463 | + State(db): State<DatabaseConnection>, | |
| 464 | + session: Session, | |
| 465 | + Query(query): Query<PasswordPageQuery>, | |
| 466 | +) -> Result<impl IntoResponse, AppError> { | |
| 467 | + let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 468 | + .await? | |
| 469 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 470 | + | |
| 471 | + let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 472 | + let user = UserProfileDTO::new(user_model.clone(), roles.clone()); | |
| 473 | + | |
| 474 | + let locale_str = locale.as_str(); | |
| 475 | + | |
| 476 | + let must_change = query.must_change.as_deref() == Some("true"); | |
| 477 | + let must_change_message = if must_change { | |
| 478 | + i18n_service | |
| 479 | + .translate("password.must_change_message", locale_str) | |
| 480 | + .await | |
| 481 | + } else { | |
| 482 | + String::new() | |
| 483 | + }; | |
| 484 | + | |
| 485 | + let pw_rules = | |
| 486 | + rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await; | |
| 487 | + | |
| 488 | + use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 489 | + let password_change_service = PasswordChangeService::new(); | |
| 490 | + let pending_request = password_change_service | |
| 491 | + .find_pending_by_user_id(&db, user_model.id) | |
| 492 | + .await?; | |
| 493 | + let has_pending_request = pending_request.is_some(); | |
| 494 | + let has_pending_password_change = has_pending_request; | |
| 495 | + | |
| 496 | + let pending_request_message = if has_pending_request { | |
| 497 | + i18n_service | |
| 498 | + .translate("password.pending_request_message", locale_str) | |
| 499 | + .await | |
| 500 | + } else { | |
| 501 | + String::new() | |
| 502 | + }; | |
| 503 | + let pending_request_info = if has_pending_request { | |
| 504 | + i18n_service | |
| 505 | + .translate("password.pending_request_info", locale_str) | |
| 506 | + .await | |
| 507 | + } else { | |
| 508 | + String::new() | |
| 509 | + }; | |
| 510 | + let pending_request_cancel = i18n_service | |
| 511 | + .translate("password.pending_request_cancel", locale_str) | |
| 512 | + .await; | |
| 513 | + | |
| 514 | + let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", ""); | |
| 515 | + use crate::template_helpers::csrf_token_helper; | |
| 516 | + let csrf_token = csrf_token_helper(&session).await; | |
| 517 | + | |
| 518 | + let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 519 | + navigation.set_pending_password_change(has_pending_password_change); | |
| 520 | + let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 521 | + | |
| 522 | + let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 523 | + .await? | |
| 524 | + .map(|s| s.value) | |
| 525 | + .unwrap_or_else(|| "localhost".to_string()); | |
| 526 | + let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 527 | + .await? | |
| 528 | + .map(|s| s.value) | |
| 529 | + .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 530 | + | |
| 531 | + let page_title = i18n_service | |
| 532 | + .translate("password.page_title", locale_str) | |
| 533 | + .await; | |
| 534 | + | |
| 535 | + let template = PasswordTemplate { | |
| 536 | + page_title, | |
| 537 | + csrf_token, | |
| 538 | + app_name: layout.app_name.clone(), | |
| 539 | + app_version: layout.app_version.clone(), | |
| 540 | + app_domain, | |
| 541 | + app_description, | |
| 542 | + username: user.username.clone(), | |
| 543 | + email: user.email.clone(), | |
| 544 | + roles_text: user.roles.join(", "), | |
| 545 | + initials: user.initials.clone(), | |
| 546 | + avatar_color: user.avatar_color.clone(), | |
| 547 | + nav_items, | |
| 548 | + user_menu_profile: i18n_service | |
| 549 | + .translate("user_menu.profile", locale_str) | |
| 550 | + .await, | |
| 551 | + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 552 | + | |
| 553 | + must_change, | |
| 554 | + must_change_message, | |
| 555 | + has_pending_request, | |
| 556 | + pending_request_message, | |
| 557 | + pending_request_info, | |
| 558 | + pending_request_cancel, | |
| 559 | + title: i18n_service.translate("password.title", locale_str).await, | |
| 560 | + subtitle: i18n_service | |
| 561 | + .translate("password.subtitle", locale_str) | |
| 562 | + .await, | |
| 563 | + new_password_label: i18n_service | |
| 564 | + .translate("password.new_password", locale_str) | |
| 565 | + .await, | |
| 566 | + current_password_label: i18n_service | |
| 567 | + .translate("password.current_password", locale_str) | |
| 568 | + .await, | |
| 569 | + current_password_placeholder: i18n_service | |
| 570 | + .translate("password.current_password_placeholder", locale_str) | |
| 571 | + .await, | |
| 572 | + new_password_placeholder: i18n_service | |
| 573 | + .translate("password.new_password_placeholder", locale_str) | |
| 574 | + .await, | |
| 575 | + required: i18n_service | |
| 576 | + .translate("password.required", locale_str) | |
| 577 | + .await, | |
| 578 | + requirements: i18n_service | |
| 579 | + .translate("password.requirements", locale_str) | |
| 580 | + .await, | |
| 581 | + strength_label: i18n_service | |
| 582 | + .translate("password.strength", locale_str) | |
| 583 | + .await, | |
| 584 | + strength_weak: i18n_service | |
| 585 | + .translate("password.strength_weak", locale_str) | |
| 586 | + .await, | |
| 587 | + strength_medium: i18n_service | |
| 588 | + .translate("password.strength_medium", locale_str) | |
| 589 | + .await, | |
| 590 | + strength_strong: i18n_service | |
| 591 | + .translate("password.strength_strong", locale_str) | |
| 592 | + .await, | |
| 593 | + strength_too_short: i18n_service | |
| 594 | + .translate("password.strength_too_short", locale_str) | |
| 595 | + .await, | |
| 596 | + strength_missing_lowercase: i18n_service | |
| 597 | + .translate("password.strength_missing_lowercase", locale_str) | |
| 598 | + .await, | |
| 599 | + strength_missing_uppercase: i18n_service | |
| 600 | + .translate("password.strength_missing_uppercase", locale_str) | |
| 601 | + .await, | |
| 602 | + strength_missing_digit: i18n_service | |
| 603 | + .translate("password.strength_missing_digit", locale_str) | |
| 604 | + .await, | |
| 605 | + strength_missing_special: i18n_service | |
| 606 | + .translate("password.strength_missing_special", locale_str) | |
| 607 | + .await, | |
| 608 | + confirm_password_label: i18n_service | |
| 609 | + .translate("password.confirm_password", locale_str) | |
| 610 | + .await, | |
| 611 | + confirm_password_placeholder: i18n_service | |
| 612 | + .translate("password.confirm_password_placeholder", locale_str) | |
| 613 | + .await, | |
| 614 | + passwords_match: i18n_service | |
| 615 | + .translate("password.passwords_match", locale_str) | |
| 616 | + .await, | |
| 617 | + passwords_not_match: i18n_service | |
| 618 | + .translate("password.passwords_not_match", locale_str) | |
| 619 | + .await, | |
| 620 | + change_button: i18n_service | |
| 621 | + .translate("password.change_button", locale_str) | |
| 622 | + .await, | |
| 623 | + changing: i18n_service | |
| 624 | + .translate("password.changing", locale_str) | |
| 625 | + .await, | |
| 626 | + logout_warning_title: i18n_service | |
| 627 | + .translate("password.logout_warning_title", locale_str) | |
| 628 | + .await, | |
| 629 | + logout_warning_desc: i18n_service | |
| 630 | + .translate("password.logout_warning_desc", locale_str) | |
| 631 | + .await, | |
| 632 | + saved: i18n_service.translate("password.saved", locale_str).await, | |
| 633 | + error_all_fields_required: i18n_service | |
| 634 | + .translate("password.error_all_fields_required", locale_str) | |
| 635 | + .await, | |
| 636 | + error_empty_data: i18n_service | |
| 637 | + .translate("password.error_empty_data", locale_str) | |
| 638 | + .await, | |
| 639 | + error_invalid_data: i18n_service | |
| 640 | + .translate("password.error_invalid_data", locale_str) | |
| 641 | + .await, | |
| 642 | + error_incorrect_current: i18n_service | |
| 643 | + .translate("password.error_incorrect_current", locale_str) | |
| 644 | + .await, | |
| 645 | + error_reading_response: i18n_service | |
| 646 | + .translate("password.error_reading_response", locale_str) | |
| 647 | + .await, | |
| 648 | + error_415: i18n_service | |
| 649 | + .translate("password.error_415", locale_str) | |
| 650 | + .await, | |
| 651 | + network_error: i18n_service | |
| 652 | + .translate("password.network_error", locale_str) | |
| 653 | + .await, | |
| 654 | + security_tips_title: i18n_service | |
| 655 | + .translate("password.security_tips_title", locale_str) | |
| 656 | + .await, | |
| 657 | + security_tip_unique: i18n_service | |
| 658 | + .translate("password.security_tip_unique", locale_str) | |
| 659 | + .await, | |
| 660 | + security_tip_share: i18n_service | |
| 661 | + .translate("password.security_tip_share", locale_str) | |
| 662 | + .await, | |
| 663 | + security_tip_regular: i18n_service | |
| 664 | + .translate("password.security_tip_regular", locale_str) | |
| 665 | + .await, | |
| 666 | + security_tip_2fa: i18n_service | |
| 667 | + .translate("password.security_tip_2fa", locale_str) | |
| 668 | + .await, | |
| 669 | + security_tip_manager: i18n_service | |
| 670 | + .translate("password.security_tip_manager", locale_str) | |
| 671 | + .await, | |
| 672 | + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 673 | + breadcrumb_settings: i18n_service | |
| 674 | + .translate("breadcrumb.settings", locale_str) | |
| 675 | + .await, | |
| 676 | + breadcrumb_password: i18n_service | |
| 677 | + .translate("breadcrumb.password", locale_str) | |
| 678 | + .await, | |
| 679 | + cancel: i18n_service.translate("common.cancel", locale_str).await, | |
| 680 | + logout_label: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 681 | + has_pending_password_change, | |
| 682 | + pw_min_length: pw_rules.min_length, | |
| 683 | + pw_require_uppercase: pw_rules.require_uppercase, | |
| 684 | + pw_require_lowercase: pw_rules.require_lowercase, | |
| 685 | + pw_require_digits: pw_rules.require_digits, | |
| 686 | + pw_require_special: pw_rules.require_special, | |
| 687 | + password_change_require_email: AppSettingsService::get_bool( | |
| 688 | + &db, | |
| 689 | + "password_change_require_email", | |
| 690 | + true, | |
| 691 | + ) | |
| 692 | + .await | |
| 693 | + .unwrap_or(true), | |
| 694 | + subtitle_no_email: i18n_service | |
| 695 | + .translate("password.subtitle_no_email", locale_str) | |
| 696 | + .await, | |
| 697 | + saved_direct: i18n_service | |
| 698 | + .translate("password.saved_direct", locale_str) | |
| 699 | + .await, | |
| 700 | + }; | |
| 701 | + | |
| 702 | + Ok(template) | |
| 703 | +} | |
| 704 | + | |
| 705 | +/// Handler for GET /settings/sessions - Display sessions page (PROTECTED) | |
| 706 | +pub async fn get_sessions_page( | |
| 707 | + Extension(claims): Extension<Claims>, | |
| 708 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 709 | + Extension(locale): Extension<Locale>, | |
| 710 | + State(db): State<DatabaseConnection>, | |
| 711 | + session: Session, | |
| 712 | +) -> Result<impl IntoResponse, AppError> { | |
| 713 | + let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 714 | + .await? | |
| 715 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 716 | + | |
| 717 | + let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 718 | + let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 719 | + | |
| 720 | + let locale_str = locale.as_str(); | |
| 721 | + | |
| 722 | + let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 723 | + | |
| 724 | + let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; | |
| 725 | + | |
| 726 | + if let Some(current_id) = current_token_id { | |
| 727 | + for session_data in &mut sessions_data { | |
| 728 | + if session_data.id == current_id { | |
| 729 | + session_data.is_current = true; | |
| 730 | + break; | |
| 731 | + } | |
| 732 | + } | |
| 733 | + } | |
| 734 | + | |
| 735 | + let ip_fallback = i18n_service | |
| 736 | + .translate("session.ip_unavailable", locale_str) | |
| 737 | + .await; | |
| 738 | + let sessions: Vec<SessionItem> = sessions_data | |
| 739 | + .iter() | |
| 740 | + .map(|s| SessionItem { | |
| 741 | + id: s.id.to_string(), | |
| 742 | + device_info: s | |
| 743 | + .device_info | |
| 744 | + .clone() | |
| 745 | + .unwrap_or_else(|| "Unknown".to_string()), | |
| 746 | + ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()), | |
| 747 | + last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 748 | + created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 749 | + is_current: s.is_current, | |
| 750 | + }) | |
| 751 | + .collect(); | |
| 752 | + | |
| 753 | + let layout = rustwarden_core::templates::LayoutContext::new("Sessions", ""); | |
| 754 | + use crate::template_helpers::csrf_token_helper; | |
| 755 | + let csrf_token = csrf_token_helper(&session).await; | |
| 756 | + | |
| 757 | + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 758 | + let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 759 | + | |
| 760 | + let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 761 | + .await? | |
| 762 | + .map(|s| s.value) | |
| 763 | + .unwrap_or_else(|| "localhost".to_string()); | |
| 764 | + let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 765 | + .await? | |
| 766 | + .map(|s| s.value) | |
| 767 | + .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 768 | + | |
| 769 | + let page_title = i18n_service | |
| 770 | + .translate("sessions.page_title", locale_str) | |
| 771 | + .await; | |
| 772 | + | |
| 773 | + let template = SessionsTemplate { | |
| 774 | + page_title, | |
| 775 | + csrf_token, | |
| 776 | + app_name: layout.app_name.clone(), | |
| 777 | + app_version: layout.app_version.clone(), | |
| 778 | + app_domain, | |
| 779 | + app_description, | |
| 780 | + username: user.username.clone(), | |
| 781 | + email: user.email.clone(), | |
| 782 | + roles_text: user.roles.join(", "), | |
| 783 | + initials: user.initials.clone(), | |
| 784 | + avatar_color: user.avatar_color.clone(), | |
| 785 | + sessions, | |
| 786 | + nav_items, | |
| 787 | + has_pending_password_change: false, | |
| 788 | + user_menu_profile: i18n_service | |
| 789 | + .translate("user_menu.profile", locale_str) | |
| 790 | + .await, | |
| 791 | + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 792 | + | |
| 793 | + title: i18n_service.translate("sessions.title", locale_str).await, | |
| 794 | + subtitle: i18n_service | |
| 795 | + .translate("sessions.subtitle", locale_str) | |
| 796 | + .await, | |
| 797 | + current_session: i18n_service | |
| 798 | + .translate("sessions.current_session", locale_str) | |
| 799 | + .await, | |
| 800 | + current_badge: i18n_service | |
| 801 | + .translate("sessions.current_badge", locale_str) | |
| 802 | + .await, | |
| 803 | + unknown_device: i18n_service | |
| 804 | + .translate("sessions.unknown_device", locale_str) | |
| 805 | + .await, | |
| 806 | + ip_address: i18n_service | |
| 807 | + .translate("sessions.ip_address", locale_str) | |
| 808 | + .await, | |
| 809 | + created_at: i18n_service | |
| 810 | + .translate("sessions.created_at", locale_str) | |
| 811 | + .await, | |
| 812 | + last_activity: i18n_service | |
| 813 | + .translate("sessions.last_activity", locale_str) | |
| 814 | + .await, | |
| 815 | + session_id: i18n_service | |
| 816 | + .translate("sessions.session_id", locale_str) | |
| 817 | + .await, | |
| 818 | + revoke: i18n_service.translate("sessions.revoke", locale_str).await, | |
| 819 | + revoke_confirm_template: i18n_service | |
| 820 | + .translate("sessions.revoke_confirm", locale_str) | |
| 821 | + .await, | |
| 822 | + revoke_success: i18n_service | |
| 823 | + .translate("sessions.revoke_success", locale_str) | |
| 824 | + .await, | |
| 825 | + revoke_error: i18n_service | |
| 826 | + .translate("sessions.revoke_error", locale_str) | |
| 827 | + .await, | |
| 828 | + no_sessions: i18n_service | |
| 829 | + .translate("sessions.no_sessions", locale_str) | |
| 830 | + .await, | |
| 831 | + no_sessions_desc: i18n_service | |
| 832 | + .translate("sessions.no_sessions_desc", locale_str) | |
| 833 | + .await, | |
| 834 | + danger_zone: i18n_service | |
| 835 | + .translate("sessions.danger_zone", locale_str) | |
| 836 | + .await, | |
| 837 | + danger_zone_desc: i18n_service | |
| 838 | + .translate("sessions.danger_zone_desc", locale_str) | |
| 839 | + .await, | |
| 840 | + revoke_all: i18n_service | |
| 841 | + .translate("sessions.revoke_all", locale_str) | |
| 842 | + .await, | |
| 843 | + revoke_all_confirm: i18n_service | |
| 844 | + .translate("sessions.revoke_all_confirm", locale_str) | |
| 845 | + .await, | |
| 846 | + revoke_all_success: i18n_service | |
| 847 | + .translate("sessions.revoke_all_success", locale_str) | |
| 848 | + .await, | |
| 849 | + revoke_all_error: i18n_service | |
| 850 | + .translate("sessions.revoke_all_error", locale_str) | |
| 851 | + .await, | |
| 852 | + about_title: i18n_service | |
| 853 | + .translate("sessions.about_title", locale_str) | |
| 854 | + .await, | |
| 855 | + about_desc: i18n_service | |
| 856 | + .translate("sessions.about_desc", locale_str) | |
| 857 | + .await, | |
| 858 | + back_to_settings: i18n_service | |
| 859 | + .translate("sessions.back_to_settings", locale_str) | |
| 860 | + .await, | |
| 861 | + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 862 | + breadcrumb_settings: i18n_service | |
| 863 | + .translate("breadcrumb.settings", locale_str) | |
| 864 | + .await, | |
| 865 | + breadcrumb_sessions: i18n_service | |
| 866 | + .translate("breadcrumb.sessions", locale_str) | |
| 867 | + .await, | |
| 868 | + }; | |
| 869 | + | |
| 870 | + Ok(template) | |
| 871 | +} |
D
crates/rustwarden-ui/src/handlers/settings_handler.rs
+0
-1358
@@ -1,1358 +0,0 @@
| 1 | -/// Settings Handler | |
| 2 | -/// Feature 004: UI Post-Auth + Feature 009: Admin & Settings | |
| 3 | -/// | |
| 4 | -/// Handles the settings page, profile updates, and password changes. | |
| 5 | -use askama::Template; | |
| 6 | -use axum::{ | |
| 7 | - extract::{Query, State}, | |
| 8 | - response::IntoResponse, | |
| 9 | - Extension, Json, | |
| 10 | -}; | |
| 11 | -use sea_orm::DatabaseConnection; | |
| 12 | -use serde::Deserialize; | |
| 13 | -use std::sync::Arc; | |
| 14 | -use tower_sessions::Session; | |
| 15 | - | |
| 16 | -use crate::dto::navigation_dto::NavigationDTO; | |
| 17 | -use crate::dto::user_profile_dto::UserProfileDTO; | |
| 18 | -use rustwarden_core::auth::jwt::Claims; | |
| 19 | -use rustwarden_core::config::email::EmailConfig; | |
| 20 | -use rustwarden_core::error::AppError; | |
| 21 | -use rustwarden_core::i18n::extractor::Locale; | |
| 22 | -use rustwarden_core::i18n::service::I18nService; | |
| 23 | -use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 24 | -use rustwarden_core::services::refresh_token_service::RefreshTokenService; | |
| 25 | -use rustwarden_core::services::user_service::UserService; | |
| 26 | -use chrono::Utc; | |
| 27 | - | |
| 28 | -/// Inline DTO: Update profile request | |
| 29 | -#[derive(Debug, Deserialize)] | |
| 30 | -pub struct UpdateProfileRequest { | |
| 31 | - pub username: Option<String>, | |
| 32 | - pub email: Option<String>, | |
| 33 | -} | |
| 34 | - | |
| 35 | -/// Inline DTO: Change password request | |
| 36 | -#[derive(Debug, Deserialize)] | |
| 37 | -pub struct ChangePasswordRequest { | |
| 38 | - pub old_password: String, | |
| 39 | - pub new_password: String, | |
| 40 | - pub confirm_password: String, | |
| 41 | -} | |
| 42 | - | |
| 43 | -/// Inline DTO: Change password response | |
| 44 | -#[derive(Debug, serde::Serialize)] | |
| 45 | -pub struct ChangePasswordResponse { | |
| 46 | - pub message: String, | |
| 47 | -} | |
| 48 | - | |
| 49 | -/// Inline DTO: Active sessions response | |
| 50 | -#[derive(Debug, serde::Serialize)] | |
| 51 | -pub struct ActiveSessionsResponse { | |
| 52 | - pub sessions: Vec<rustwarden_core::services::refresh_token_service::ActiveSession>, | |
| 53 | -} | |
| 54 | - | |
| 55 | -/// Inline DTO: Update preferences request | |
| 56 | -#[derive(Debug, Deserialize)] | |
| 57 | -pub struct UpdatePreferencesRequest { | |
| 58 | - pub theme: Option<String>, | |
| 59 | - pub language: Option<String>, | |
| 60 | -} | |
| 61 | - | |
| 62 | -/// Simplified navigation item for templates | |
| 63 | -#[derive(Clone)] | |
| 64 | -#[allow(dead_code)] | |
| 65 | -struct NavItem { | |
| 66 | - id: String, | |
| 67 | - label: String, | |
| 68 | - icon: String, | |
| 69 | - url: String, | |
| 70 | - active: bool, | |
| 71 | - enabled: bool, | |
| 72 | - badge: String, | |
| 73 | - has_badge: bool, | |
| 74 | - disabled_message: String, | |
| 75 | - has_disabled_message: bool, | |
| 76 | -} | |
| 77 | - | |
| 78 | -/// Helper to build NavItems from NavigationDTO | |
| 79 | -fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<NavItem> { | |
| 80 | - navigation | |
| 81 | - .items | |
| 82 | - .iter() | |
| 83 | - .map(|item| NavItem { | |
| 84 | - id: item.id.clone(), | |
| 85 | - label: item.label.clone(), | |
| 86 | - icon: item.icon.clone(), | |
| 87 | - url: item.url.clone(), | |
| 88 | - active: item.id == active_id, | |
| 89 | - enabled: item.enabled, | |
| 90 | - badge: item.badge.clone(), | |
| 91 | - has_badge: item.has_badge, | |
| 92 | - disabled_message: item.disabled_message.clone(), | |
| 93 | - has_disabled_message: item.has_disabled_message, | |
| 94 | - }) | |
| 95 | - .collect() | |
| 96 | -} | |
| 97 | - | |
| 98 | - | |
| 99 | -/// Handler for PUT /settings/profile - Update user profile (PROTECTED) | |
| 100 | -pub async fn update_profile( | |
| 101 | - Extension(claims): Extension<Claims>, | |
| 102 | - State(db): State<DatabaseConnection>, | |
| 103 | - _session: Session, | |
| 104 | - Json(request): Json<UpdateProfileRequest>, | |
| 105 | -) -> Result<impl IntoResponse, AppError> { | |
| 106 | - let old_user = UserService::get_user_by_id(&db, claims.sub) | |
| 107 | - .await? | |
| 108 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 109 | - | |
| 110 | - let old_email = old_user.email.clone(); | |
| 111 | - let new_email = request.email.clone(); | |
| 112 | - let new_username = request.username.clone(); | |
| 113 | - let email_was_changed = new_email.as_ref().map(|e| e != &old_email).unwrap_or(false); | |
| 114 | - | |
| 115 | - let updated_user = | |
| 116 | - UserService::update_profile(&db, claims.sub, request.username, request.email).await?; | |
| 117 | - | |
| 118 | - tracing::info!("Profile updated for user {}", updated_user.username); | |
| 119 | - | |
| 120 | - if email_was_changed { | |
| 121 | - use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 122 | - let password_change_service = PasswordChangeService::new(); | |
| 123 | - let invalidated = password_change_service | |
| 124 | - .invalidate_on_email_change(&db, claims.sub) | |
| 125 | - .await?; | |
| 126 | - if invalidated > 0 { | |
| 127 | - tracing::warn!( | |
| 128 | - "Invalidated {} pending password change request(s) for user {} due to email change", | |
| 129 | - invalidated, | |
| 130 | - updated_user.username | |
| 131 | - ); | |
| 132 | - } | |
| 133 | - } | |
| 134 | - | |
| 135 | - if let Ok(email_config) = EmailConfig::from_env() { | |
| 136 | - let modified_at = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string(); | |
| 137 | - let app_name = email_config | |
| 138 | - .smtp_from_name | |
| 139 | - .clone() | |
| 140 | - .unwrap_or_else(rustwarden_core::config::app::default_app_name); | |
| 141 | - | |
| 142 | - let mut changes = Vec::new(); | |
| 143 | - if new_username.is_some() { | |
| 144 | - changes.push(rustwarden_core::templates::email_templates::AccountChange { | |
| 145 | - field: "Username".to_string(), | |
| 146 | - old_value: Some(old_user.username.clone()), | |
| 147 | - new_value: updated_user.username.clone(), | |
| 148 | - }); | |
| 149 | - } | |
| 150 | - if email_was_changed { | |
| 151 | - if let Some(ref new_email) = new_email { | |
| 152 | - changes.push(rustwarden_core::templates::email_templates::AccountChange { | |
| 153 | - field: "Email".to_string(), | |
| 154 | - old_value: Some(old_email.clone()), | |
| 155 | - new_value: new_email.clone(), | |
| 156 | - }); | |
| 157 | - } | |
| 158 | - } | |
| 159 | - | |
| 160 | - if !changes.is_empty() { | |
| 161 | - let template = rustwarden_core::templates::email_templates::AccountModifiedTemplate::new( | |
| 162 | - Some("fr".to_string()), | |
| 163 | - updated_user.username.clone(), | |
| 164 | - modified_at, | |
| 165 | - changes, | |
| 166 | - Some(app_name.clone()), | |
| 167 | - ); | |
| 168 | - | |
| 169 | - if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) { | |
| 170 | - let subject = "Compte modifié".to_string(); | |
| 171 | - use rustwarden_core::services::email_queue_service::EmailQueueService; | |
| 172 | - | |
| 173 | - if email_was_changed { | |
| 174 | - if let Err(e) = EmailQueueService::enqueue( | |
| 175 | - &db, updated_user.email.clone(), Some(updated_user.username.clone()), | |
| 176 | - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(), | |
| 177 | - subject.clone(), body_html.clone(), body_text.clone(), | |
| 178 | - "account_modified".to_string(), | |
| 179 | - ).await { | |
| 180 | - tracing::warn!("Failed to enqueue account modified email to new address: {}", e); | |
| 181 | - } | |
| 182 | - if let Err(e) = EmailQueueService::enqueue( | |
| 183 | - &db, old_email.clone(), Some(updated_user.username.clone()), | |
| 184 | - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(), | |
| 185 | - subject, body_html, body_text, | |
| 186 | - "account_modified".to_string(), | |
| 187 | - ).await { | |
| 188 | - tracing::warn!("Failed to enqueue account modified email to old address: {}", e); | |
| 189 | - } | |
| 190 | - } else if let Err(e) = EmailQueueService::enqueue( | |
| 191 | - &db, updated_user.email.clone(), Some(updated_user.username.clone()), | |
| 192 | - email_config.smtp_from.clone(), email_config.smtp_from_name.clone(), | |
| 193 | - subject, body_html, body_text, | |
| 194 | - "account_modified".to_string(), | |
| 195 | - ).await { | |
| 196 | - tracing::warn!("Failed to enqueue account modified notification: {}", e); | |
| 197 | - } | |
| 198 | - } | |
| 199 | - } | |
| 200 | - } | |
| 201 | - | |
| 202 | - Ok(Json(serde_json::json!({ | |
| 203 | - "message": "Profile updated successfully", | |
| 204 | - "username": updated_user.username, | |
| 205 | - "email": updated_user.email, | |
| 206 | - }))) | |
| 207 | -} | |
| 208 | - | |
| 209 | -/// Handler for PUT /settings/password - Change user password (PROTECTED) | |
| 210 | -pub async fn change_password( | |
| 211 | - Extension(claims): Extension<Claims>, | |
| 212 | - State(db): State<DatabaseConnection>, | |
| 213 | - session: Session, | |
| 214 | - Json(request): Json<ChangePasswordRequest>, | |
| 215 | -) -> Result<impl IntoResponse, AppError> { | |
| 216 | - if request.new_password != request.confirm_password { | |
| 217 | - return Err(AppError::Validation( | |
| 218 | - "New password and confirmation do not match".to_string(), | |
| 219 | - )); | |
| 220 | - } | |
| 221 | - | |
| 222 | - let require_email = AppSettingsService::get_bool( | |
| 223 | - &db, | |
| 224 | - "password_change_require_email", | |
| 225 | - true, | |
| 226 | - ) | |
| 227 | - .await?; | |
| 228 | - | |
| 229 | - if require_email { | |
| 230 | - let current_token_id: Option<uuid::Uuid> = | |
| 231 | - session.get("refresh_token_id").await.ok().flatten(); | |
| 232 | - | |
| 233 | - let (_request_id, token) = UserService::change_password( | |
| 234 | - &db, | |
| 235 | - claims.sub, | |
| 236 | - &request.old_password, | |
| 237 | - &request.new_password, | |
| 238 | - current_token_id, | |
| 239 | - ) | |
| 240 | - .await?; | |
| 241 | - | |
| 242 | - tracing::info!( | |
| 243 | - "Created pending password change request for user {}", | |
| 244 | - claims.sub | |
| 245 | - ); | |
| 246 | - | |
| 247 | - let user = UserService::get_user_by_id(&db, claims.sub) | |
| 248 | - .await? | |
| 249 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 250 | - | |
| 251 | - let email_config = EmailConfig::from_env() | |
| 252 | - .map_err(|e| AppError::Config(format!("Email configuration error: {}", e)))?; | |
| 253 | - | |
| 254 | - let confirmation_url = format!( | |
| 255 | - "{}/confirm-password-change?token={}", | |
| 256 | - email_config.email_base_url, token | |
| 257 | - ); | |
| 258 | - | |
| 259 | - let template = | |
| 260 | - rustwarden_core::templates::email_templates::PasswordChangeConfirmationEmailTemplate::new( | |
| 261 | - Some("fr".to_string()), | |
| 262 | - user.username.clone(), | |
| 263 | - confirmation_url, | |
| 264 | - Some( | |
| 265 | - email_config | |
| 266 | - .smtp_from_name | |
| 267 | - .clone() | |
| 268 | - .unwrap_or_else(rustwarden_core::config::app::default_app_name), | |
| 269 | - ), | |
| 270 | - ); | |
| 271 | - | |
| 272 | - let body_html = template | |
| 273 | - .render_html() | |
| 274 | - .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?; | |
| 275 | - let body_text = template.render_text(); | |
| 276 | - | |
| 277 | - let subject = "Confirmation de changement de mot de passe".to_string(); | |
| 278 | - | |
| 279 | - let email_svc = rustwarden_core::services::email_service::EmailService::new(email_config) | |
| 280 | - .map_err(|e| AppError::Internal(format!("Failed to create email service: {}", e)))?; | |
| 281 | - email_svc | |
| 282 | - .send_email( | |
| 283 | - &user.email, | |
| 284 | - Some(&user.username), | |
| 285 | - &subject, | |
| 286 | - &body_html, | |
| 287 | - &body_text, | |
| 288 | - ) | |
| 289 | - .await | |
| 290 | - .map_err(|e| { | |
| 291 | - tracing::error!( | |
| 292 | - "Failed to send password change confirmation email for user {}: {}", | |
| 293 | - user.username, | |
| 294 | - e | |
| 295 | - ); | |
| 296 | - AppError::Internal( | |
| 297 | - "Failed to send confirmation email. Please try again later.".to_string(), | |
| 298 | - ) | |
| 299 | - })?; | |
| 300 | - | |
| 301 | - tracing::info!( | |
| 302 | - "Password change confirmation email sent for user {}", | |
| 303 | - user.username | |
| 304 | - ); | |
| 305 | - | |
| 306 | - Ok(Json(ChangePasswordResponse { | |
| 307 | - message: "email_confirmation_sent".to_string(), | |
| 308 | - })) | |
| 309 | - } else { | |
| 310 | - use rustwarden_core::services::password_service::PasswordService; | |
| 311 | - | |
| 312 | - let user = UserService::get_user_by_id(&db, claims.sub) | |
| 313 | - .await? | |
| 314 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 315 | - | |
| 316 | - if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? { | |
| 317 | - return Err(AppError::Validation( | |
| 318 | - "Old password is incorrect".to_string(), | |
| 319 | - )); | |
| 320 | - } | |
| 321 | - | |
| 322 | - rustwarden_core::middleware::password_complexity::validate_password_complexity( | |
| 323 | - &db, | |
| 324 | - &request.new_password, | |
| 325 | - ) | |
| 326 | - .await?; | |
| 327 | - | |
| 328 | - let new_hash = PasswordService::hash_password(&request.new_password).await?; | |
| 329 | - UserService::update_password_hash(&db, claims.sub, &new_hash).await?; | |
| 330 | - RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?; | |
| 331 | - | |
| 332 | - tracing::info!( | |
| 333 | - "Password changed directly (no email confirmation) for user {}", | |
| 334 | - claims.sub | |
| 335 | - ); | |
| 336 | - | |
| 337 | - Ok(Json(ChangePasswordResponse { | |
| 338 | - message: "password_changed_directly".to_string(), | |
| 339 | - })) | |
| 340 | - } | |
| 341 | -} | |
| 342 | - | |
| 343 | -/// Query parameters for password change confirmation | |
| 344 | -#[derive(Deserialize)] | |
| 345 | -pub struct ConfirmPasswordChangeQuery { | |
| 346 | - token: String, | |
| 347 | -} | |
| 348 | - | |
| 349 | -/// Handler for GET /confirm-password-change?token={token} - Confirm password change (PUBLIC) | |
| 350 | -pub async fn confirm_password_change( | |
| 351 | - State(db): State<DatabaseConnection>, | |
| 352 | - Query(query): Query<ConfirmPasswordChangeQuery>, | |
| 353 | -) -> Result<impl IntoResponse, AppError> { | |
| 354 | - use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 355 | - | |
| 356 | - let password_change_service = PasswordChangeService::new(); | |
| 357 | - | |
| 358 | - let _request = password_change_service | |
| 359 | - .find_by_token(&db, &query.token) | |
| 360 | - .await?; | |
| 361 | - | |
| 362 | - let user_id = password_change_service | |
| 363 | - .confirm_request(&db, &query.token) | |
| 364 | - .await?; | |
| 365 | - | |
| 366 | - RefreshTokenService::revoke_all_tokens_for_user(&db, user_id).await?; | |
| 367 | - | |
| 368 | - tracing::info!( | |
| 369 | - "Password change confirmed and applied for user {}. All sessions invalidated.", | |
| 370 | - user_id | |
| 371 | - ); | |
| 372 | - | |
| 373 | - Ok(Json(serde_json::json!({ | |
| 374 | - "success": true, | |
| 375 | - "message": "Password change confirmed successfully. Please log in with your new password." | |
| 376 | - }))) | |
| 377 | -} | |
| 378 | - | |
| 379 | -/// Handler for DELETE /settings/password/pending - Cancel pending password change request (PROTECTED) | |
| 380 | -pub async fn cancel_pending_password_change( | |
| 381 | - Extension(claims): Extension<Claims>, | |
| 382 | - State(db): State<DatabaseConnection>, | |
| 383 | -) -> Result<impl IntoResponse, AppError> { | |
| 384 | - use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 385 | - | |
| 386 | - let password_change_service = PasswordChangeService::new(); | |
| 387 | - | |
| 388 | - password_change_service | |
| 389 | - .cancel_pending_request(&db, claims.sub) | |
| 390 | - .await?; | |
| 391 | - | |
| 392 | - tracing::info!( | |
| 393 | - "Canceled pending password change request for user {}", | |
| 394 | - claims.sub | |
| 395 | - ); | |
| 396 | - | |
| 397 | - Ok(Json(serde_json::json!({ | |
| 398 | - "success": true, | |
| 399 | - "message": "Pending password change request canceled successfully." | |
| 400 | - }))) | |
| 401 | -} | |
| 402 | - | |
| 403 | -/// Handler for GET /settings/sessions - List active sessions (PROTECTED) | |
| 404 | -pub async fn list_sessions( | |
| 405 | - Extension(claims): Extension<Claims>, | |
| 406 | - State(db): State<DatabaseConnection>, | |
| 407 | - session: Session, | |
| 408 | -) -> Result<impl IntoResponse, AppError> { | |
| 409 | - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 410 | - | |
| 411 | - let mut sessions = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; | |
| 412 | - | |
| 413 | - if let Some(current_id) = current_token_id { | |
| 414 | - for session in &mut sessions { | |
| 415 | - if session.id == current_id { | |
| 416 | - session.is_current = true; | |
| 417 | - break; | |
| 418 | - } | |
| 419 | - } | |
| 420 | - } | |
| 421 | - | |
| 422 | - Ok(Json(ActiveSessionsResponse { sessions })) | |
| 423 | -} | |
| 424 | - | |
| 425 | -/// Handler for DELETE /settings/sessions/:id - Revoke session (PROTECTED) | |
| 426 | -pub async fn revoke_session( | |
| 427 | - Extension(claims): Extension<Claims>, | |
| 428 | - State(db): State<DatabaseConnection>, | |
| 429 | - session: Session, | |
| 430 | - axum::extract::Path(session_id): axum::extract::Path<uuid::Uuid>, | |
| 431 | -) -> Result<impl IntoResponse, AppError> { | |
| 432 | - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 433 | - | |
| 434 | - if Some(session_id) == current_token_id { | |
| 435 | - return Err(AppError::Validation( | |
| 436 | - "Cannot revoke current session. Please log out instead.".to_string(), | |
| 437 | - )); | |
| 438 | - } | |
| 439 | - | |
| 440 | - use rustwarden_core::models::refresh_token; | |
| 441 | - use sea_orm::EntityTrait; | |
| 442 | - | |
| 443 | - let token = refresh_token::Entity::find_by_id(session_id) | |
| 444 | - .one(&db) | |
| 445 | - .await? | |
| 446 | - .ok_or_else(|| AppError::NotFound("Session not found".to_string()))?; | |
| 447 | - | |
| 448 | - if token.user_id != claims.sub { | |
| 449 | - return Err(AppError::Validation( | |
| 450 | - "You can only revoke your own sessions".to_string(), | |
| 451 | - )); | |
| 452 | - } | |
| 453 | - | |
| 454 | - RefreshTokenService::revoke_session(&db, session_id).await?; | |
| 455 | - | |
| 456 | - tracing::info!("Session {} revoked by user {}", session_id, claims.sub); | |
| 457 | - | |
| 458 | - Ok(Json(serde_json::json!({ | |
| 459 | - "message": "Session revoked successfully", | |
| 460 | - }))) | |
| 461 | -} | |
| 462 | - | |
| 463 | -#[cfg(test)] | |
| 464 | -mod tests { | |
| 465 | - use super::*; | |
| 466 | - | |
| 467 | - #[test] | |
| 468 | - fn test_settings_template_compiles() {} | |
| 469 | -} | |
| 470 | - | |
| 471 | -// ============================================================================ | |
| 472 | -// User Preferences Handlers | |
| 473 | -// ============================================================================ | |
| 474 | - | |
| 475 | -/// GET /settings/preferences - Display user preferences (JSON) | |
| 476 | -pub async fn get_preferences( | |
| 477 | - State(db): State<DatabaseConnection>, | |
| 478 | - Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>, | |
| 479 | -) -> Result<impl IntoResponse, AppError> { | |
| 480 | - use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 481 | - | |
| 482 | - let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 483 | - Some(prefs) => prefs, | |
| 484 | - None => { | |
| 485 | - UserPreferencesService::create_default_preferences(&db, claims.sub).await? | |
| 486 | - } | |
| 487 | - }; | |
| 488 | - | |
| 489 | - Ok(Json(serde_json::json!({ | |
| 490 | - "theme": preferences.theme, | |
| 491 | - "language": preferences.language, | |
| 492 | - }))) | |
| 493 | -} | |
| 494 | - | |
| 495 | -/// PUT /settings/preferences - Update user preferences | |
| 496 | -pub async fn update_preferences( | |
| 497 | - State(db): State<DatabaseConnection>, | |
| 498 | - Extension(claims): Extension<rustwarden_core::auth::jwt::Claims>, | |
| 499 | - axum::extract::Form(form): axum::extract::Form<UpdatePreferencesRequest>, | |
| 500 | -) -> Result<impl IntoResponse, AppError> { | |
| 501 | - use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 502 | - | |
| 503 | - let current_prefs = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 504 | - Some(prefs) => prefs, | |
| 505 | - None => { | |
| 506 | - UserPreferencesService::create_default_preferences(&db, claims.sub).await? | |
| 507 | - } | |
| 508 | - }; | |
| 509 | - | |
| 510 | - let theme = form.theme.unwrap_or(current_prefs.theme.clone()); | |
| 511 | - let language = form.language.unwrap_or(current_prefs.language.clone()); | |
| 512 | - let language_changed = language != current_prefs.language; | |
| 513 | - | |
| 514 | - let updated_prefs = | |
| 515 | - UserPreferencesService::update_preferences(&db, claims.sub, theme, language.clone()) | |
| 516 | - .await?; | |
| 517 | - | |
| 518 | - tracing::info!( | |
| 519 | - "User {} updated preferences: theme={}, language={}", | |
| 520 | - claims.sub, | |
| 521 | - updated_prefs.theme, | |
| 522 | - updated_prefs.language | |
| 523 | - ); | |
| 524 | - | |
| 525 | - let json_response = Json(serde_json::json!({ | |
| 526 | - "message": "Preferences updated successfully", | |
| 527 | - "theme": updated_prefs.theme, | |
| 528 | - "language": updated_prefs.language, | |
| 529 | - "language_changed": language_changed, | |
| 530 | - "reload_required": language_changed, | |
| 531 | - })); | |
| 532 | - | |
| 533 | - use time::Duration as TimeDuration; | |
| 534 | - use axum::http::header; | |
| 535 | - | |
| 536 | - let theme_cookie = axum_extra::extract::cookie::Cookie::build(("theme", updated_prefs.theme.clone())) | |
| 537 | - .http_only(false) | |
| 538 | - .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 539 | - .path("/") | |
| 540 | - .max_age(TimeDuration::seconds(31536000)); | |
| 541 | - | |
| 542 | - let mut response = json_response.into_response(); | |
| 543 | - | |
| 544 | - if let Ok(hv) = theme_cookie.to_string().parse() { | |
| 545 | - response.headers_mut().append(header::SET_COOKIE, hv); | |
| 546 | - } | |
| 547 | - | |
| 548 | - if language_changed && matches!(language.as_str(), "fr" | "en" | "de" | "es") { | |
| 549 | - let locale_cookie = axum_extra::extract::cookie::Cookie::build(("locale", language)) | |
| 550 | - .http_only(false) | |
| 551 | - .same_site(axum_extra::extract::cookie::SameSite::Lax) | |
| 552 | - .path("/") | |
| 553 | - .max_age(TimeDuration::seconds(31536000)); | |
| 554 | - | |
| 555 | - if let Ok(hv) = locale_cookie.to_string().parse() { | |
| 556 | - response.headers_mut().append(header::SET_COOKIE, hv); | |
| 557 | - } | |
| 558 | - } | |
| 559 | - | |
| 560 | - Ok(response) | |
| 561 | -} | |
| 562 | - | |
| 563 | -// ============================================================================ | |
| 564 | -// GET Handlers for HTML Pages (with i18n) | |
| 565 | -// ============================================================================ | |
| 566 | - | |
| 567 | -/// Custom filters for Askama templates | |
| 568 | -#[allow(dead_code)] | |
| 569 | -mod filters { | |
| 570 | - use askama::Result; | |
| 571 | - | |
| 572 | - #[allow(dead_code)] | |
| 573 | - #[askama::filter_fn] | |
| 574 | - pub fn default<T: std::fmt::Display>( | |
| 575 | - value: T, | |
| 576 | - _values: &dyn askama::Values, | |
| 577 | - default_value: &str, | |
| 578 | - ) -> Result<String> { | |
| 579 | - let value_str = value.to_string(); | |
| 580 | - if value_str.is_empty() { | |
| 581 | - Ok(default_value.to_string()) | |
| 582 | - } else { | |
| 583 | - Ok(value_str) | |
| 584 | - } | |
| 585 | - } | |
| 586 | - | |
| 587 | - #[askama::filter_fn] | |
| 588 | - pub fn length<I: IntoIterator>(value: I, _values: &dyn askama::Values) -> Result<usize> { | |
| 589 | - Ok(value.into_iter().count()) | |
| 590 | - } | |
| 591 | -} | |
| 592 | - | |
| 593 | -/// Profile page template | |
| 594 | -#[derive(Template)] | |
| 595 | -#[template(path = "profile.html")] | |
| 596 | -#[allow(dead_code)] | |
| 597 | -struct ProfileTemplate { | |
| 598 | - page_title: String, | |
| 599 | - csrf_token: String, | |
| 600 | - app_name: String, | |
| 601 | - app_version: String, | |
| 602 | - app_domain: String, | |
| 603 | - app_description: String, | |
| 604 | - username: String, | |
| 605 | - email: String, | |
| 606 | - user_id: String, | |
| 607 | - created_at: String, | |
| 608 | - roles: String, | |
| 609 | - roles_text: String, | |
| 610 | - initials: String, | |
| 611 | - avatar_color: String, | |
| 612 | - nav_items: Vec<NavItem>, | |
| 613 | - has_pending_password_change: bool, | |
| 614 | - user_menu_profile: String, | |
| 615 | - user_menu_logout: String, | |
| 616 | - | |
| 617 | - // Translated strings (hardcoded FR) | |
| 618 | - title: String, | |
| 619 | - subtitle: String, | |
| 620 | - account_info: String, | |
| 621 | - username_label: String, | |
| 622 | - username_public: String, | |
| 623 | - username_placeholder: String, | |
| 624 | - username_pattern: String, | |
| 625 | - username_help: String, | |
| 626 | - email_label: String, | |
| 627 | - email_used_for: String, | |
| 628 | - email_warning: String, | |
| 629 | - email_placeholder: String, | |
| 630 | - email_change_info: String, | |
| 631 | - save_button: String, | |
| 632 | - cancel: String, | |
| 633 | - additional_info: String, | |
| 634 | - account_created: String, | |
| 635 | - roles_label: String, | |
| 636 | - breadcrumb_home: String, | |
| 637 | - breadcrumb_settings: String, | |
| 638 | - breadcrumb_profile: String, | |
| 639 | -} | |
| 640 | - | |
| 641 | -rustwarden_core::impl_template_response!(ProfileTemplate); | |
| 642 | - | |
| 643 | -/// Query parameters for password page | |
| 644 | -#[derive(Deserialize)] | |
| 645 | -pub struct PasswordPageQuery { | |
| 646 | - must_change: Option<String>, | |
| 647 | -} | |
| 648 | - | |
| 649 | -/// Password page template | |
| 650 | -#[derive(Template)] | |
| 651 | -#[template(path = "settings/password.html")] | |
| 652 | -#[allow(dead_code)] | |
| 653 | -struct PasswordTemplate { | |
| 654 | - page_title: String, | |
| 655 | - csrf_token: String, | |
| 656 | - app_name: String, | |
| 657 | - app_version: String, | |
| 658 | - app_domain: String, | |
| 659 | - app_description: String, | |
| 660 | - username: String, | |
| 661 | - email: String, | |
| 662 | - roles_text: String, | |
| 663 | - initials: String, | |
| 664 | - avatar_color: String, | |
| 665 | - nav_items: Vec<NavItem>, | |
| 666 | - user_menu_profile: String, | |
| 667 | - user_menu_logout: String, | |
| 668 | - | |
| 669 | - must_change: bool, | |
| 670 | - must_change_message: String, | |
| 671 | - has_pending_request: bool, | |
| 672 | - pending_request_message: String, | |
| 673 | - pending_request_info: String, | |
| 674 | - pending_request_cancel: String, | |
| 675 | - // Translated strings (hardcoded FR) | |
| 676 | - title: String, | |
| 677 | - subtitle: String, | |
| 678 | - new_password_label: String, | |
| 679 | - current_password_label: String, | |
| 680 | - current_password_placeholder: String, | |
| 681 | - new_password_placeholder: String, | |
| 682 | - required: String, | |
| 683 | - requirements: String, | |
| 684 | - strength_label: String, | |
| 685 | - strength_weak: String, | |
| 686 | - strength_medium: String, | |
| 687 | - strength_strong: String, | |
| 688 | - strength_too_short: String, | |
| 689 | - strength_missing_lowercase: String, | |
| 690 | - strength_missing_uppercase: String, | |
| 691 | - strength_missing_digit: String, | |
| 692 | - strength_missing_special: String, | |
| 693 | - confirm_password_label: String, | |
| 694 | - confirm_password_placeholder: String, | |
| 695 | - passwords_match: String, | |
| 696 | - passwords_not_match: String, | |
| 697 | - change_button: String, | |
| 698 | - changing: String, | |
| 699 | - logout_warning_title: String, | |
| 700 | - logout_warning_desc: String, | |
| 701 | - saved: String, | |
| 702 | - error_all_fields_required: String, | |
| 703 | - error_empty_data: String, | |
| 704 | - error_invalid_data: String, | |
| 705 | - error_incorrect_current: String, | |
| 706 | - error_reading_response: String, | |
| 707 | - error_415: String, | |
| 708 | - network_error: String, | |
| 709 | - security_tips_title: String, | |
| 710 | - security_tip_unique: String, | |
| 711 | - security_tip_share: String, | |
| 712 | - security_tip_regular: String, | |
| 713 | - security_tip_2fa: String, | |
| 714 | - security_tip_manager: String, | |
| 715 | - breadcrumb_home: String, | |
| 716 | - breadcrumb_settings: String, | |
| 717 | - breadcrumb_password: String, | |
| 718 | - cancel: String, | |
| 719 | - logout_label: String, | |
| 720 | - has_pending_password_change: bool, | |
| 721 | - // Password complexity rules (from app_settings) | |
| 722 | - pw_min_length: usize, | |
| 723 | - pw_require_uppercase: bool, | |
| 724 | - pw_require_lowercase: bool, | |
| 725 | - pw_require_digits: bool, | |
| 726 | - pw_require_special: bool, | |
| 727 | - password_change_require_email: bool, | |
| 728 | - subtitle_no_email: String, | |
| 729 | - saved_direct: String, | |
| 730 | -} | |
| 731 | - | |
| 732 | -rustwarden_core::impl_template_response!(PasswordTemplate); | |
| 733 | - | |
| 734 | -/// Session item for template | |
| 735 | -#[derive(Clone, Debug)] | |
| 736 | -#[allow(dead_code)] | |
| 737 | -struct SessionItem { | |
| 738 | - id: String, | |
| 739 | - device_info: String, | |
| 740 | - ip_address: String, | |
| 741 | - last_activity: String, | |
| 742 | - created_at: String, | |
| 743 | - is_current: bool, | |
| 744 | -} | |
| 745 | - | |
| 746 | -/// Sessions page template | |
| 747 | -#[derive(Template)] | |
| 748 | -#[template(path = "settings/sessions.html")] | |
| 749 | -#[allow(dead_code)] | |
| 750 | -struct SessionsTemplate { | |
| 751 | - page_title: String, | |
| 752 | - csrf_token: String, | |
| 753 | - app_name: String, | |
| 754 | - app_version: String, | |
| 755 | - app_domain: String, | |
| 756 | - app_description: String, | |
| 757 | - username: String, | |
| 758 | - email: String, | |
| 759 | - roles_text: String, | |
| 760 | - initials: String, | |
| 761 | - avatar_color: String, | |
| 762 | - sessions: Vec<SessionItem>, | |
| 763 | - nav_items: Vec<NavItem>, | |
| 764 | - has_pending_password_change: bool, | |
| 765 | - user_menu_profile: String, | |
| 766 | - user_menu_logout: String, | |
| 767 | - | |
| 768 | - // Translated strings (hardcoded FR) | |
| 769 | - title: String, | |
| 770 | - subtitle: String, | |
| 771 | - current_session: String, | |
| 772 | - current_badge: String, | |
| 773 | - unknown_device: String, | |
| 774 | - ip_address: String, | |
| 775 | - created_at: String, | |
| 776 | - last_activity: String, | |
| 777 | - session_id: String, | |
| 778 | - revoke: String, | |
| 779 | - revoke_confirm_template: String, | |
| 780 | - revoke_success: String, | |
| 781 | - revoke_error: String, | |
| 782 | - no_sessions: String, | |
| 783 | - no_sessions_desc: String, | |
| 784 | - danger_zone: String, | |
| 785 | - danger_zone_desc: String, | |
| 786 | - revoke_all: String, | |
| 787 | - revoke_all_confirm: String, | |
| 788 | - revoke_all_success: String, | |
| 789 | - revoke_all_error: String, | |
| 790 | - about_title: String, | |
| 791 | - about_desc: String, | |
| 792 | - back_to_settings: String, | |
| 793 | - breadcrumb_home: String, | |
| 794 | - breadcrumb_settings: String, | |
| 795 | - breadcrumb_sessions: String, | |
| 796 | -} | |
| 797 | - | |
| 798 | -rustwarden_core::impl_template_response!(SessionsTemplate); | |
| 799 | - | |
| 800 | -/// Preferences page template | |
| 801 | -#[derive(Template)] | |
| 802 | -#[template(path = "settings/preferences.html")] | |
| 803 | -#[allow(dead_code)] | |
| 804 | -struct PreferencesTemplate { | |
| 805 | - page_title: String, | |
| 806 | - csrf_token: String, | |
| 807 | - app_name: String, | |
| 808 | - app_version: String, | |
| 809 | - app_domain: String, | |
| 810 | - app_description: String, | |
| 811 | - username: String, | |
| 812 | - email: String, | |
| 813 | - roles_text: String, | |
| 814 | - initials: String, | |
| 815 | - avatar_color: String, | |
| 816 | - theme: String, | |
| 817 | - is_theme_light: bool, | |
| 818 | - is_theme_dark: bool, | |
| 819 | - is_theme_auto: bool, | |
| 820 | - language: String, | |
| 821 | - is_language_fr: bool, | |
| 822 | - is_language_en: bool, | |
| 823 | - is_language_de: bool, | |
| 824 | - is_language_es: bool, | |
| 825 | - current_locale: String, | |
| 826 | - supported_languages: Vec<(String, String, bool)>, | |
| 827 | - nav_items: Vec<NavItem>, | |
| 828 | - has_pending_password_change: bool, | |
| 829 | - user_menu_profile: String, | |
| 830 | - user_menu_logout: String, | |
| 831 | - | |
| 832 | - // Translated strings (hardcoded FR) | |
| 833 | - title: String, | |
| 834 | - subtitle: String, | |
| 835 | - theme_label: String, | |
| 836 | - theme_light: String, | |
| 837 | - theme_light_desc: String, | |
| 838 | - theme_dark: String, | |
| 839 | - theme_dark_desc: String, | |
| 840 | - theme_auto: String, | |
| 841 | - theme_auto_desc: String, | |
| 842 | - theme_preview: String, | |
| 843 | - language_label: String, | |
| 844 | - language_fr: String, | |
| 845 | - language_fr_desc: String, | |
| 846 | - language_en: String, | |
| 847 | - language_en_desc: String, | |
| 848 | - language_de: String, | |
| 849 | - language_de_desc: String, | |
| 850 | - language_es: String, | |
| 851 | - language_es_desc: String, | |
| 852 | - translation_warning: String, | |
| 853 | - modified: String, | |
| 854 | - modified_desc: String, | |
| 855 | - save_button: String, | |
| 856 | - saving: String, | |
| 857 | - cancel: String, | |
| 858 | - saved_success: String, | |
| 859 | - saved_reload: String, | |
| 860 | - saved_reload_json: String, | |
| 861 | - saved_success_json: String, | |
| 862 | - error_prefix: String, | |
| 863 | - error_prefix_json: String, | |
| 864 | - network_error: String, | |
| 865 | - network_error_json: String, | |
| 866 | - save_button_json: String, | |
| 867 | - sync_title: String, | |
| 868 | - sync_desc: String, | |
| 869 | - preview_dark: String, | |
| 870 | - preview_light: String, | |
| 871 | - preview_auto: String, | |
| 872 | - breadcrumb_home: String, | |
| 873 | - breadcrumb_settings: String, | |
| 874 | - breadcrumb_preferences: String, | |
| 875 | -} | |
| 876 | - | |
| 877 | -rustwarden_core::impl_template_response!(PreferencesTemplate); | |
| 878 | - | |
| 879 | -/// Handler for GET /profile - Display profile page (PROTECTED) | |
| 880 | -pub async fn get_profile_page( | |
| 881 | - Extension(claims): Extension<Claims>, | |
| 882 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 883 | - Extension(locale): Extension<Locale>, | |
| 884 | - State(db): State<DatabaseConnection>, | |
| 885 | - session: Session, | |
| 886 | -) -> Result<impl IntoResponse, AppError> { | |
| 887 | - let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 888 | - .await? | |
| 889 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 890 | - | |
| 891 | - let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 892 | - let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 893 | - | |
| 894 | - let locale_str = locale.as_str(); | |
| 895 | - | |
| 896 | - let layout = rustwarden_core::templates::LayoutContext::new("Profil", ""); | |
| 897 | - | |
| 898 | - use crate::template_helpers::csrf_token_helper; | |
| 899 | - let csrf_token = csrf_token_helper(&session).await; | |
| 900 | - | |
| 901 | - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 902 | - let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 903 | - | |
| 904 | - let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 905 | - .await? | |
| 906 | - .map(|s| s.value) | |
| 907 | - .unwrap_or_else(|| "localhost".to_string()); | |
| 908 | - let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 909 | - .await? | |
| 910 | - .map(|s| s.value) | |
| 911 | - .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 912 | - | |
| 913 | - let template = ProfileTemplate { | |
| 914 | - page_title: layout.page_title.clone(), | |
| 915 | - csrf_token, | |
| 916 | - app_name: layout.app_name.clone(), | |
| 917 | - app_version: layout.app_version.clone(), | |
| 918 | - app_domain, | |
| 919 | - app_description, | |
| 920 | - username: user.username.clone(), | |
| 921 | - email: user.email.clone(), | |
| 922 | - user_id: user.id.to_string(), | |
| 923 | - created_at: user.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 924 | - roles: user.roles.join(", "), | |
| 925 | - roles_text: user.roles.join(", "), | |
| 926 | - initials: user.initials.clone(), | |
| 927 | - avatar_color: user.avatar_color.clone(), | |
| 928 | - nav_items, | |
| 929 | - has_pending_password_change: false, | |
| 930 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 931 | - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 932 | - | |
| 933 | - title: i18n_service.translate("profile.title", locale_str).await, | |
| 934 | - subtitle: i18n_service.translate("profile.subtitle", locale_str).await, | |
| 935 | - account_info: i18n_service.translate("profile.account_info", locale_str).await, | |
| 936 | - username_label: i18n_service.translate("profile.username", locale_str).await, | |
| 937 | - username_public: i18n_service.translate("profile.username_public", locale_str).await, | |
| 938 | - username_placeholder: i18n_service.translate("profile.username_placeholder", locale_str).await, | |
| 939 | - username_pattern: i18n_service.translate("profile.username_pattern", locale_str).await, | |
| 940 | - username_help: i18n_service.translate("profile.username_help", locale_str).await, | |
| 941 | - email_label: i18n_service.translate("profile.email_label", locale_str).await, | |
| 942 | - email_used_for: i18n_service.translate("profile.email_used_for", locale_str).await, | |
| 943 | - email_warning: i18n_service.translate("profile.email_warning", locale_str).await, | |
| 944 | - email_placeholder: i18n_service.translate("profile.email_placeholder", locale_str).await, | |
| 945 | - email_change_info: i18n_service.translate("profile.email_change_info", locale_str).await, | |
| 946 | - save_button: i18n_service.translate("profile.save", locale_str).await, | |
| 947 | - cancel: i18n_service.translate("common.cancel", locale_str).await, | |
| 948 | - additional_info: i18n_service.translate("profile.additional_info", locale_str).await, | |
| 949 | - account_created: i18n_service.translate("profile.account_created", locale_str).await, | |
| 950 | - roles_label: i18n_service.translate("profile.roles", locale_str).await, | |
| 951 | - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 952 | - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await, | |
| 953 | - breadcrumb_profile: i18n_service.translate("breadcrumb.profile", locale_str).await, | |
| 954 | - }; | |
| 955 | - | |
| 956 | - Ok(template) | |
| 957 | -} | |
| 958 | - | |
| 959 | -/// Handler for GET /settings/password - Display password change page (PROTECTED) | |
| 960 | -pub async fn get_password_page( | |
| 961 | - Extension(claims): Extension<Claims>, | |
| 962 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 963 | - Extension(locale): Extension<Locale>, | |
| 964 | - State(db): State<DatabaseConnection>, | |
| 965 | - session: Session, | |
| 966 | - Query(query): Query<PasswordPageQuery>, | |
| 967 | -) -> Result<impl IntoResponse, AppError> { | |
| 968 | - let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 969 | - .await? | |
| 970 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 971 | - | |
| 972 | - let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 973 | - let user = UserProfileDTO::new(user_model.clone(), roles.clone()); | |
| 974 | - | |
| 975 | - let locale_str = locale.as_str(); | |
| 976 | - | |
| 977 | - let must_change = query.must_change.as_deref() == Some("true"); | |
| 978 | - let must_change_message = if must_change { | |
| 979 | - i18n_service.translate("password.must_change_message", locale_str).await | |
| 980 | - } else { | |
| 981 | - String::new() | |
| 982 | - }; | |
| 983 | - | |
| 984 | - let pw_rules = rustwarden_core::middleware::password_complexity::PasswordRules::from_db(&db).await; | |
| 985 | - | |
| 986 | - use rustwarden_core::services::password_change_service::PasswordChangeService; | |
| 987 | - let password_change_service = PasswordChangeService::new(); | |
| 988 | - let pending_request = password_change_service | |
| 989 | - .find_pending_by_user_id(&db, user_model.id) | |
| 990 | - .await?; | |
| 991 | - let has_pending_request = pending_request.is_some(); | |
| 992 | - let has_pending_password_change = has_pending_request; | |
| 993 | - | |
| 994 | - let pending_request_message = if has_pending_request { | |
| 995 | - i18n_service.translate("password.pending_request_message", locale_str).await | |
| 996 | - } else { | |
| 997 | - String::new() | |
| 998 | - }; | |
| 999 | - let pending_request_info = if has_pending_request { | |
| 1000 | - i18n_service.translate("password.pending_request_info", locale_str).await | |
| 1001 | - } else { | |
| 1002 | - String::new() | |
| 1003 | - }; | |
| 1004 | - let pending_request_cancel = i18n_service.translate("password.pending_request_cancel", locale_str).await; | |
| 1005 | - | |
| 1006 | - let layout = rustwarden_core::templates::LayoutContext::new("Mot de passe", ""); | |
| 1007 | - use crate::template_helpers::csrf_token_helper; | |
| 1008 | - let csrf_token = csrf_token_helper(&session).await; | |
| 1009 | - | |
| 1010 | - let mut navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 1011 | - navigation.set_pending_password_change(has_pending_password_change); | |
| 1012 | - let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 1013 | - | |
| 1014 | - let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 1015 | - .await? | |
| 1016 | - .map(|s| s.value) | |
| 1017 | - .unwrap_or_else(|| "localhost".to_string()); | |
| 1018 | - let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 1019 | - .await? | |
| 1020 | - .map(|s| s.value) | |
| 1021 | - .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 1022 | - | |
| 1023 | - let page_title = i18n_service.translate("password.page_title", locale_str).await; | |
| 1024 | - | |
| 1025 | - let template = PasswordTemplate { | |
| 1026 | - page_title, | |
| 1027 | - csrf_token, | |
| 1028 | - app_name: layout.app_name.clone(), | |
| 1029 | - app_version: layout.app_version.clone(), | |
| 1030 | - app_domain, | |
| 1031 | - app_description, | |
| 1032 | - username: user.username.clone(), | |
| 1033 | - email: user.email.clone(), | |
| 1034 | - roles_text: user.roles.join(", "), | |
| 1035 | - initials: user.initials.clone(), | |
| 1036 | - avatar_color: user.avatar_color.clone(), | |
| 1037 | - nav_items, | |
| 1038 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 1039 | - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 1040 | - | |
| 1041 | - must_change, | |
| 1042 | - must_change_message, | |
| 1043 | - has_pending_request, | |
| 1044 | - pending_request_message, | |
| 1045 | - pending_request_info, | |
| 1046 | - pending_request_cancel, | |
| 1047 | - title: i18n_service.translate("password.title", locale_str).await, | |
| 1048 | - subtitle: i18n_service.translate("password.subtitle", locale_str).await, | |
| 1049 | - new_password_label: i18n_service.translate("password.new_password", locale_str).await, | |
| 1050 | - current_password_label: i18n_service.translate("password.current_password", locale_str).await, | |
| 1051 | - current_password_placeholder: i18n_service.translate("password.current_password_placeholder", locale_str).await, | |
| 1052 | - new_password_placeholder: i18n_service.translate("password.new_password_placeholder", locale_str).await, | |
| 1053 | - required: i18n_service.translate("password.required", locale_str).await, | |
| 1054 | - requirements: i18n_service.translate("password.requirements", locale_str).await, | |
| 1055 | - strength_label: i18n_service.translate("password.strength", locale_str).await, | |
| 1056 | - strength_weak: i18n_service.translate("password.strength_weak", locale_str).await, | |
| 1057 | - strength_medium: i18n_service.translate("password.strength_medium", locale_str).await, | |
| 1058 | - strength_strong: i18n_service.translate("password.strength_strong", locale_str).await, | |
| 1059 | - strength_too_short: i18n_service.translate("password.strength_too_short", locale_str).await, | |
| 1060 | - strength_missing_lowercase: i18n_service.translate("password.strength_missing_lowercase", locale_str).await, | |
| 1061 | - strength_missing_uppercase: i18n_service.translate("password.strength_missing_uppercase", locale_str).await, | |
| 1062 | - strength_missing_digit: i18n_service.translate("password.strength_missing_digit", locale_str).await, | |
| 1063 | - strength_missing_special: i18n_service.translate("password.strength_missing_special", locale_str).await, | |
| 1064 | - confirm_password_label: i18n_service.translate("password.confirm_password", locale_str).await, | |
| 1065 | - confirm_password_placeholder: i18n_service.translate("password.confirm_password_placeholder", locale_str).await, | |
| 1066 | - passwords_match: i18n_service.translate("password.passwords_match", locale_str).await, | |
| 1067 | - passwords_not_match: i18n_service.translate("password.passwords_not_match", locale_str).await, | |
| 1068 | - change_button: i18n_service.translate("password.change_button", locale_str).await, | |
| 1069 | - changing: i18n_service.translate("password.changing", locale_str).await, | |
| 1070 | - logout_warning_title: i18n_service.translate("password.logout_warning_title", locale_str).await, | |
| 1071 | - logout_warning_desc: i18n_service.translate("password.logout_warning_desc", locale_str).await, | |
| 1072 | - saved: i18n_service.translate("password.saved", locale_str).await, | |
| 1073 | - error_all_fields_required: i18n_service.translate("password.error_all_fields_required", locale_str).await, | |
| 1074 | - error_empty_data: i18n_service.translate("password.error_empty_data", locale_str).await, | |
| 1075 | - error_invalid_data: i18n_service.translate("password.error_invalid_data", locale_str).await, | |
| 1076 | - error_incorrect_current: i18n_service.translate("password.error_incorrect_current", locale_str).await, | |
| 1077 | - error_reading_response: i18n_service.translate("password.error_reading_response", locale_str).await, | |
| 1078 | - error_415: i18n_service.translate("password.error_415", locale_str).await, | |
| 1079 | - network_error: i18n_service.translate("password.network_error", locale_str).await, | |
| 1080 | - security_tips_title: i18n_service.translate("password.security_tips_title", locale_str).await, | |
| 1081 | - security_tip_unique: i18n_service.translate("password.security_tip_unique", locale_str).await, | |
| 1082 | - security_tip_share: i18n_service.translate("password.security_tip_share", locale_str).await, | |
| 1083 | - security_tip_regular: i18n_service.translate("password.security_tip_regular", locale_str).await, | |
| 1084 | - security_tip_2fa: i18n_service.translate("password.security_tip_2fa", locale_str).await, | |
| 1085 | - security_tip_manager: i18n_service.translate("password.security_tip_manager", locale_str).await, | |
| 1086 | - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 1087 | - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await, | |
| 1088 | - breadcrumb_password: i18n_service.translate("breadcrumb.password", locale_str).await, | |
| 1089 | - cancel: i18n_service.translate("common.cancel", locale_str).await, | |
| 1090 | - logout_label: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 1091 | - has_pending_password_change, | |
| 1092 | - pw_min_length: pw_rules.min_length, | |
| 1093 | - pw_require_uppercase: pw_rules.require_uppercase, | |
| 1094 | - pw_require_lowercase: pw_rules.require_lowercase, | |
| 1095 | - pw_require_digits: pw_rules.require_digits, | |
| 1096 | - pw_require_special: pw_rules.require_special, | |
| 1097 | - password_change_require_email: AppSettingsService::get_bool( | |
| 1098 | - &db, | |
| 1099 | - "password_change_require_email", | |
| 1100 | - true, | |
| 1101 | - ) | |
| 1102 | - .await | |
| 1103 | - .unwrap_or(true), | |
| 1104 | - subtitle_no_email: i18n_service | |
| 1105 | - .translate("password.subtitle_no_email", locale_str) | |
| 1106 | - .await, | |
| 1107 | - saved_direct: i18n_service | |
| 1108 | - .translate("password.saved_direct", locale_str) | |
| 1109 | - .await, | |
| 1110 | - }; | |
| 1111 | - | |
| 1112 | - Ok(template) | |
| 1113 | -} | |
| 1114 | - | |
| 1115 | -/// Handler for GET /settings/sessions - Display sessions page (PROTECTED) | |
| 1116 | -pub async fn get_sessions_page( | |
| 1117 | - Extension(claims): Extension<Claims>, | |
| 1118 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1119 | - Extension(locale): Extension<Locale>, | |
| 1120 | - State(db): State<DatabaseConnection>, | |
| 1121 | - session: Session, | |
| 1122 | -) -> Result<impl IntoResponse, AppError> { | |
| 1123 | - let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 1124 | - .await? | |
| 1125 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 1126 | - | |
| 1127 | - let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 1128 | - let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 1129 | - | |
| 1130 | - let locale_str = locale.as_str(); | |
| 1131 | - | |
| 1132 | - let current_token_id: Option<uuid::Uuid> = session.get("refresh_token_id").await.ok().flatten(); | |
| 1133 | - | |
| 1134 | - let mut sessions_data = RefreshTokenService::list_user_sessions(&db, claims.sub).await?; | |
| 1135 | - | |
| 1136 | - if let Some(current_id) = current_token_id { | |
| 1137 | - for session_data in &mut sessions_data { | |
| 1138 | - if session_data.id == current_id { | |
| 1139 | - session_data.is_current = true; | |
| 1140 | - break; | |
| 1141 | - } | |
| 1142 | - } | |
| 1143 | - } | |
| 1144 | - | |
| 1145 | - let ip_fallback = i18n_service.translate("session.ip_unavailable", locale_str).await; | |
| 1146 | - let sessions: Vec<SessionItem> = sessions_data | |
| 1147 | - .iter() | |
| 1148 | - .map(|s| SessionItem { | |
| 1149 | - id: s.id.to_string(), | |
| 1150 | - device_info: s.device_info.clone().unwrap_or_else(|| "Unknown".to_string()), | |
| 1151 | - ip_address: s.ip_address.clone().unwrap_or_else(|| ip_fallback.clone()), | |
| 1152 | - last_activity: s.last_activity.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 1153 | - created_at: s.created_at.format("%Y-%m-%d %H:%M:%S").to_string(), | |
| 1154 | - is_current: s.is_current, | |
| 1155 | - }) | |
| 1156 | - .collect(); | |
| 1157 | - | |
| 1158 | - let layout = rustwarden_core::templates::LayoutContext::new("Sessions", ""); | |
| 1159 | - use crate::template_helpers::csrf_token_helper; | |
| 1160 | - let csrf_token = csrf_token_helper(&session).await; | |
| 1161 | - | |
| 1162 | - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 1163 | - let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 1164 | - | |
| 1165 | - let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 1166 | - .await? | |
| 1167 | - .map(|s| s.value) | |
| 1168 | - .unwrap_or_else(|| "localhost".to_string()); | |
| 1169 | - let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 1170 | - .await? | |
| 1171 | - .map(|s| s.value) | |
| 1172 | - .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 1173 | - | |
| 1174 | - let page_title = i18n_service.translate("sessions.page_title", locale_str).await; | |
| 1175 | - | |
| 1176 | - let template = SessionsTemplate { | |
| 1177 | - page_title, | |
| 1178 | - csrf_token, | |
| 1179 | - app_name: layout.app_name.clone(), | |
| 1180 | - app_version: layout.app_version.clone(), | |
| 1181 | - app_domain, | |
| 1182 | - app_description, | |
| 1183 | - username: user.username.clone(), | |
| 1184 | - email: user.email.clone(), | |
| 1185 | - roles_text: user.roles.join(", "), | |
| 1186 | - initials: user.initials.clone(), | |
| 1187 | - avatar_color: user.avatar_color.clone(), | |
| 1188 | - sessions, | |
| 1189 | - nav_items, | |
| 1190 | - has_pending_password_change: false, | |
| 1191 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 1192 | - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 1193 | - | |
| 1194 | - title: i18n_service.translate("sessions.title", locale_str).await, | |
| 1195 | - subtitle: i18n_service.translate("sessions.subtitle", locale_str).await, | |
| 1196 | - current_session: i18n_service.translate("sessions.current_session", locale_str).await, | |
| 1197 | - current_badge: i18n_service.translate("sessions.current_badge", locale_str).await, | |
| 1198 | - unknown_device: i18n_service.translate("sessions.unknown_device", locale_str).await, | |
| 1199 | - ip_address: i18n_service.translate("sessions.ip_address", locale_str).await, | |
| 1200 | - created_at: i18n_service.translate("sessions.created_at", locale_str).await, | |
| 1201 | - last_activity: i18n_service.translate("sessions.last_activity", locale_str).await, | |
| 1202 | - session_id: i18n_service.translate("sessions.session_id", locale_str).await, | |
| 1203 | - revoke: i18n_service.translate("sessions.revoke", locale_str).await, | |
| 1204 | - revoke_confirm_template: i18n_service.translate("sessions.revoke_confirm", locale_str).await, | |
| 1205 | - revoke_success: i18n_service.translate("sessions.revoke_success", locale_str).await, | |
| 1206 | - revoke_error: i18n_service.translate("sessions.revoke_error", locale_str).await, | |
| 1207 | - no_sessions: i18n_service.translate("sessions.no_sessions", locale_str).await, | |
| 1208 | - no_sessions_desc: i18n_service.translate("sessions.no_sessions_desc", locale_str).await, | |
| 1209 | - danger_zone: i18n_service.translate("sessions.danger_zone", locale_str).await, | |
| 1210 | - danger_zone_desc: i18n_service.translate("sessions.danger_zone_desc", locale_str).await, | |
| 1211 | - revoke_all: i18n_service.translate("sessions.revoke_all", locale_str).await, | |
| 1212 | - revoke_all_confirm: i18n_service.translate("sessions.revoke_all_confirm", locale_str).await, | |
| 1213 | - revoke_all_success: i18n_service.translate("sessions.revoke_all_success", locale_str).await, | |
| 1214 | - revoke_all_error: i18n_service.translate("sessions.revoke_all_error", locale_str).await, | |
| 1215 | - about_title: i18n_service.translate("sessions.about_title", locale_str).await, | |
| 1216 | - about_desc: i18n_service.translate("sessions.about_desc", locale_str).await, | |
| 1217 | - back_to_settings: i18n_service.translate("sessions.back_to_settings", locale_str).await, | |
| 1218 | - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 1219 | - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await, | |
| 1220 | - breadcrumb_sessions: i18n_service.translate("breadcrumb.sessions", locale_str).await, | |
| 1221 | - }; | |
| 1222 | - | |
| 1223 | - Ok(template) | |
| 1224 | -} | |
| 1225 | - | |
| 1226 | -/// Handler for GET /settings/preferences - Display preferences page (PROTECTED) | |
| 1227 | -pub async fn get_preferences_page( | |
| 1228 | - Extension(claims): Extension<Claims>, | |
| 1229 | - Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 1230 | - Extension(locale): Extension<Locale>, | |
| 1231 | - State(db): State<DatabaseConnection>, | |
| 1232 | - session: Session, | |
| 1233 | -) -> Result<impl IntoResponse, AppError> { | |
| 1234 | - let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 1235 | - .await? | |
| 1236 | - .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 1237 | - | |
| 1238 | - let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 1239 | - let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 1240 | - | |
| 1241 | - let locale_str = locale.as_str(); | |
| 1242 | - | |
| 1243 | - use rustwarden_core::services::user_preferences_service::UserPreferencesService; | |
| 1244 | - let preferences = match UserPreferencesService::get_preferences(&db, claims.sub).await? { | |
| 1245 | - Some(prefs) => prefs, | |
| 1246 | - None => UserPreferencesService::create_default_preferences(&db, claims.sub).await?, | |
| 1247 | - }; | |
| 1248 | - | |
| 1249 | - let current_locale = locale_str.to_string(); | |
| 1250 | - let supported_languages: Vec<(String, String, bool)> = vec![ | |
| 1251 | - ("fr".to_string(), "Français".to_string(), current_locale == "fr"), | |
| 1252 | - ("en".to_string(), "English".to_string(), current_locale == "en"), | |
| 1253 | - ("de".to_string(), "Deutsch".to_string(), current_locale == "de"), | |
| 1254 | - ("es".to_string(), "Español".to_string(), current_locale == "es"), | |
| 1255 | - ]; | |
| 1256 | - | |
| 1257 | - let layout = rustwarden_core::templates::LayoutContext::new("Préférences", ""); | |
| 1258 | - use crate::template_helpers::csrf_token_helper; | |
| 1259 | - let csrf_token = csrf_token_helper(&session).await; | |
| 1260 | - | |
| 1261 | - let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 1262 | - let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 1263 | - | |
| 1264 | - let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 1265 | - .await? | |
| 1266 | - .map(|s| s.value) | |
| 1267 | - .unwrap_or_else(|| "localhost".to_string()); | |
| 1268 | - let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 1269 | - .await? | |
| 1270 | - .map(|s| s.value) | |
| 1271 | - .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 1272 | - | |
| 1273 | - let save_button = i18n_service.translate("preferences.save", locale_str).await; | |
| 1274 | - let saved_success = i18n_service.translate("preferences.saved_success", locale_str).await; | |
| 1275 | - let saved_reload = i18n_service.translate("preferences.saved_reload", locale_str).await; | |
| 1276 | - let error_prefix = i18n_service.translate("preferences.error", locale_str).await; | |
| 1277 | - let network_error = i18n_service.translate("preferences.network_error", locale_str).await; | |
| 1278 | - | |
| 1279 | - let saved_reload_json = serde_json::to_string(&saved_reload).unwrap_or_else(|_| "\"\"".to_string()); | |
| 1280 | - let saved_success_json = serde_json::to_string(&saved_success).unwrap_or_else(|_| "\"\"".to_string()); | |
| 1281 | - let error_prefix_json = serde_json::to_string(&error_prefix).unwrap_or_else(|_| "\"\"".to_string()); | |
| 1282 | - let network_error_json = serde_json::to_string(&network_error).unwrap_or_else(|_| "\"\"".to_string()); | |
| 1283 | - let save_button_json = serde_json::to_string(&save_button).unwrap_or_else(|_| "\"\"".to_string()); | |
| 1284 | - | |
| 1285 | - let template = PreferencesTemplate { | |
| 1286 | - page_title: layout.page_title.clone(), | |
| 1287 | - csrf_token, | |
| 1288 | - app_name: layout.app_name.clone(), | |
| 1289 | - app_version: layout.app_version.clone(), | |
| 1290 | - app_domain, | |
| 1291 | - app_description, | |
| 1292 | - username: user.username.clone(), | |
| 1293 | - email: user.email.clone(), | |
| 1294 | - roles_text: user.roles.join(", "), | |
| 1295 | - initials: user.initials.clone(), | |
| 1296 | - avatar_color: user.avatar_color.clone(), | |
| 1297 | - theme: preferences.theme.clone(), | |
| 1298 | - is_theme_light: preferences.theme == "light", | |
| 1299 | - is_theme_dark: preferences.theme == "dark", | |
| 1300 | - is_theme_auto: preferences.theme == "auto", | |
| 1301 | - language: preferences.language.clone(), | |
| 1302 | - is_language_fr: preferences.language == "fr", | |
| 1303 | - is_language_en: preferences.language == "en", | |
| 1304 | - is_language_de: preferences.language == "de", | |
| 1305 | - is_language_es: preferences.language == "es", | |
| 1306 | - current_locale, | |
| 1307 | - supported_languages, | |
| 1308 | - nav_items, | |
| 1309 | - has_pending_password_change: false, | |
| 1310 | - user_menu_profile: i18n_service.translate("user_menu.profile", locale_str).await, | |
| 1311 | - user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 1312 | - | |
| 1313 | - title: i18n_service.translate("preferences.title", locale_str).await, | |
| 1314 | - subtitle: i18n_service.translate("preferences.subtitle", locale_str).await, | |
| 1315 | - theme_label: i18n_service.translate("preferences.theme", locale_str).await, | |
| 1316 | - theme_light: i18n_service.translate("preferences.theme_light", locale_str).await, | |
| 1317 | - theme_light_desc: i18n_service.translate("preferences.theme_light_desc", locale_str).await, | |
| 1318 | - theme_dark: i18n_service.translate("preferences.theme_dark", locale_str).await, | |
| 1319 | - theme_dark_desc: i18n_service.translate("preferences.theme_dark_desc", locale_str).await, | |
| 1320 | - theme_auto: i18n_service.translate("preferences.theme_auto", locale_str).await, | |
| 1321 | - theme_auto_desc: i18n_service.translate("preferences.theme_auto_desc", locale_str).await, | |
| 1322 | - theme_preview: i18n_service.translate("preferences.theme_preview", locale_str).await, | |
| 1323 | - language_label: i18n_service.translate("preferences.language", locale_str).await, | |
| 1324 | - language_fr: i18n_service.translate("preferences.language_fr", locale_str).await, | |
| 1325 | - language_fr_desc: i18n_service.translate("preferences.language_fr_desc", locale_str).await, | |
| 1326 | - language_en: i18n_service.translate("preferences.language_en", locale_str).await, | |
| 1327 | - language_en_desc: i18n_service.translate("preferences.language_en_desc", locale_str).await, | |
| 1328 | - language_de: i18n_service.translate("preferences.language_de", locale_str).await, | |
| 1329 | - language_de_desc: i18n_service.translate("preferences.language_de_desc", locale_str).await, | |
| 1330 | - language_es: i18n_service.translate("preferences.language_es", locale_str).await, | |
| 1331 | - language_es_desc: i18n_service.translate("preferences.language_es_desc", locale_str).await, | |
| 1332 | - translation_warning: i18n_service.translate("preferences.translation_warning", locale_str).await, | |
| 1333 | - modified: i18n_service.translate("preferences.modified", locale_str).await, | |
| 1334 | - modified_desc: i18n_service.translate("preferences.modified_desc", locale_str).await, | |
| 1335 | - save_button, | |
| 1336 | - saving: i18n_service.translate("preferences.saving", locale_str).await, | |
| 1337 | - cancel: i18n_service.translate("preferences.cancel", locale_str).await, | |
| 1338 | - saved_success, | |
| 1339 | - saved_reload, | |
| 1340 | - saved_reload_json, | |
| 1341 | - saved_success_json, | |
| 1342 | - error_prefix, | |
| 1343 | - error_prefix_json, | |
| 1344 | - network_error, | |
| 1345 | - network_error_json, | |
| 1346 | - save_button_json, | |
| 1347 | - sync_title: i18n_service.translate("preferences.sync_title", locale_str).await, | |
| 1348 | - sync_desc: i18n_service.translate("preferences.sync_desc", locale_str).await, | |
| 1349 | - preview_dark: i18n_service.translate("preferences.preview_dark", locale_str).await, | |
| 1350 | - preview_light: i18n_service.translate("preferences.preview_light", locale_str).await, | |
| 1351 | - preview_auto: i18n_service.translate("preferences.preview_auto", locale_str).await, | |
| 1352 | - breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 1353 | - breadcrumb_settings: i18n_service.translate("breadcrumb.settings", locale_str).await, | |
| 1354 | - breadcrumb_preferences: i18n_service.translate("breadcrumb.preferences", locale_str).await, | |
| 1355 | - }; | |
| 1356 | - | |
| 1357 | - Ok(template) | |
| 1358 | -} |
A
crates/rustwarden-ui/src/handlers/totp_handler.rs
+359
-0
@@ -0,0 +1,359 @@
| 1 | +//! TOTP 2FA settings handlers | |
| 2 | +//! | |
| 3 | +//! Handlers for the /settings/security page where users can | |
| 4 | +//! enable/disable TOTP two-factor authentication. | |
| 5 | + | |
| 6 | +use askama::Template; | |
| 7 | +use axum::{ | |
| 8 | + extract::{Extension, State}, | |
| 9 | + response::IntoResponse, | |
| 10 | + Json, | |
| 11 | +}; | |
| 12 | +use sea_orm::DatabaseConnection; | |
| 13 | +use serde::Deserialize; | |
| 14 | +use std::sync::Arc; | |
| 15 | +use tower_sessions::Session; | |
| 16 | + | |
| 17 | +use rustwarden_core::auth::jwt::Claims; | |
| 18 | +use rustwarden_core::error::AppError; | |
| 19 | +use rustwarden_core::i18n::extractor::Locale; | |
| 20 | +use rustwarden_core::i18n::service::I18nService; | |
| 21 | +use rustwarden_core::services::app_settings_service::AppSettingsService; | |
| 22 | +use rustwarden_core::services::totp_service::TotpService; | |
| 23 | +use rustwarden_core::services::user_service::UserService; | |
| 24 | + | |
| 25 | +use crate::dto::navigation_dto::NavigationDTO; | |
| 26 | +use crate::dto::user_profile_dto::UserProfileDTO; | |
| 27 | +use crate::template_helpers::csrf_token_helper; | |
| 28 | + | |
| 29 | +/// Simplified navigation item for templates | |
| 30 | +#[derive(Clone)] | |
| 31 | +#[allow(dead_code)] | |
| 32 | +pub(crate) struct NavItem { | |
| 33 | + id: String, | |
| 34 | + label: String, | |
| 35 | + icon: String, | |
| 36 | + url: String, | |
| 37 | + active: bool, | |
| 38 | + enabled: bool, | |
| 39 | + badge: String, | |
| 40 | + has_badge: bool, | |
| 41 | + disabled_message: String, | |
| 42 | + has_disabled_message: bool, | |
| 43 | +} | |
| 44 | + | |
| 45 | +/// Helper to build NavItems from NavigationDTO | |
| 46 | +fn build_nav_items_from_dto(navigation: &NavigationDTO, active_id: &str) -> Vec<NavItem> { | |
| 47 | + navigation | |
| 48 | + .items | |
| 49 | + .iter() | |
| 50 | + .map(|item| NavItem { | |
| 51 | + id: item.id.clone(), | |
| 52 | + label: item.label.clone(), | |
| 53 | + icon: item.icon.clone(), | |
| 54 | + url: item.url.clone(), | |
| 55 | + active: item.id == active_id, | |
| 56 | + enabled: item.enabled, | |
| 57 | + badge: item.badge.clone(), | |
| 58 | + has_badge: item.has_badge, | |
| 59 | + disabled_message: item.disabled_message.clone(), | |
| 60 | + has_disabled_message: item.has_disabled_message, | |
| 61 | + }) | |
| 62 | + .collect() | |
| 63 | +} | |
| 64 | + | |
| 65 | +/// Security settings page template | |
| 66 | +#[derive(Template)] | |
| 67 | +#[template(path = "settings/security.html")] | |
| 68 | +#[allow(dead_code)] | |
| 69 | +struct SecurityTemplate { | |
| 70 | + page_title: String, | |
| 71 | + app_name: String, | |
| 72 | + app_version: String, | |
| 73 | + app_domain: String, | |
| 74 | + app_description: String, | |
| 75 | + csrf_token: String, | |
| 76 | + username: String, | |
| 77 | + email: String, | |
| 78 | + roles_text: String, | |
| 79 | + initials: String, | |
| 80 | + avatar_color: String, | |
| 81 | + current_locale: String, | |
| 82 | + nav_items: Vec<NavItem>, | |
| 83 | + user_menu_profile: String, | |
| 84 | + user_menu_logout: String, | |
| 85 | + // 2FA state | |
| 86 | + totp_enabled: bool, | |
| 87 | + backup_codes_count: i32, | |
| 88 | + // Translated strings | |
| 89 | + title: String, | |
| 90 | + subtitle: String, | |
| 91 | + totp_title: String, | |
| 92 | + status_enabled: String, | |
| 93 | + status_disabled: String, | |
| 94 | + enabled_message: String, | |
| 95 | + disabled_message: String, | |
| 96 | + enable_button: String, | |
| 97 | + disable_button: String, | |
| 98 | + scan_qr: String, | |
| 99 | + manual_entry: String, | |
| 100 | + enter_code: String, | |
| 101 | + code_placeholder: String, | |
| 102 | + verify_button: String, | |
| 103 | + backup_codes_title: String, | |
| 104 | + backup_codes_warning: String, | |
| 105 | + backup_codes_remaining: String, | |
| 106 | + regenerate_button: String, | |
| 107 | + regenerate_confirm: String, | |
| 108 | + confirm_disable: String, | |
| 109 | + password_label: String, | |
| 110 | + cancel_button: String, | |
| 111 | + copy_button: String, | |
| 112 | + saved_codes_button: String, | |
| 113 | + breadcrumb_home: String, | |
| 114 | + breadcrumb_settings: String, | |
| 115 | + breadcrumb_security: String, | |
| 116 | + /// True when admin has forced 2FA and the user hasn't set it up yet | |
| 117 | + force_2fa: bool, | |
| 118 | + force_2fa_message: String, | |
| 119 | + force_2fa_logout: String, | |
| 120 | +} | |
| 121 | + | |
| 122 | +rustwarden_core::impl_template_response!(SecurityTemplate); | |
| 123 | + | |
| 124 | +/// GET /settings/security — Security settings page | |
| 125 | +pub async fn security_page( | |
| 126 | + Extension(claims): Extension<Claims>, | |
| 127 | + State(db): State<DatabaseConnection>, | |
| 128 | + Extension(i18n_service): Extension<Arc<I18nService>>, | |
| 129 | + locale: Locale, | |
| 130 | + session: Session, | |
| 131 | +) -> Result<impl IntoResponse, AppError> { | |
| 132 | + let user_model = UserService::get_user_by_username(&db, &claims.username) | |
| 133 | + .await? | |
| 134 | + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; | |
| 135 | + let roles = UserService::get_user_roles(&db, user_model.id).await?; | |
| 136 | + let user = UserProfileDTO::new(user_model, roles.clone()); | |
| 137 | + | |
| 138 | + let locale_str = locale.as_str(); | |
| 139 | + let csrf_token = csrf_token_helper(&session).await; | |
| 140 | + | |
| 141 | + let navigation = NavigationDTO::for_user(&roles, &i18n_service, &locale, Some(&db)).await; | |
| 142 | + let nav_items = build_nav_items_from_dto(&navigation, "settings"); | |
| 143 | + | |
| 144 | + let app_domain = AppSettingsService::get_setting(&db, "app_domain") | |
| 145 | + .await? | |
| 146 | + .map(|s| s.value) | |
| 147 | + .unwrap_or_else(|| "localhost".to_string()); | |
| 148 | + let app_description = AppSettingsService::get_setting(&db, "app_description") | |
| 149 | + .await? | |
| 150 | + .map(|s| s.value) | |
| 151 | + .unwrap_or_else(|| "Secure password manager".to_string()); | |
| 152 | + | |
| 153 | + // Check TOTP status | |
| 154 | + let totp_info = TotpService::get_totp_info(&db, claims.sub).await?; | |
| 155 | + let totp_enabled = totp_info.as_ref().is_some_and(|t| t.enabled); | |
| 156 | + | |
| 157 | + // Check if 2FA is forced globally | |
| 158 | + let totp_force_enabled = | |
| 159 | + AppSettingsService::get_bool_with_fallback(&db, &["totp_force_enabled"], false) | |
| 160 | + .await | |
| 161 | + .unwrap_or(false); | |
| 162 | + let force_2fa = totp_force_enabled && !totp_enabled; | |
| 163 | + let backup_codes_count = totp_info | |
| 164 | + .as_ref() | |
| 165 | + .map(|t| t.backup_codes_remaining) | |
| 166 | + .unwrap_or(0); | |
| 167 | + | |
| 168 | + let layout = rustwarden_core::templates::LayoutContext::new("", ""); | |
| 169 | + | |
| 170 | + let template = SecurityTemplate { | |
| 171 | + page_title: i18n_service | |
| 172 | + .translate("settings_security.page_title", locale_str) | |
| 173 | + .await, | |
| 174 | + app_name: layout.app_name.clone(), | |
| 175 | + app_version: layout.app_version.clone(), | |
| 176 | + app_domain, | |
| 177 | + app_description, | |
| 178 | + csrf_token, | |
| 179 | + username: user.username.clone(), | |
| 180 | + email: user.email.clone(), | |
| 181 | + roles_text: user.roles.join(", "), | |
| 182 | + initials: user.initials.clone(), | |
| 183 | + avatar_color: user.avatar_color.clone(), | |
| 184 | + current_locale: locale_str.to_string(), | |
| 185 | + nav_items, | |
| 186 | + user_menu_profile: i18n_service | |
| 187 | + .translate("user_menu.profile", locale_str) | |
| 188 | + .await, | |
| 189 | + user_menu_logout: i18n_service.translate("user_menu.logout", locale_str).await, | |
| 190 | + totp_enabled, | |
| 191 | + backup_codes_count, | |
| 192 | + title: i18n_service | |
| 193 | + .translate("settings_security.title", locale_str) | |
| 194 | + .await, | |
| 195 | + subtitle: i18n_service | |
| 196 | + .translate("settings_security.subtitle", locale_str) | |
| 197 | + .await, | |
| 198 | + totp_title: i18n_service | |
| 199 | + .translate("settings_security.totp_title", locale_str) | |
| 200 | + .await, | |
| 201 | + status_enabled: i18n_service | |
| 202 | + .translate("settings_security.status_enabled", locale_str) | |
| 203 | + .await, | |
| 204 | + status_disabled: i18n_service | |
| 205 | + .translate("settings_security.status_disabled", locale_str) | |
| 206 | + .await, | |
| 207 | + enabled_message: i18n_service | |
| 208 | + .translate("settings_security.enabled_message", locale_str) | |
| 209 | + .await, | |
| 210 | + disabled_message: i18n_service | |
| 211 | + .translate("settings_security.disabled_message", locale_str) | |
| 212 | + .await, | |
| 213 | + enable_button: i18n_service | |
| 214 | + .translate("settings_security.enable_button", locale_str) | |
| 215 | + .await, | |
| 216 | + disable_button: i18n_service | |
| 217 | + .translate("settings_security.disable_button", locale_str) | |
| 218 | + .await, | |
| 219 | + scan_qr: i18n_service | |
| 220 | + .translate("settings_security.scan_qr", locale_str) | |
| 221 | + .await, | |
| 222 | + manual_entry: i18n_service | |
| 223 | + .translate("settings_security.manual_entry", locale_str) | |
| 224 | + .await, | |
| 225 | + enter_code: i18n_service | |
| 226 | + .translate("settings_security.enter_code", locale_str) | |
| 227 | + .await, | |
| 228 | + code_placeholder: "000000".to_string(), | |
| 229 | + verify_button: i18n_service | |
| 230 | + .translate("settings_security.verify_button", locale_str) | |
| 231 | + .await, | |
| 232 | + backup_codes_title: i18n_service | |
| 233 | + .translate("settings_security.backup_codes_title", locale_str) | |
| 234 | + .await, | |
| 235 | + backup_codes_warning: i18n_service | |
| 236 | + .translate("settings_security.backup_codes_warning", locale_str) | |
| 237 | + .await, | |
| 238 | + backup_codes_remaining: i18n_service | |
| 239 | + .translate("settings_security.backup_codes_remaining", locale_str) | |
| 240 | + .await, | |
| 241 | + regenerate_button: i18n_service | |
| 242 | + .translate("settings_security.regenerate_button", locale_str) | |
| 243 | + .await, | |
| 244 | + regenerate_confirm: i18n_service | |
| 245 | + .translate("settings_security.regenerate_confirm", locale_str) | |
| 246 | + .await, | |
| 247 | + confirm_disable: i18n_service | |
| 248 | + .translate("settings_security.confirm_disable", locale_str) | |
| 249 | + .await, | |
| 250 | + password_label: i18n_service | |
| 251 | + .translate("settings_security.password_label", locale_str) | |
| 252 | + .await, | |
| 253 | + cancel_button: i18n_service.translate("common.cancel", locale_str).await, | |
| 254 | + copy_button: i18n_service | |
| 255 | + .translate("settings_security.copy_button", locale_str) | |
| 256 | + .await, | |
| 257 | + saved_codes_button: i18n_service | |
| 258 | + .translate("settings_security.saved_codes_button", locale_str) | |
| 259 | + .await, | |
| 260 | + breadcrumb_home: i18n_service.translate("breadcrumb.home", locale_str).await, | |
| 261 | + breadcrumb_settings: i18n_service | |
| 262 | + .translate("breadcrumb.settings", locale_str) | |
| 263 | + .await, | |
| 264 | + breadcrumb_security: i18n_service | |
| 265 | + .translate("settings_security.breadcrumb", locale_str) | |
| 266 | + .await, | |
| 267 | + force_2fa, | |
| 268 | + force_2fa_message: i18n_service | |
| 269 | + .translate("settings_security.force_2fa_message", locale_str) | |
| 270 | + .await, | |
| 271 | + force_2fa_logout: i18n_service | |
| 272 | + .translate("settings_security.force_2fa_logout", locale_str) | |
| 273 | + .await, | |
| 274 | + }; | |
| 275 | + | |
| 276 | + Ok(template) | |
| 277 | +} | |
| 278 | + | |
| 279 | +/// POST /settings/security/totp/enable — Start TOTP setup | |
| 280 | +pub async fn enable_totp( | |
| 281 | + Extension(claims): Extension<Claims>, | |
| 282 | + State(db): State<DatabaseConnection>, | |
| 283 | +) -> Result<impl IntoResponse, AppError> { | |
| 284 | + let app_name = AppSettingsService::get_setting(&db, "app_name") | |
| 285 | + .await? | |
| 286 | + .map(|s| s.value) | |
| 287 | + .unwrap_or_else(|| "RustWarden".to_string()); | |
| 288 | + | |
| 289 | + let setup = TotpService::setup_totp(&db, claims.sub, &claims.username, &app_name).await?; | |
| 290 | + | |
| 291 | + Ok(Json(serde_json::json!({ | |
| 292 | + "secret_base32": setup.secret_base32, | |
| 293 | + "otpauth_uri": setup.otpauth_uri, | |
| 294 | + "qr_code_data_uri": setup.qr_code_data_uri, | |
| 295 | + }))) | |
| 296 | +} | |
| 297 | + | |
| 298 | +/// Request body for verifying a TOTP code. | |
| 299 | +#[derive(Deserialize)] | |
| 300 | +pub struct VerifyTotpRequest { | |
| 301 | + /// Six-digit TOTP code from the authenticator app. | |
| 302 | + pub code: String, | |
| 303 | +} | |
| 304 | + | |
| 305 | +/// POST /settings/security/totp/verify — Verify code and enable 2FA | |
| 306 | +pub async fn verify_totp( | |
| 307 | + Extension(claims): Extension<Claims>, | |
| 308 | + State(db): State<DatabaseConnection>, | |
| 309 | + Json(payload): Json<VerifyTotpRequest>, | |
| 310 | +) -> Result<impl IntoResponse, AppError> { | |
| 311 | + let backup_codes = TotpService::verify_and_enable(&db, claims.sub, &payload.code).await?; | |
| 312 | + | |
| 313 | + // Audit log | |
| 314 | + tracing::info!(user_id = %claims.sub, "TOTP_ENABLED"); | |
| 315 | + | |
| 316 | + Ok(Json(serde_json::json!({ | |
| 317 | + "backup_codes": backup_codes, | |
| 318 | + }))) | |
| 319 | +} | |
| 320 | + | |
| 321 | +/// Request body for disabling TOTP two-factor authentication. | |
| 322 | +#[derive(Deserialize)] | |
| 323 | +pub struct DisableTotpRequest { | |
| 324 | + /// Current account password, required to confirm the disable action. | |
| 325 | + pub password: String, | |
| 326 | +} | |
| 327 | + | |
| 328 | +/// POST /settings/security/totp/disable — Disable 2FA (requires password) | |
| 329 | +pub async fn disable_totp( | |
| 330 | + Extension(claims): Extension<Claims>, | |
| 331 | + State(db): State<DatabaseConnection>, | |
| 332 | + Json(payload): Json<DisableTotpRequest>, | |
| 333 | +) -> Result<impl IntoResponse, AppError> { | |
| 334 | + // Verify password first | |
| 335 | + use rustwarden_core::services::auth_service::AuthService; | |
| 336 | + AuthService::verify_credentials(&db, &claims.username, &payload.password).await?; | |
| 337 | + | |
| 338 | + TotpService::disable_totp(&db, claims.sub).await?; | |
| 339 | + | |
| 340 | + // Audit log | |
| 341 | + tracing::info!(user_id = %claims.sub, "TOTP_DISABLED"); | |
| 342 | + | |
| 343 | + Ok(Json(serde_json::json!({ "success": true }))) | |
| 344 | +} | |
| 345 | + | |
| 346 | +/// POST /settings/security/backup-codes — Regenerate backup codes | |
| 347 | +pub async fn regenerate_backup_codes( | |
| 348 | + Extension(claims): Extension<Claims>, | |
| 349 | + State(db): State<DatabaseConnection>, | |
| 350 | +) -> Result<impl IntoResponse, AppError> { | |
| 351 | + let codes = TotpService::regenerate_backup_codes(&db, claims.sub).await?; | |
| 352 | + | |
| 353 | + // Audit log | |
| 354 | + tracing::info!(user_id = %claims.sub, "BACKUP_CODES_REGENERATED"); | |
| 355 | + | |
| 356 | + Ok(Json(serde_json::json!({ | |
| 357 | + "backup_codes": codes, | |
| 358 | + }))) | |
| 359 | +} |
M
crates/rustwarden-ui/src/handlers/user_handler.rs
+24
-11
@@ -18,37 +18,52 @@ 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). | |
| 21 | 22 | #[validate(length(min = 3, max = 50))] |
| 22 | 23 | pub username: String, |
| 24 | + /// Valid email address for the new account. | |
| 23 | 25 | #[validate(email)] |
| 24 | 26 | pub email: String, |
| 27 | + /// Initial password, or `None` to generate one automatically. | |
| 25 | 28 | pub password: Option<String>, |
| 29 | + /// Roles to assign to the new user. | |
| 26 | 30 | pub roles: Vec<String>, |
| 27 | 31 | } |
| 28 | 32 | |
| 29 | 33 | /// Inline DTO: User response |
| 30 | 34 | #[derive(Debug, serde::Serialize)] |
| 31 | 35 | pub struct UserResponse { |
| 36 | + /// User unique identifier. | |
| 32 | 37 | pub id: uuid::Uuid, |
| 38 | + /// User login name. | |
| 33 | 39 | pub username: String, |
| 40 | + /// User email address. | |
| 34 | 41 | pub email: String, |
| 42 | + /// Whether the user must change their password on next login. | |
| 35 | 43 | pub must_change_password: bool, |
| 44 | + /// Roles assigned to the user. | |
| 36 | 45 | pub roles: Vec<String>, |
| 46 | + /// Timestamp when the account was created. | |
| 37 | 47 | pub created_at: chrono::DateTime<chrono::Utc>, |
| 48 | + /// Timestamp of the last account update. | |
| 38 | 49 | pub updated_at: chrono::DateTime<chrono::Utc>, |
| 39 | 50 | } |
| 40 | 51 | |
| 41 | 52 | /// Inline DTO: User list response |
| 42 | 53 | #[derive(Debug, serde::Serialize)] |
| 43 | 54 | pub struct UserListResponse { |
| 55 | + /// Total number of users matching the query. | |
| 44 | 56 | pub total: usize, |
| 57 | + /// Page of user records. | |
| 45 | 58 | pub users: Vec<UserResponse>, |
| 46 | 59 | } |
| 47 | 60 | |
| 48 | 61 | /// Query parameters for user list/search |
| 49 | 62 | #[derive(Debug, Deserialize)] |
| 50 | 63 | pub struct UserListQuery { |
| 64 | + /// Optional full-text search filter. | |
| 51 | 65 | pub search: Option<String>, |
| 66 | + /// Maximum number of results to return. | |
| 52 | 67 | pub per_page: Option<u64>, |
| 53 | 68 | } |
| 54 | 69 |
@@ -174,13 +189,14 @@ pub async fn create_user(
| 174 | 189 | .clone() |
| 175 | 190 | .unwrap_or_else(rustwarden_core::config::app::default_app_name); |
| 176 | 191 | |
| 177 | - let template = rustwarden_core::templates::email_templates::AdminNewUserTemplate::new( | |
| 178 | - Some("fr".to_string()), | |
| 179 | - user.username.clone(), | |
| 180 | - user.email.clone(), | |
| 181 | - registered_at, | |
| 182 | - Some(app_name), | |
| 183 | - ); | |
| 192 | + let template = | |
| 193 | + rustwarden_core::templates::email_templates::AdminNewUserTemplate::new( | |
| 194 | + Some("fr".to_string()), | |
| 195 | + user.username.clone(), | |
| 196 | + user.email.clone(), | |
| 197 | + registered_at, | |
| 198 | + Some(app_name), | |
| 199 | + ); | |
| 184 | 200 | |
| 185 | 201 | if let (Ok(body_html), body_text) = |
| 186 | 202 | (template.render_html(), template.render_text()) |
@@ -254,10 +270,7 @@ pub async fn list_users(
| 254 | 270 | ); |
| 255 | 271 | |
| 256 | 272 | let limit = query.per_page.unwrap_or(10); |
| 257 | - let filtered_users = query_builder | |
| 258 | - .all(&db) | |
| 259 | - .await | |
| 260 | - .map_err(AppError::Database)?; | |
| 273 | + let filtered_users = query_builder.all(&db).await.map_err(AppError::Database)?; | |
| 261 | 274 | |
| 262 | 275 | filtered_users.into_iter().take(limit as usize).collect() |
| 263 | 276 | } else { |
M
crates/rustwarden-ui/src/lib.rs
+7
-1
@@ -3,10 +3,15 @@
| 3 | 3 | //! Default HTML user interface for the Rustwarden framework. |
| 4 | 4 | //! Provides login pages, profile management, admin panel, and all associated routes. |
| 5 | 5 | |
| 6 | +/// Data Transfer Objects for request/response handling. | |
| 6 | 7 | pub mod dto; |
| 8 | +/// Custom Axum extractors (e.g. form with duplicate fields). | |
| 7 | 9 | pub mod extractors; |
| 10 | +/// HTTP request handlers (login, admin, settings, etc.). | |
| 8 | 11 | pub mod handlers; |
| 12 | +/// Route definitions and router construction. | |
| 9 | 13 | pub mod routes; |
| 14 | +/// Shared Askama template helpers and base context. | |
| 10 | 15 | pub mod template_helpers; |
| 11 | 16 | |
| 12 | 17 | use axum::Router; |
@@ -20,6 +25,7 @@ pub fn default_routes() -> Router<DatabaseConnection> {
| 20 | 25 | |
| 21 | 26 | /// Returns a static file service for serving CSS, JS, and images. |
| 22 | 27 | pub fn default_static_service() -> ServeDir { |
| 23 | - let static_dir = std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string()); | |
| 28 | + let static_dir = | |
| 29 | + std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string()); | |
| 24 | 30 | ServeDir::new(static_dir) |
| 25 | 31 | } |
M
crates/rustwarden-ui/src/routes.rs
+44
-32
@@ -5,8 +5,9 @@ use axum::{
| 5 | 5 | use sea_orm::DatabaseConnection; |
| 6 | 6 | use tower_http::services::ServeDir; |
| 7 | 7 | |
| 8 | +use crate::handlers::settings::{preferences, profile, security}; | |
| 8 | 9 | use crate::handlers::{ |
| 9 | - admin_handler, health, home_handler, login_handler, root_handler, settings_handler, user_handler, | |
| 10 | + admin_handler, health, home_handler, login_handler, root_handler, totp_handler, user_handler, | |
| 10 | 11 | }; |
| 11 | 12 | use rustwarden_core::auth::handlers as auth_handlers; |
| 12 | 13 |
@@ -37,7 +38,8 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 37 | 38 | .route( |
| 38 | 39 | "/api/v1/auth/resend-verification/by-username", |
| 39 | 40 | post(auth_handlers::resend_verification_by_username), |
| 40 | - ); | |
| 41 | + ) | |
| 42 | + .route("/api/v1/auth/2fa/verify", post(auth_handlers::verify_2fa)); | |
| 41 | 43 | |
| 42 | 44 | let user_routes = Router::new() |
| 43 | 45 | .route("/api/v1/users", post(user_handler::create_user)) |
@@ -49,56 +51,65 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 49 | 51 | "/resend-verification", |
| 50 | 52 | get(login_handler::resend_verification_page), |
| 51 | 53 | ) |
| 52 | - .route( | |
| 53 | - "/reset-password", | |
| 54 | - get(login_handler::reset_password_page), | |
| 55 | - ) | |
| 54 | + .route("/reset-password", get(login_handler::reset_password_page)) | |
| 56 | 55 | .route( |
| 57 | 56 | "/confirm-password-change", |
| 58 | - get(settings_handler::confirm_password_change), | |
| 57 | + get(security::confirm_password_change), | |
| 59 | 58 | ) |
| 60 | - .route("/health", get(health::health_check)); | |
| 59 | + .route("/health", get(health::health_check)) | |
| 60 | + .route("/login/2fa", get(login_handler::login_2fa_page)); | |
| 61 | 61 | |
| 62 | 62 | let ui_routes = Router::new() |
| 63 | 63 | .route("/home", get(home_handler::home)) |
| 64 | - .route("/profile", get(settings_handler::get_profile_page)) | |
| 65 | - .route("/profile", put(settings_handler::update_profile)) | |
| 64 | + .route("/profile", get(profile::get_profile_page)) | |
| 65 | + .route("/profile", put(profile::update_profile)) | |
| 66 | 66 | .route( |
| 67 | 67 | "/settings/password", |
| 68 | - get(settings_handler::get_password_page), | |
| 69 | - ) | |
| 70 | - .route( | |
| 71 | - "/settings/password", | |
| 72 | - put(settings_handler::change_password), | |
| 68 | + get(security::get_password_page), | |
| 73 | 69 | ) |
| 70 | + .route("/settings/password", put(security::change_password)) | |
| 74 | 71 | .route( |
| 75 | 72 | "/settings/password/pending", |
| 76 | - delete(settings_handler::cancel_pending_password_change), | |
| 73 | + delete(security::cancel_pending_password_change), | |
| 77 | 74 | ) |
| 78 | 75 | .route( |
| 79 | 76 | "/settings/sessions", |
| 80 | - get(settings_handler::get_sessions_page), | |
| 77 | + get(security::get_sessions_page), | |
| 81 | 78 | ) |
| 82 | 79 | .route( |
| 83 | 80 | "/settings/sessions/{id}", |
| 84 | - delete(settings_handler::revoke_session), | |
| 81 | + delete(security::revoke_session), | |
| 85 | 82 | ) |
| 86 | 83 | .route( |
| 87 | 84 | "/settings/preferences", |
| 88 | - get(settings_handler::get_preferences_page), | |
| 85 | + get(preferences::get_preferences_page), | |
| 89 | 86 | ) |
| 90 | 87 | .route( |
| 91 | 88 | "/settings/preferences", |
| 92 | - put(settings_handler::update_preferences), | |
| 89 | + put(preferences::update_preferences), | |
| 90 | + ) | |
| 91 | + .route("/settings/security", get(totp_handler::security_page)) | |
| 92 | + .route( | |
| 93 | + "/settings/security/totp/enable", | |
| 94 | + post(totp_handler::enable_totp), | |
| 95 | + ) | |
| 96 | + .route( | |
| 97 | + "/settings/security/totp/verify", | |
| 98 | + post(totp_handler::verify_totp), | |
| 99 | + ) | |
| 100 | + .route( | |
| 101 | + "/settings/security/totp/disable", | |
| 102 | + post(totp_handler::disable_totp), | |
| 103 | + ) | |
| 104 | + .route( | |
| 105 | + "/settings/security/backup-codes", | |
| 106 | + post(totp_handler::regenerate_backup_codes), | |
| 93 | 107 | ); |
| 94 | 108 | |
| 95 | 109 | let admin_routes = Router::new() |
| 96 | 110 | .route("/admin", get(admin_handler::admin_dashboard)) |
| 97 | 111 | .route("/admin/users", get(admin_handler::list_users)) |
| 98 | - .route( | |
| 99 | - "/admin/users/create", | |
| 100 | - get(admin_handler::create_user_form), | |
| 101 | - ) | |
| 112 | + .route("/admin/users/create", get(admin_handler::create_user_form)) | |
| 102 | 113 | .route("/admin/users", post(admin_handler::create_user)) |
| 103 | 114 | .route("/admin/users/{id}", get(admin_handler::edit_user_form)) |
| 104 | 115 | .route("/admin/users/{id}", put(admin_handler::update_user)) |
@@ -112,21 +123,21 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 112 | 123 | post(admin_handler::admin_reset_password_by_email), |
| 113 | 124 | ) |
| 114 | 125 | .route("/admin/roles", get(admin_handler::list_roles)) |
| 115 | - .route( | |
| 116 | - "/admin/roles/create", | |
| 117 | - get(admin_handler::create_role_form), | |
| 118 | - ) | |
| 126 | + .route("/admin/roles/create", get(admin_handler::create_role_form)) | |
| 119 | 127 | .route("/admin/roles", post(admin_handler::create_role)) |
| 120 | 128 | .route("/admin/roles/{id}", get(admin_handler::edit_role_form)) |
| 121 | 129 | .route("/admin/roles/{id}", put(admin_handler::update_role)) |
| 122 | 130 | .route("/admin/roles/{id}", delete(admin_handler::delete_role)) |
| 123 | 131 | .route("/admin/settings", get(admin_handler::get_app_settings)) |
| 124 | 132 | .route("/admin/settings", put(admin_handler::update_app_settings)) |
| 125 | - .route("/admin/errors", get(admin_handler::admin_errors)); | |
| 133 | + .route("/admin/errors", get(admin_handler::admin_errors)) | |
| 134 | + .route( | |
| 135 | + "/admin/users/{id}/reset-totp", | |
| 136 | + post(admin_handler::reset_user_totp), | |
| 137 | + ); | |
| 126 | 138 | |
| 127 | - let static_dir = std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| { | |
| 128 | - "static".to_string() | |
| 129 | - }); | |
| 139 | + let static_dir = | |
| 140 | + std::env::var("RUSTWARDEN_STATIC_DIR").unwrap_or_else(|_| "static".to_string()); | |
| 130 | 141 | |
| 131 | 142 | tracing::info!("Serving static files from: {}", static_dir); |
| 132 | 143 | let static_service = ServeDir::new(&static_dir); |
@@ -141,6 +152,7 @@ pub fn create_router() -> Router<DatabaseConnection> {
| 141 | 152 | .nest_service("/static", static_service) |
| 142 | 153 | } |
| 143 | 154 | |
| 155 | +/// Returns a minimal router with only the health-check route, used in tests. | |
| 144 | 156 | #[allow(dead_code)] |
| 145 | 157 | pub fn create_test_router() -> Router { |
| 146 | 158 | Router::new().route("/health", get(health::health_check)) |
M
crates/rustwarden-ui/src/template_helpers.rs
+13
-4
@@ -2,7 +2,6 @@
| 2 | 2 | //! |
| 3 | 3 | //! Provides helper functions for generating template contexts and data structures. |
| 4 | 4 | |
| 5 | - | |
| 6 | 5 | use sea_orm::DatabaseConnection; |
| 7 | 6 | use std::sync::Arc; |
| 8 | 7 | use tower_sessions::Session; |
@@ -11,24 +10,34 @@ use crate::dto::{
| 11 | 10 | navigation_dto::{NavigationDTO, NavigationItemDTO}, |
| 12 | 11 | BreadcrumbItem, SidebarItem, SidebarSection, |
| 13 | 12 | }; |
| 14 | -use rustwarden_core::middleware::csrf; | |
| 15 | 13 | use rustwarden_core::auth::jwt::Claims; |
| 16 | 14 | use rustwarden_core::error::AppError; |
| 17 | 15 | use rustwarden_core::i18n::extractor::Locale; |
| 18 | 16 | use rustwarden_core::i18n::service::I18nService; |
| 17 | +use rustwarden_core::middleware::csrf; | |
| 19 | 18 | |
| 20 | 19 | /// Base template context containing common data for all pages |
| 21 | 20 | #[derive(Debug, Clone)] |
| 22 | 21 | pub struct BaseTemplateContext { |
| 22 | + /// Title shown in the browser tab and page header. | |
| 23 | 23 | pub page_title: String, |
| 24 | + /// Application display name from settings. | |
| 24 | 25 | pub app_name: String, |
| 26 | + /// Application version string. | |
| 25 | 27 | pub app_version: String, |
| 28 | + /// Public domain of the application. | |
| 26 | 29 | pub app_domain: String, |
| 30 | + /// Short description of the application. | |
| 27 | 31 | pub app_description: String, |
| 32 | + /// Authenticated user's login name. | |
| 28 | 33 | pub username: String, |
| 34 | + /// Authenticated user's email address. | |
| 29 | 35 | pub email: String, |
| 36 | + /// Two-letter initials derived from the username for the avatar. | |
| 30 | 37 | pub initials: String, |
| 38 | + /// CSS color class used for the user's avatar background. | |
| 31 | 39 | pub avatar_color: String, |
| 40 | + /// Roles assigned to the authenticated user. | |
| 32 | 41 | pub roles: Vec<String>, |
| 33 | 42 | } |
| 34 | 43 |
@@ -271,8 +280,8 @@ pub fn generate_default_sidebar(
| 271 | 280 | let mut info_section = SidebarSection::new("Informations"); |
| 272 | 281 | |
| 273 | 282 | // User info item (display only) |
| 274 | - info_section = info_section | |
| 275 | - .with_item(SidebarItem::info(username, "👤").with_badge(initials.to_string())); | |
| 283 | + info_section = | |
| 284 | + info_section.with_item(SidebarItem::info(username, "👤").with_badge(initials.to_string())); | |
| 276 | 285 | |
| 277 | 286 | // Email info item |
| 278 | 287 | info_section = info_section.with_item(SidebarItem::info(email, "✉️")); |
M
crates/rustwarden-ui/templates/admin/settings.html
+26
-0
@@ -274,6 +274,32 @@
| 274 | 274 | </div> |
| 275 | 275 | </div> |
| 276 | 276 | |
| 277 | + <!-- Two-Factor Authentication Card --> | |
| 278 | + <div class="card bg-base-100 shadow-xl"> | |
| 279 | + <div class="card-body"> | |
| 280 | + <h2 class="card-title text-xl mb-4"> | |
| 281 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> | |
| 282 | + Authentification à deux facteurs | |
| 283 | + </h2> | |
| 284 | + | |
| 285 | + <div class="form-control"> | |
| 286 | + <label class="label cursor-pointer justify-start gap-4"> | |
| 287 | + <input | |
| 288 | + type="checkbox" | |
| 289 | + name="totp_force_enabled" | |
| 290 | + value="true" | |
| 291 | + class="toggle toggle-primary" | |
| 292 | + {% if settings.totp_force_enabled == "true" %}checked{% endif %} | |
| 293 | + /> | |
| 294 | + <div> | |
| 295 | + <span class="label-text font-semibold">Forcer le 2FA pour tous les utilisateurs</span> | |
| 296 | + <p class="text-xs text-base-content/60 mt-1">Lorsqu'activé, tous les utilisateurs devront configurer l'authentification à deux facteurs avant de pouvoir accéder à l'application.</p> | |
| 297 | + </div> | |
| 298 | + </label> | |
| 299 | + </div> | |
| 300 | + </div> | |
| 301 | + </div> | |
| 302 | + | |
| 277 | 303 | <!-- Audit Logging Card --> |
| 278 | 304 | <div class="card bg-base-100 shadow-xl"> |
| 279 | 305 | <div class="card-body"> |
M
crates/rustwarden-ui/templates/admin/users_list.html
+10
-0
@@ -107,6 +107,7 @@
| 107 | 107 | <th>{{ table_username }}</th> |
| 108 | 108 | <th>{{ table_email }}</th> |
| 109 | 109 | <th>{{ table_roles }}</th> |
| 110 | + <th>2FA</th> | |
| 110 | 111 | <th>{{ table_created_at }}</th> |
| 111 | 112 | <th>{{ table_last_login }}</th> |
| 112 | 113 | <th>{{ table_actions }}</th> |
@@ -129,6 +130,15 @@
| 129 | 130 | </div> |
| 130 | 131 | </td> |
| 131 | 132 | <td> |
| 133 | + {% if user.totp_enabled %} | |
| 134 | + <span class="badge badge-success gap-1"> | |
| 135 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> | |
| 136 | + </span> | |
| 137 | + {% else %} | |
| 138 | + <span class="badge badge-ghost text-xs">-</span> | |
| 139 | + {% endif %} | |
| 140 | + </td> | |
| 141 | + <td> | |
| 132 | 142 | <div class="text-sm">{{ user.created_at }}</div> |
| 133 | 143 | </td> |
| 134 | 144 | <td> |
M
crates/rustwarden-ui/templates/login.html
+27
-7
@@ -98,17 +98,17 @@
| 98 | 98 | id="login-form" |
| 99 | 99 | class="space-y-4"> |
| 100 | 100 | |
| 101 | - <!-- Username --> | |
| 101 | + <!-- Username or Email --> | |
| 102 | 102 | <div class="form-control"> |
| 103 | 103 | <label for="username" class="label"> |
| 104 | 104 | <span class="label-text">{{ username_label }}</span> |
| 105 | 105 | </label> |
| 106 | - <input | |
| 107 | - type="text" | |
| 108 | - id="username" | |
| 109 | - name="username" | |
| 106 | + <input | |
| 107 | + type="text" | |
| 108 | + id="username" | |
| 109 | + name="username" | |
| 110 | 110 | required |
| 111 | - autocomplete="username" | |
| 111 | + autocomplete="username email" | |
| 112 | 112 | class="input input-bordered w-full" |
| 113 | 113 | placeholder="{{ username_placeholder }}"> |
| 114 | 114 | </div> |
@@ -334,7 +334,13 @@
| 334 | 334 | if (xhr.status === 200) { |
| 335 | 335 | // Login successful |
| 336 | 336 | const response = JSON.parse(xhr.responseText); |
| 337 | - | |
| 337 | + | |
| 338 | + // Check if 2FA is required | |
| 339 | + if (response.requires_2fa && response.challenge_token) { | |
| 340 | + window.location.href = '/login/2fa?challenge=' + encodeURIComponent(response.challenge_token); | |
| 341 | + return; | |
| 342 | + } | |
| 343 | + | |
| 338 | 344 | // Hide error message if visible |
| 339 | 345 | const errorDiv = document.getElementById('error-message'); |
| 340 | 346 | if (errorDiv) { |
@@ -417,6 +423,12 @@
| 417 | 423 | } |
| 418 | 424 | }) |
| 419 | 425 | .then(data => { |
| 426 | + // Check if 2FA is required | |
| 427 | + if (data.requires_2fa && data.challenge_token) { | |
| 428 | + window.location.href = '/login/2fa?challenge=' + encodeURIComponent(data.challenge_token); | |
| 429 | + return; | |
| 430 | + } | |
| 431 | + | |
| 420 | 432 | // Login successful |
| 421 | 433 | const successDiv = document.getElementById('success-message'); |
| 422 | 434 | if (successDiv) { |
@@ -447,6 +459,14 @@
| 447 | 459 | return; |
| 448 | 460 | } |
| 449 | 461 | |
| 462 | + // Check if user must setup 2FA | |
| 463 | + if (data.user && data.user.must_setup_2fa === true) { | |
| 464 | + setTimeout(() => { | |
| 465 | + window.location.href = '/settings/security?force_2fa=true'; | |
| 466 | + }, 500); | |
| 467 | + return; | |
| 468 | + } | |
| 469 | + | |
| 450 | 470 | // SEC-H2: safe redirect after login |
| 451 | 471 | setTimeout(() => { |
| 452 | 472 | window.location.href = getSafeRedirect(); |
A
crates/rustwarden-ui/templates/login_2fa.html
+123
-0
@@ -0,0 +1,123 @@
| 1 | +<!DOCTYPE html> | |
| 2 | +<html lang="fr"> | |
| 3 | +<head> | |
| 4 | + <meta charset="UTF-8"> | |
| 5 | + <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| 6 | + <title>{{ page_title }}</title> | |
| 7 | + <link href="/static/css/tailwind.css" rel="stylesheet"> | |
| 8 | + <link href="/static/css/custom.css" rel="stylesheet"> | |
| 9 | + <script src="/static/js/alpine.min.js" defer></script> | |
| 10 | +</head> | |
| 11 | +<body class="min-h-screen bg-base-200"> | |
| 12 | + <div class="min-h-screen flex items-center justify-center px-4" x-data="twoFactorPage()"> | |
| 13 | + <div class="card bg-base-100 shadow-xl w-full max-w-md"> | |
| 14 | + <div class="card-body"> | |
| 15 | + <div class="text-center mb-6"> | |
| 16 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-12 w-12 mx-auto text-primary mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| 17 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> | |
| 18 | + </svg> | |
| 19 | + <h1 class="text-2xl font-bold">{{ title }}</h1> | |
| 20 | + <p class="text-base-content/70 mt-1">{{ subtitle }}</p> | |
| 21 | + </div> | |
| 22 | + | |
| 23 | + <!-- TOTP Code Input --> | |
| 24 | + <div x-show="!useBackupCode"> | |
| 25 | + <label class="label"><span class="label-text font-semibold">{{ code_label }}</span></label> | |
| 26 | + <input | |
| 27 | + type="text" | |
| 28 | + x-model="code" | |
| 29 | + maxlength="6" | |
| 30 | + pattern="[0-9]*" | |
| 31 | + inputmode="numeric" | |
| 32 | + autocomplete="one-time-code" | |
| 33 | + class="input input-bordered w-full text-center text-2xl tracking-widest" | |
| 34 | + placeholder="000000" | |
| 35 | + @keyup.enter="verify()" | |
| 36 | + autofocus | |
| 37 | + /> | |
| 38 | + </div> | |
| 39 | + | |
| 40 | + <!-- Backup Code Input --> | |
| 41 | + <div x-show="useBackupCode" x-cloak> | |
| 42 | + <label class="label"><span class="label-text font-semibold">{{ backup_code_label }}</span></label> | |
| 43 | + <input | |
| 44 | + type="text" | |
| 45 | + x-model="code" | |
| 46 | + maxlength="8" | |
| 47 | + class="input input-bordered w-full text-center text-xl tracking-widest font-mono" | |
| 48 | + placeholder="ABCD1234" | |
| 49 | + @keyup.enter="verify()" | |
| 50 | + /> | |
| 51 | + </div> | |
| 52 | + | |
| 53 | + <!-- Error message --> | |
| 54 | + <p x-show="error" class="text-error text-sm mt-2" x-text="error"></p> | |
| 55 | + | |
| 56 | + <!-- Submit button --> | |
| 57 | + <button class="btn btn-primary w-full mt-4" @click="verify()" :disabled="!code || verifying"> | |
| 58 | + <span x-show="!verifying">{{ verify_button }}</span> | |
| 59 | + <span x-show="verifying" class="loading loading-spinner loading-sm"></span> | |
| 60 | + </button> | |
| 61 | + | |
| 62 | + <!-- Toggle backup code --> | |
| 63 | + <div class="text-center mt-4"> | |
| 64 | + <button class="btn btn-ghost btn-sm" @click="useBackupCode = !useBackupCode; code = '';"> | |
| 65 | + <span x-show="!useBackupCode">{{ use_backup_code }}</span> | |
| 66 | + <span x-show="useBackupCode">{{ use_totp_code }}</span> | |
| 67 | + </button> | |
| 68 | + </div> | |
| 69 | + | |
| 70 | + <!-- Back to login --> | |
| 71 | + <div class="text-center mt-2"> | |
| 72 | + <a href="/login" class="link link-hover text-sm">{{ back_to_login }}</a> | |
| 73 | + </div> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + </div> | |
| 77 | + | |
| 78 | + <script> | |
| 79 | + function twoFactorPage() { | |
| 80 | + return { | |
| 81 | + code: '', | |
| 82 | + error: '', | |
| 83 | + verifying: false, | |
| 84 | + useBackupCode: false, | |
| 85 | + challengeToken: new URLSearchParams(window.location.search).get('challenge') || '', | |
| 86 | + | |
| 87 | + async verify() { | |
| 88 | + if (!this.code || !this.challengeToken) return; | |
| 89 | + this.verifying = true; | |
| 90 | + this.error = ''; | |
| 91 | + try { | |
| 92 | + const res = await fetch('/api/v1/auth/2fa/verify', { | |
| 93 | + method: 'POST', | |
| 94 | + headers: { 'Content-Type': 'application/json' }, | |
| 95 | + body: JSON.stringify({ | |
| 96 | + challenge_token: this.challengeToken, | |
| 97 | + code: this.code, | |
| 98 | + }), | |
| 99 | + }); | |
| 100 | + const data = await res.json(); | |
| 101 | + if (res.ok) { | |
| 102 | + if (data.refresh_token) { | |
| 103 | + localStorage.setItem('refresh_token', data.refresh_token); | |
| 104 | + } | |
| 105 | + if (data.user && data.user.must_change_password === true) { | |
| 106 | + window.location.href = '/settings/password?must_change=true'; | |
| 107 | + } else { | |
| 108 | + window.location.href = '/home'; | |
| 109 | + } | |
| 110 | + } else { | |
| 111 | + this.error = data.error || '{{ error_invalid_code }}'; | |
| 112 | + this.code = ''; | |
| 113 | + } | |
| 114 | + } catch (e) { | |
| 115 | + this.error = '{{ error_network }}'; | |
| 116 | + } | |
| 117 | + this.verifying = false; | |
| 118 | + } | |
| 119 | + }; | |
| 120 | + } | |
| 121 | + </script> | |
| 122 | +</body> | |
| 123 | +</html> |
M
crates/rustwarden-ui/templates/profile.html
+4
-0
@@ -127,6 +127,10 @@
| 127 | 127 | <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" /></svg> |
| 128 | 128 | Préférences d'affichage |
| 129 | 129 | </a> |
| 130 | + <a href="/settings/security" class="btn btn-outline btn-warning"> | |
| 131 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> | |
| 132 | + Sécurité 2FA | |
| 133 | + </a> | |
| 130 | 134 | </div> |
| 131 | 135 | </div> |
| 132 | 136 |
M
crates/rustwarden-ui/templates/settings/preferences.html
+40
-0
@@ -226,6 +226,46 @@
| 226 | 226 | </label> |
| 227 | 227 | </div> |
| 228 | 228 | |
| 229 | + <div class="form-control"> | |
| 230 | + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"> | |
| 231 | + <input | |
| 232 | + type="radio" | |
| 233 | + name="language" | |
| 234 | + value="it" | |
| 235 | + class="radio radio-primary" | |
| 236 | + form="preferencesForm" | |
| 237 | + {% if is_language_it %}checked{% endif %} | |
| 238 | + > | |
| 239 | + <div class="flex-1"> | |
| 240 | + <div class="flex items-center gap-2"> | |
| 241 | + <span class="text-2xl">🇮🇹</span> | |
| 242 | + <span class="label-text font-semibold">{{ language_it }}</span> | |
| 243 | + </div> | |
| 244 | + <span class="label-text-alt text-base-content/70">{{ language_it_desc }}</span> | |
| 245 | + </div> | |
| 246 | + </label> | |
| 247 | + </div> | |
| 248 | + | |
| 249 | + <div class="form-control"> | |
| 250 | + <label class="label cursor-pointer justify-start gap-4 p-4 rounded-lg border border-base-300 hover:bg-base-200 transition-colors"> | |
| 251 | + <input | |
| 252 | + type="radio" | |
| 253 | + name="language" | |
| 254 | + value="pt" | |
| 255 | + class="radio radio-primary" | |
| 256 | + form="preferencesForm" | |
| 257 | + {% if is_language_pt %}checked{% endif %} | |
| 258 | + > | |
| 259 | + <div class="flex-1"> | |
| 260 | + <div class="flex items-center gap-2"> | |
| 261 | + <span class="text-2xl">🇧🇷</span> | |
| 262 | + <span class="label-text font-semibold">{{ language_pt }}</span> | |
| 263 | + </div> | |
| 264 | + <span class="label-text-alt text-base-content/70">{{ language_pt_desc }}</span> | |
| 265 | + </div> | |
| 266 | + </label> | |
| 267 | + </div> | |
| 268 | + | |
| 229 | 269 | <div class="alert alert-warning"> |
| 230 | 270 | <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> |
| 231 | 271 | <span class="text-xs">{{ translation_warning }}</span> |
A
crates/rustwarden-ui/templates/settings/security.html
+304
-0
@@ -0,0 +1,304 @@
| 1 | +{% extends "base.html" %} | |
| 2 | + | |
| 3 | +{% block title %}{{ page_title }}{% endblock %} | |
| 4 | + | |
| 5 | +{% block content %} | |
| 6 | +<div class="container mx-auto max-w-4xl px-4 py-8"> | |
| 7 | + <!-- Breadcrumb --> | |
| 8 | + <div class="text-sm breadcrumbs mb-6"> | |
| 9 | + <ul> | |
| 10 | + <li><a href="/home">{{ breadcrumb_home }}</a></li> | |
| 11 | + <li><a href="/settings/preferences">{{ breadcrumb_settings }}</a></li> | |
| 12 | + <li>{{ breadcrumb_security }}</li> | |
| 13 | + </ul> | |
| 14 | + </div> | |
| 15 | + | |
| 16 | + {% if force_2fa %} | |
| 17 | + <div class="alert alert-warning mb-6 shadow-lg"> | |
| 18 | + <svg xmlns="http://www.w3.org/2000/svg" class="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg> | |
| 19 | + <div class="flex-1"> | |
| 20 | + <span>{{ force_2fa_message }}</span> | |
| 21 | + </div> | |
| 22 | + <button class="btn btn-sm btn-ghost" | |
| 23 | + onclick="localStorage.removeItem('refresh_token'); localStorage.removeItem('remember_me'); localStorage.removeItem('jwt_expires_in'); fetch('/api/v1/auth/logout', {method:'POST', credentials:'include'}).then(()=>window.location.href='/login');"> | |
| 24 | + {{ force_2fa_logout }} | |
| 25 | + </button> | |
| 26 | + </div> | |
| 27 | + {% endif %} | |
| 28 | + | |
| 29 | + <div class="flex items-center gap-3 mb-8"> | |
| 30 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
| 31 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /> | |
| 32 | + </svg> | |
| 33 | + <div> | |
| 34 | + <h1 class="text-2xl font-bold">{{ title }}</h1> | |
| 35 | + <p class="text-base-content/70">{{ subtitle }}</p> | |
| 36 | + </div> | |
| 37 | + </div> | |
| 38 | + | |
| 39 | + <div x-data="securityPage()" class="space-y-6"> | |
| 40 | + <!-- Status Card --> | |
| 41 | + <div class="card bg-base-100 shadow-lg"> | |
| 42 | + <div class="card-body"> | |
| 43 | + <div class="flex items-center justify-between"> | |
| 44 | + <div class="flex items-center gap-3"> | |
| 45 | + <h2 class="card-title">{{ totp_title }}</h2> | |
| 46 | + {% if totp_enabled %} | |
| 47 | + <span class="badge badge-success gap-1"> | |
| 48 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" /></svg> | |
| 49 | + {{ status_enabled }} | |
| 50 | + </span> | |
| 51 | + {% else %} | |
| 52 | + <span class="badge badge-warning gap-1"> | |
| 53 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" /></svg> | |
| 54 | + {{ status_disabled }} | |
| 55 | + </span> | |
| 56 | + {% endif %} | |
| 57 | + </div> | |
| 58 | + </div> | |
| 59 | + | |
| 60 | + {% if totp_enabled %} | |
| 61 | + <!-- Enabled state --> | |
| 62 | + <div class="mt-4 space-y-4"> | |
| 63 | + <div class="alert alert-success"> | |
| 64 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg> | |
| 65 | + <span>{{ enabled_message }}</span> | |
| 66 | + </div> | |
| 67 | + <p class="text-sm text-base-content/70">{{ backup_codes_remaining }}: <strong>{{ backup_codes_count }}</strong></p> | |
| 68 | + <div class="flex gap-3"> | |
| 69 | + <button class="btn btn-outline btn-sm" @click="regenerateBackupCodes()"> | |
| 70 | + <span x-show="!regenerating">{{ regenerate_button }}</span> | |
| 71 | + <span x-show="regenerating" class="loading loading-spinner loading-sm"></span> | |
| 72 | + </button> | |
| 73 | + <button class="btn btn-error btn-outline btn-sm" @click="showDisableModal = true">{{ disable_button }}</button> | |
| 74 | + </div> | |
| 75 | + </div> | |
| 76 | + {% else %} | |
| 77 | + <!-- Disabled state --> | |
| 78 | + <div class="mt-4"> | |
| 79 | + <p class="text-base-content/70 mb-4">{{ disabled_message }}</p> | |
| 80 | + <button class="btn btn-primary" @click="startSetup()" x-show="!setupStarted"> | |
| 81 | + {{ enable_button }} | |
| 82 | + </button> | |
| 83 | + </div> | |
| 84 | + {% endif %} | |
| 85 | + | |
| 86 | + <!-- Setup flow (shown when user clicks Enable) --> | |
| 87 | + <div x-show="setupStarted && !setupComplete" x-cloak class="mt-6 space-y-6"> | |
| 88 | + <div class="divider"></div> | |
| 89 | + | |
| 90 | + <!-- QR Code --> | |
| 91 | + <div class="text-center" x-show="qrCode"> | |
| 92 | + <p class="font-semibold mb-3">{{ scan_qr }}</p> | |
| 93 | + <div class="flex justify-center mb-4"> | |
| 94 | + <img :src="qrCode" alt="QR Code" class="w-48 h-48 rounded-lg border border-base-300 p-2 bg-white" /> | |
| 95 | + </div> | |
| 96 | + <div class="collapse collapse-arrow bg-base-200 max-w-md mx-auto"> | |
| 97 | + <input type="checkbox" /> | |
| 98 | + <div class="collapse-title text-sm font-medium">{{ manual_entry }}</div> | |
| 99 | + <div class="collapse-content"> | |
| 100 | + <code class="text-xs break-all select-all" x-text="secretBase32"></code> | |
| 101 | + </div> | |
| 102 | + </div> | |
| 103 | + </div> | |
| 104 | + | |
| 105 | + <!-- Verification code input --> | |
| 106 | + <div class="max-w-sm mx-auto" x-show="qrCode"> | |
| 107 | + <label class="label"><span class="label-text font-semibold">{{ enter_code }}</span></label> | |
| 108 | + <div class="join w-full"> | |
| 109 | + <input | |
| 110 | + type="text" | |
| 111 | + x-model="verifyCode" | |
| 112 | + maxlength="6" | |
| 113 | + pattern="[0-9]*" | |
| 114 | + inputmode="numeric" | |
| 115 | + autocomplete="one-time-code" | |
| 116 | + class="input input-bordered join-item flex-1 text-center text-2xl tracking-widest" | |
| 117 | + placeholder="{{ code_placeholder }}" | |
| 118 | + @keyup.enter="verifyAndEnable()" | |
| 119 | + /> | |
| 120 | + <button class="btn btn-primary join-item" @click="verifyAndEnable()" :disabled="verifyCode.length !== 6 || verifying"> | |
| 121 | + <span x-show="!verifying">{{ verify_button }}</span> | |
| 122 | + <span x-show="verifying" class="loading loading-spinner loading-sm"></span> | |
| 123 | + </button> | |
| 124 | + </div> | |
| 125 | + <p x-show="setupError" class="text-error text-sm mt-2" x-text="setupError"></p> | |
| 126 | + </div> | |
| 127 | + </div> | |
| 128 | + </div> | |
| 129 | + </div> | |
| 130 | + | |
| 131 | + <!-- Backup Codes Modal --> | |
| 132 | + <dialog id="backupCodesModal" class="modal" :class="{ 'modal-open': showBackupCodes }"> | |
| 133 | + <div class="modal-box max-w-lg"> | |
| 134 | + <h3 class="font-bold text-lg mb-2">{{ backup_codes_title }}</h3> | |
| 135 | + <div class="alert alert-warning mb-4"> | |
| 136 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01" /></svg> | |
| 137 | + <span class="text-sm">{{ backup_codes_warning }}</span> | |
| 138 | + </div> | |
| 139 | + <div class="grid grid-cols-2 gap-2 mb-4"> | |
| 140 | + <template x-for="code in backupCodes" :key="code"> | |
| 141 | + <code class="bg-base-200 p-2 rounded text-center font-mono text-sm select-all" x-text="code"></code> | |
| 142 | + </template> | |
| 143 | + </div> | |
| 144 | + <div class="flex gap-2"> | |
| 145 | + <button class="btn btn-sm btn-outline" @click="copyBackupCodes()"> | |
| 146 | + <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" /></svg> | |
| 147 | + {{ copy_button }} | |
| 148 | + </button> | |
| 149 | + </div> | |
| 150 | + <div class="modal-action"> | |
| 151 | + <button class="btn btn-primary" @click="showBackupCodes = false; window.location.reload();">{{ saved_codes_button }}</button> | |
| 152 | + </div> | |
| 153 | + </div> | |
| 154 | + <form method="dialog" class="modal-backdrop" @click="showBackupCodes = false; window.location.reload();"> | |
| 155 | + <button>close</button> | |
| 156 | + </form> | |
| 157 | + </dialog> | |
| 158 | + | |
| 159 | + <!-- Disable 2FA Modal --> | |
| 160 | + <dialog id="disableModal" class="modal" :class="{ 'modal-open': showDisableModal }"> | |
| 161 | + <div class="modal-box"> | |
| 162 | + <h3 class="font-bold text-lg mb-4">{{ confirm_disable }}</h3> | |
| 163 | + <div class="form-control"> | |
| 164 | + <label class="label"><span class="label-text">{{ password_label }}</span></label> | |
| 165 | + <input type="password" x-model="disablePassword" class="input input-bordered" @keyup.enter="disableTotp()" /> | |
| 166 | + </div> | |
| 167 | + <p x-show="disableError" class="text-error text-sm mt-2" x-text="disableError"></p> | |
| 168 | + <div class="modal-action"> | |
| 169 | + <button class="btn" @click="showDisableModal = false; disablePassword = ''; disableError = '';">{{ cancel_button }}</button> | |
| 170 | + <button class="btn btn-error" @click="disableTotp()" :disabled="!disablePassword || disabling"> | |
| 171 | + <span x-show="!disabling">{{ disable_button }}</span> | |
| 172 | + <span x-show="disabling" class="loading loading-spinner loading-sm"></span> | |
| 173 | + </button> | |
| 174 | + </div> | |
| 175 | + </div> | |
| 176 | + <form method="dialog" class="modal-backdrop" @click="showDisableModal = false;"> | |
| 177 | + <button>close</button> | |
| 178 | + </form> | |
| 179 | + </dialog> | |
| 180 | + </div> | |
| 181 | +</div> | |
| 182 | + | |
| 183 | +<script> | |
| 184 | +function securityPage() { | |
| 185 | + return { | |
| 186 | + setupStarted: false, | |
| 187 | + setupComplete: false, | |
| 188 | + setupError: '', | |
| 189 | + qrCode: '', | |
| 190 | + secretBase32: '', | |
| 191 | + verifyCode: '', | |
| 192 | + verifying: false, | |
| 193 | + showBackupCodes: false, | |
| 194 | + backupCodes: [], | |
| 195 | + regenerating: false, | |
| 196 | + showDisableModal: false, | |
| 197 | + disablePassword: '', | |
| 198 | + disableError: '', | |
| 199 | + disabling: false, | |
| 200 | + | |
| 201 | + async startSetup() { | |
| 202 | + this.setupStarted = true; | |
| 203 | + this.setupError = ''; | |
| 204 | + try { | |
| 205 | + const res = await fetch('/settings/security/totp/enable', { | |
| 206 | + method: 'POST', | |
| 207 | + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 208 | + }); | |
| 209 | + const data = await res.json(); | |
| 210 | + if (res.ok) { | |
| 211 | + this.qrCode = data.qr_code_data_uri; | |
| 212 | + this.secretBase32 = data.secret_base32; | |
| 213 | + } else { | |
| 214 | + this.setupError = data.error || 'Error'; | |
| 215 | + this.setupStarted = false; | |
| 216 | + } | |
| 217 | + } catch (e) { | |
| 218 | + this.setupError = 'Network error'; | |
| 219 | + this.setupStarted = false; | |
| 220 | + } | |
| 221 | + }, | |
| 222 | + | |
| 223 | + async verifyAndEnable() { | |
| 224 | + if (this.verifyCode.length !== 6) return; | |
| 225 | + this.verifying = true; | |
| 226 | + this.setupError = ''; | |
| 227 | + try { | |
| 228 | + const res = await fetch('/settings/security/totp/verify', { | |
| 229 | + method: 'POST', | |
| 230 | + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 231 | + body: JSON.stringify({ code: this.verifyCode }), | |
| 232 | + }); | |
| 233 | + const data = await res.json(); | |
| 234 | + if (res.ok) { | |
| 235 | + this.setupComplete = true; | |
| 236 | + this.backupCodes = data.backup_codes; | |
| 237 | + this.showBackupCodes = true; | |
| 238 | + } else { | |
| 239 | + this.setupError = data.error || 'Invalid code'; | |
| 240 | + this.verifyCode = ''; | |
| 241 | + } | |
| 242 | + } catch (e) { | |
| 243 | + this.setupError = 'Network error'; | |
| 244 | + } | |
| 245 | + this.verifying = false; | |
| 246 | + }, | |
| 247 | + | |
| 248 | + async regenerateBackupCodes() { | |
| 249 | + if (!confirm('{{ regenerate_confirm }}')) return; | |
| 250 | + this.regenerating = true; | |
| 251 | + try { | |
| 252 | + const res = await fetch('/settings/security/backup-codes', { | |
| 253 | + method: 'POST', | |
| 254 | + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 255 | + }); | |
| 256 | + const data = await res.json(); | |
| 257 | + if (res.ok) { | |
| 258 | + this.backupCodes = data.backup_codes; | |
| 259 | + this.showBackupCodes = true; | |
| 260 | + } | |
| 261 | + } catch (e) {} | |
| 262 | + this.regenerating = false; | |
| 263 | + }, | |
| 264 | + | |
| 265 | + async disableTotp() { | |
| 266 | + this.disabling = true; | |
| 267 | + this.disableError = ''; | |
| 268 | + try { | |
| 269 | + const res = await fetch('/settings/security/totp/disable', { | |
| 270 | + method: 'POST', | |
| 271 | + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': '{{ csrf_token }}' }, | |
| 272 | + body: JSON.stringify({ password: this.disablePassword }), | |
| 273 | + }); | |
| 274 | + if (res.ok) { | |
| 275 | + window.location.reload(); | |
| 276 | + } else { | |
| 277 | + const data = await res.json(); | |
| 278 | + this.disableError = data.error || 'Error'; | |
| 279 | + } | |
| 280 | + } catch (e) { | |
| 281 | + this.disableError = 'Network error'; | |
| 282 | + } | |
| 283 | + this.disabling = false; | |
| 284 | + }, | |
| 285 | + | |
| 286 | + copyBackupCodes() { | |
| 287 | + const text = this.backupCodes.join('\n'); | |
| 288 | + if (navigator.clipboard && navigator.clipboard.writeText) { | |
| 289 | + navigator.clipboard.writeText(text); | |
| 290 | + } else { | |
| 291 | + const ta = document.createElement('textarea'); | |
| 292 | + ta.value = text; | |
| 293 | + ta.style.position = 'fixed'; | |
| 294 | + ta.style.opacity = '0'; | |
| 295 | + document.body.appendChild(ta); | |
| 296 | + ta.select(); | |
| 297 | + document.execCommand('copy'); | |
| 298 | + document.body.removeChild(ta); | |
| 299 | + } | |
| 300 | + } | |
| 301 | + }; | |
| 302 | +} | |
| 303 | +</script> | |
| 304 | +{% endblock %} |
A
docs/BACKLOG_I18N_LOCALE.md
+115
-0
@@ -0,0 +1,115 @@
| 1 | +# Backlog — Propagation de la locale utilisateur | |
| 2 | + | |
| 3 | +> **Status** : ticket différé, à planifier comme chantier dédié. | |
| 4 | +> **Origine** : audit anti-slop post-AddTeams (24 avril 2026) — 9 `TODO` recensés, | |
| 5 | +> tous pré-existants (aucun introduit par la branche AddTeams). | |
| 6 | +> | |
| 7 | +> **Objectif** : remplacer les locales hardcodées `Some("fr".to_string())` par la | |
| 8 | +> locale réelle de l'utilisateur (préférence DB → cookie → `Accept-Language` → défaut). | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. Contexte | |
| 13 | + | |
| 14 | +Plusieurs services envoient des emails transactionnels en utilisant des templates i18n | |
| 15 | +(`EmailValidationTemplate`, `PasswordResetEmailTemplate`, `WelcomeEmailTemplate`, | |
| 16 | +`PasswordChangeConfirmationEmailTemplate`). Aujourd'hui, la locale passée au template | |
| 17 | +est **hardcodée à `"fr"`** avec un `TODO`. Conséquence : un utilisateur qui a choisi | |
| 18 | +`en` dans ses préférences reçoit malgré tout un mail en français. | |
| 19 | + | |
| 20 | +Par ailleurs, l'extracteur Axum `Locale` n'implémente qu'une partie de la priorité | |
| 21 | +prévue (cookie + `Accept-Language`) — la lecture de la préférence DB reste à faire. | |
| 22 | + | |
| 23 | +## 2. Inventaire des TODO (9) | |
| 24 | + | |
| 25 | +| # | Fichier | Ligne | Contenu | Impact | | |
| 26 | +|---|---|---|---|---| | |
| 27 | +| 1 | [crates/rustwarden-core/src/i18n/extractor.rs](../crates/rustwarden-core/src/i18n/extractor.rs) | 57 | `T009 - Implement full locale extraction logic` | L'extracteur `Locale` ne lit jamais `user_preferences.preferred_locale`. Priorité 1 (source de vérité). | | |
| 28 | +| 2 | [crates/rustwarden-core/src/services/email_validation_service.rs](../crates/rustwarden-core/src/services/email_validation_service.rs) | 100 | `Get from user preferences or Accept-Language header` | Email de validation d'adresse toujours en `fr`. | | |
| 29 | +| 3 | [crates/rustwarden-core/src/services/email_validation_service.rs](../crates/rustwarden-core/src/services/email_validation_service.rs) | 220 | `Get from user preferences` | Email de bienvenue toujours en `fr`. | | |
| 30 | +| 4 | [crates/rustwarden-core/src/services/password_reset_service.rs](../crates/rustwarden-core/src/services/password_reset_service.rs) | 115 | `Get from user preferences or Accept-Language header` | Email de reset mot de passe toujours en `fr`. | | |
| 31 | +| 5 | [crates/rustwarden-core/src/tasks/password_change_email_retry.rs](../crates/rustwarden-core/src/tasks/password_change_email_retry.rs) | 197 | `Get from user preferences` | Email de confirmation de changement mot de passe (retry) toujours en `fr`. | | |
| 32 | +| 6 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 548 | `Store user_agent in refresh_token table` | Champ `device_info` toujours `None` dans la liste des sessions actives. | | |
| 33 | +| 7 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 549 | `Store IP in refresh_token table` | Champ `ip_address` toujours `None`. | | |
| 34 | +| 8 | [crates/rustwarden-core/src/services/refresh_token_service.rs](../crates/rustwarden-core/src/services/refresh_token_service.rs) | 590 | `Blacklist associated JWT if applicable` | La révocation d'une session ne blackliste pas le JWT. | | |
| 35 | +| 9 | [crates/rustwarden-ui/src/handlers/email_handler.rs](../crates/rustwarden-ui/src/handlers/email_handler.rs) | 102 | `Get total count for pagination (requires separate query)` | La pagination de la file d'emails retourne `emails.len()` au lieu d'un `COUNT(*)`. | | |
| 36 | + | |
| 37 | +Les items #6, #7, #8 et #9 ne sont pas strictement i18n mais font partie du même | |
| 38 | +backlog "dette technique pré-existante" ; on les garde groupés ici pour un chantier | |
| 39 | +unique "propagation métadonnées requête → services". | |
| 40 | + | |
| 41 | +## 3. Ébauche de solution | |
| 42 | + | |
| 43 | +### 3.1 Couche extracteur (item #1) | |
| 44 | + | |
| 45 | +Enrichir [`Locale::from_request_parts`](../crates/rustwarden-core/src/i18n/extractor.rs) | |
| 46 | +pour lire la préférence DB quand un `Claims` est disponible : | |
| 47 | + | |
| 48 | +```text | |
| 49 | +priorité : | |
| 50 | + 1. user_preferences.preferred_locale (si Claims présent + DB accessible via Extension<DatabaseConnection>) | |
| 51 | + 2. cookie `locale` | |
| 52 | + 3. Accept-Language header | |
| 53 | + 4. "fr" | |
| 54 | +``` | |
| 55 | + | |
| 56 | +Contrainte : `from_request_parts` ne peut pas faire de requête DB sans extracteur | |
| 57 | +d'état. Deux options : | |
| 58 | +- **(A)** garder `Locale` sans DB et ajouter un helper `Locale::resolve(claims, db, headers)` appelable dans les services. | |
| 59 | +- **(B)** créer un `Extractor` supplémentaire `UserLocale` qui dépend de `AuthUser` + `Extension<DatabaseConnection>`. | |
| 60 | + | |
| 61 | +Préférence à discuter lors du chantier : option (A) pour garder `Locale` purement | |
| 62 | +HTTP, et ajouter un petit service `LocaleResolver` injecté dans les services emails. | |
| 63 | + | |
| 64 | +### 3.2 Services emails (items #2, #3, #4, #5) | |
| 65 | + | |
| 66 | +Remplacer `Some("fr".to_string())` par un paramètre `locale: Option<String>` passé | |
| 67 | +par l'appelant. Les callers sont : | |
| 68 | + | |
| 69 | +- `EmailValidationService::send_validation_email` — appelé après `POST /register` : on connaît l'utilisateur, on peut lire `user_preferences`. | |
| 70 | +- `PasswordResetService::request_reset` — appelé depuis `/forgot-password` (anonyme) : fallback sur `Accept-Language` depuis le handler. | |
| 71 | +- Task `PasswordChangeEmailRetryProcessor` — appelé périodiquement : doit lire `user_preferences` du user_id stocké en queue. | |
| 72 | + | |
| 73 | +Signature cible (exemple) : | |
| 74 | + | |
| 75 | +```rust | |
| 76 | +pub async fn send_validation_email( | |
| 77 | + &self, | |
| 78 | + db: &DatabaseConnection, | |
| 79 | + user: &user::Model, | |
| 80 | + locale: Option<String>, // déjà résolu par l'appelant | |
| 81 | +) -> Result<(), AppError> | |
| 82 | +``` | |
| 83 | + | |
| 84 | +### 3.3 Stockage métadonnées session (items #6, #7, #8) | |
| 85 | + | |
| 86 | +- Migration : ajouter `user_agent VARCHAR(512)` et `ip_address VARCHAR(45)` à `refresh_token`. | |
| 87 | +- Handler `/login` : extraire `User-Agent` + IP (`x-forwarded-for` ou `ConnectInfo`) et passer à `RefreshTokenService::create`. | |
| 88 | +- `revoke_session` : si `refresh_token.jwt_id` existe, appeler `JwtBlacklistService::blacklist`. | |
| 89 | + | |
| 90 | +### 3.4 Pagination emails (item #9) | |
| 91 | + | |
| 92 | +Ajouter une requête `COUNT(*)` dans `email_handler::list` et retourner le vrai total. | |
| 93 | + | |
| 94 | +## 4. Critères d'acceptation | |
| 95 | + | |
| 96 | +- [ ] Tous les `TODO` listés supprimés (ou déplacés vers un backlog plus précis) | |
| 97 | +- [ ] Un utilisateur avec `preferred_locale = "en"` reçoit tous les emails en anglais | |
| 98 | +- [ ] `ActiveSession.device_info` et `ActiveSession.ip_address` renseignés pour les nouvelles sessions | |
| 99 | +- [ ] Révoquer une session blackliste le JWT associé (testé via `cargo test`) | |
| 100 | +- [ ] Pagination `/admin/emails` retourne un total correct même avec offset > len | |
| 101 | + | |
| 102 | +## 5. Estimation | |
| 103 | + | |
| 104 | +- Refacto locale + tests : ~1 j | |
| 105 | +- Migration `refresh_token` + wiring login + blacklist : ~0.5 j | |
| 106 | +- Pagination emails : ~1 h | |
| 107 | +- Relecture PO + QA navigateur : ~0.5 j | |
| 108 | + | |
| 109 | +Total ≈ 2-3 jours de dev. | |
| 110 | + | |
| 111 | +## 6. Hors-scope | |
| 112 | + | |
| 113 | +- Ajout de locales (IT/PT déjà ajoutés dans la branche AddTeams) | |
| 114 | +- UI de sélection de langue (déjà existante dans `settings/preferences`) | |
| 115 | +- Conversion des logs / messages d'erreur internes (reste en anglais par convention tech) |
A
docs/CHANGELOG_FORCE_2FA.md
+233
-0
@@ -0,0 +1,233 @@
| 1 | +# Guide d'intégration — Forçage du 2FA | |
| 2 | + | |
| 3 | +**Branche** : `2FA_it_pt` | |
| 4 | +**Date** : 2026-04-09 | |
| 5 | +**Public cible** : développeurs de projets consommateurs (avec ou sans `rustwarden-ui`) | |
| 6 | + | |
| 7 | +--- | |
| 8 | + | |
| 9 | +## Sommaire | |
| 10 | + | |
| 11 | +1. [Contexte](#1-contexte) | |
| 12 | +2. [Fonctionnement](#2-fonctionnement) | |
| 13 | +3. [Impact sur l'API de login](#3-impact-sur-lapi-de-login) | |
| 14 | +4. [Middleware de blocage](#4-middleware-de-blocage) | |
| 15 | +5. [Interface utilisateur (rustwarden-ui)](#5-interface-utilisateur-rustwarden-ui) | |
| 16 | +6. [Checklist d'intégration — projets avec UI custom](#6-checklist-dintégration--projets-avec-ui-custom) | |
| 17 | + | |
| 18 | +--- | |
| 19 | + | |
| 20 | +## 1. Contexte | |
| 21 | + | |
| 22 | +Le setting `totp_force_enabled` (configurable via `/admin/settings`) existait déjà en base de données mais n'avait aucun effet concret. Cette mise à jour câble la logique de bout en bout : quand un administrateur active le forçage du 2FA, **tout utilisateur sans TOTP configuré** est contraint de l'activer avant de pouvoir naviguer dans l'application. | |
| 23 | + | |
| 24 | +Le comportement s'inspire du pattern `must_change_password` déjà en place : l'utilisateur peut se connecter, mais toute navigation est redirigée vers la page de configuration du 2FA. | |
| 25 | + | |
| 26 | +--- | |
| 27 | + | |
| 28 | +## 2. Fonctionnement | |
| 29 | + | |
| 30 | +### Flux utilisateur | |
| 31 | + | |
| 32 | +``` | |
| 33 | +Utilisateur se connecte (username/email + mot de passe) | |
| 34 | + │ | |
| 35 | + ▼ | |
| 36 | + Le 2FA est-il activé pour cet utilisateur ? | |
| 37 | + │ | |
| 38 | + ┌────┴────┐ | |
| 39 | + │ OUI │ NON | |
| 40 | + ▼ ▼ | |
| 41 | + Flow 2FA totp_force_enabled est-il true ? | |
| 42 | + classique │ | |
| 43 | + ┌───┴───┐ | |
| 44 | + │ OUI │ NON | |
| 45 | + ▼ ▼ | |
| 46 | + Réponse JSON avec Login normal | |
| 47 | + must_setup_2fa:true | |
| 48 | + │ | |
| 49 | + ▼ | |
| 50 | + Redirection vers /settings/security?force_2fa=true | |
| 51 | + │ | |
| 52 | + ▼ | |
| 53 | + ┌──────────────────────────────────┐ | |
| 54 | + │ Bandeau d'alerte : │ | |
| 55 | + │ "Vous devez configurer le 2FA" │ | |
| 56 | + │ [Se déconnecter] │ | |
| 57 | + └──────────────────────────────────┘ | |
| 58 | + │ | |
| 59 | + L'utilisateur configure le 2FA | |
| 60 | + │ | |
| 61 | + ▼ | |
| 62 | + Navigation normale débloquée | |
| 63 | +``` | |
| 64 | + | |
| 65 | +### Principes | |
| 66 | + | |
| 67 | +- L'utilisateur **peut se connecter** (le JWT est émis normalement) | |
| 68 | +- L'utilisateur **ne peut pas naviguer** — toute route protégée redirige vers `/settings/security` | |
| 69 | +- L'utilisateur **peut se déconnecter** s'il ne souhaite pas configurer le 2FA immédiatement | |
| 70 | +- Dès que le TOTP est activé, la navigation est immédiatement débloquée | |
| 71 | + | |
| 72 | +--- | |
| 73 | + | |
| 74 | +## 3. Impact sur l'API de login | |
| 75 | + | |
| 76 | +### Nouveau champ dans la réponse `POST /api/v1/auth/login` | |
| 77 | + | |
| 78 | +Le DTO `UserInfo` retourné dans `LoginResponse` contient un nouveau champ optionnel : | |
| 79 | + | |
| 80 | +```json | |
| 81 | +{ | |
| 82 | + "access_token": "eyJ...", | |
| 83 | + "refresh_token": "550e...", | |
| 84 | + "token_type": "Bearer", | |
| 85 | + "expires_in": 900, | |
| 86 | + "user": { | |
| 87 | + "id": "...", | |
| 88 | + "username": "john_doe", | |
| 89 | + "email": "john@example.com", | |
| 90 | + "roles": ["user"], | |
| 91 | + "must_change_password": false, | |
| 92 | + "must_setup_2fa": true | |
| 93 | + } | |
| 94 | +} | |
| 95 | +``` | |
| 96 | + | |
| 97 | +| Champ | Type | Présence | Description | | |
| 98 | +|-------|------|----------|-------------| | |
| 99 | +| `must_setup_2fa` | `bool` | Optionnel | Présent et `true` uniquement quand `totp_force_enabled` est activé en base ET que l'utilisateur n'a pas de TOTP configuré. Absent sinon. | | |
| 100 | + | |
| 101 | +**Note** : ce champ n'apparaît jamais dans la réponse de `POST /api/v1/auth/2fa/verify` (si l'utilisateur passe le 2FA, il l'a déjà configuré). | |
| 102 | + | |
| 103 | +--- | |
| 104 | + | |
| 105 | +## 4. Middleware de blocage | |
| 106 | + | |
| 107 | +### Routes protégées | |
| 108 | + | |
| 109 | +Le middleware `AuthService` (couche Tower) protège désormais les préfixes suivants : | |
| 110 | + | |
| 111 | +``` | |
| 112 | +/home, /profile, /test-base, /settings/*, /admin/* | |
| 113 | +``` | |
| 114 | + | |
| 115 | +(Auparavant seuls `/home`, `/profile`, `/test-base` étaient interceptés.) | |
| 116 | + | |
| 117 | +### Logique de redirection | |
| 118 | + | |
| 119 | +Pour chaque requête sur une route protégée, après validation du JWT : | |
| 120 | + | |
| 121 | +1. **Check `must_change_password`** → redirige vers `/settings/password?must_change=true` (inchangé) | |
| 122 | +2. **Check `totp_force_enabled`** (nouveau) : | |
| 123 | + - Si le setting `totp_force_enabled` est `true` en base | |
| 124 | + - ET que l'utilisateur n'a pas de TOTP activé (`TotpService::is_totp_enabled` retourne `false`) | |
| 125 | + - ET que la route **n'est pas** `/settings/security*` ni `/api/v1/auth/*` | |
| 126 | + - → Redirection `302` vers `/settings/security?force_2fa=true` | |
| 127 | + | |
| 128 | +### Routes exemptées (accessibles même sans 2FA) | |
| 129 | + | |
| 130 | +| Route | Raison | | |
| 131 | +|-------|--------| | |
| 132 | +| `/settings/security*` | Page de configuration du 2FA (et ses endpoints TOTP) | | |
| 133 | +| `/api/v1/auth/*` | Login, logout, refresh, 2FA verify | | |
| 134 | +| `/login*` | Pages publiques | | |
| 135 | +| `/static/*` | Assets statiques | | |
| 136 | + | |
| 137 | +--- | |
| 138 | + | |
| 139 | +## 5. Interface utilisateur (rustwarden-ui) | |
| 140 | + | |
| 141 | +### Page de login (`login.html`) | |
| 142 | + | |
| 143 | +Après un login réussi, le JavaScript vérifie `data.user.must_setup_2fa` : | |
| 144 | + | |
| 145 | +```javascript | |
| 146 | +if (data.user && data.user.must_setup_2fa === true) { | |
| 147 | + setTimeout(() => { | |
| 148 | + window.location.href = '/settings/security?force_2fa=true'; | |
| 149 | + }, 500); | |
| 150 | + return; | |
| 151 | +} | |
| 152 | +``` | |
| 153 | + | |
| 154 | +Ce check est placé **après** le check `must_change_password` et **avant** la redirection normale. | |
| 155 | + | |
| 156 | +### Page de sécurité (`settings/security.html`) | |
| 157 | + | |
| 158 | +Quand `force_2fa=true` : | |
| 159 | + | |
| 160 | +- Un **bandeau d'alerte warning** s'affiche en haut de page avec un message expliquant pourquoi l'utilisateur est redirigé ici | |
| 161 | +- Le bandeau contient un **bouton "Se déconnecter"** qui permet à l'utilisateur de quitter sans configurer le 2FA | |
| 162 | +- Le bouton effectue un `POST /api/v1/auth/logout` puis redirige vers `/login` | |
| 163 | + | |
| 164 | +Le message et le bouton sont traduits dans les 6 langues supportées (fr, en, de, es, it, pt). | |
| 165 | + | |
| 166 | +### Nouvelles clés i18n | |
| 167 | + | |
| 168 | +| Clé | Description | | |
| 169 | +|-----|-------------| | |
| 170 | +| `settings_security.force_2fa_message` | Message du bandeau d'alerte expliquant le forçage | | |
| 171 | +| `settings_security.force_2fa_logout` | Label du bouton de déconnexion dans le bandeau | | |
| 172 | + | |
| 173 | +#### Traductions | |
| 174 | + | |
| 175 | +| Langue | `force_2fa_message` | `force_2fa_logout` | | |
| 176 | +|--------|--------------------|--------------------| | |
| 177 | +| FR | L'administrateur exige l'activation de l'authentification à deux facteurs. Vous devez configurer le 2FA avant de pouvoir accéder au reste de l'application. | Se déconnecter | | |
| 178 | +| EN | Your administrator requires two-factor authentication. You must set up 2FA before you can access the rest of the application. | Sign out | | |
| 179 | +| DE | Ihr Administrator verlangt die Zwei-Faktor-Authentifizierung. Sie müssen 2FA einrichten, bevor Sie auf den Rest der Anwendung zugreifen können. | Abmelden | | |
| 180 | +| ES | Su administrador requiere la autenticación de dos factores. Debe configurar 2FA antes de poder acceder al resto de la aplicación. | Cerrar sesión | | |
| 181 | +| IT | L'amministratore richiede l'autenticazione a due fattori. Devi configurare il 2FA prima di poter accedere al resto dell'applicazione. | Disconnettersi | | |
| 182 | +| PT | O administrador exige a autenticação de dois fatores. Você deve configurar o 2FA antes de poder acessar o restante da aplicação. | Sair | | |
| 183 | + | |
| 184 | +--- | |
| 185 | + | |
| 186 | +## 6. Checklist d'intégration — projets avec UI custom | |
| 187 | + | |
| 188 | +Si votre projet **n'utilise pas `rustwarden-ui`**, vous devez implémenter ces comportements dans votre frontend : | |
| 189 | + | |
| 190 | +### Obligatoire | |
| 191 | + | |
| 192 | +- [ ] **Réponse login** : après `POST /api/v1/auth/login`, vérifier si `user.must_setup_2fa === true` dans la réponse JSON | |
| 193 | +- [ ] **Redirection** : si `must_setup_2fa` est `true`, rediriger l'utilisateur vers votre page de configuration 2FA (appels à `/settings/security/totp/enable` puis `/settings/security/totp/verify`) | |
| 194 | +- [ ] **Blocage navigation** : le middleware backend redirige toutes les routes protégées vers `/settings/security?force_2fa=true` — votre frontend doit gérer cette redirection (soit via une page dédiée, soit en interceptant le `302`) | |
| 195 | +- [ ] **Déconnexion** : offrir à l'utilisateur la possibilité de se déconnecter (`POST /api/v1/auth/logout`) plutôt que de configurer le 2FA immédiatement | |
| 196 | + | |
| 197 | +### Optionnel | |
| 198 | + | |
| 199 | +- [ ] **Bandeau d'alerte** : afficher un message expliquant pourquoi la navigation est bloquée | |
| 200 | +- [ ] **Traductions** : si votre app est multilingue, traduire le message de forçage | |
| 201 | + | |
| 202 | +### Pour les projets utilisant rustwarden-ui | |
| 203 | + | |
| 204 | +- [ ] Rien à faire — tout est intégré (bandeau, redirection, bouton de déconnexion, traductions) | |
| 205 | + | |
| 206 | +--- | |
| 207 | + | |
| 208 | +## Fichiers modifiés | |
| 209 | + | |
| 210 | +| Fichier | Modification | | |
| 211 | +|---------|-------------| | |
| 212 | +| `crates/rustwarden-core/src/auth/handlers.rs` | Champ `must_setup_2fa` dans `UserInfo` + check `totp_force_enabled` au login | | |
| 213 | +| `crates/rustwarden-core/src/middleware/auth_service.rs` | Routes protégées élargies (`/settings`, `/admin`) + redirection 2FA forcé | | |
| 214 | +| `crates/rustwarden-ui/src/handlers/totp_handler.rs` | Champs `force_2fa`, `force_2fa_message`, `force_2fa_logout` dans le template | | |
| 215 | +| `crates/rustwarden-ui/templates/settings/security.html` | Bandeau d'alerte conditionnel avec bouton déconnexion | | |
| 216 | +| `crates/rustwarden-ui/templates/login.html` | Redirection `must_setup_2fa` après login | | |
| 217 | +| `crates/rustwarden-ui/locales/{fr,en,de,es,it,pt}.yml` | Clés `force_2fa_message` et `force_2fa_logout` | | |
| 218 | + | |
| 219 | +--- | |
| 220 | + | |
| 221 | +## Questions fréquentes | |
| 222 | + | |
| 223 | +**Q : Un administrateur est-il aussi soumis au forçage du 2FA ?** | |
| 224 | +Oui. Le setting `totp_force_enabled` s'applique à tous les utilisateurs sans distinction de rôle. Un administrateur sans TOTP sera redirigé vers `/settings/security` comme les autres. | |
| 225 | + | |
| 226 | +**Q : Que se passe-t-il si l'admin désactive `totp_force_enabled` après coup ?** | |
| 227 | +Les utilisateurs qui n'ont pas configuré le 2FA retrouvent immédiatement une navigation normale. Ceux qui l'avaient déjà configuré gardent leur 2FA actif (il n'est pas désactivé automatiquement). | |
| 228 | + | |
| 229 | +**Q : L'utilisateur peut-il naviguer vers `/api/v1/auth/logout` pour se déconnecter ?** | |
| 230 | +Oui. Toutes les routes sous `/api/v1/auth/*` sont exemptées du blocage, y compris le logout. | |
| 231 | + | |
| 232 | +**Q : Le check `totp_force_enabled` ajoute-t-il une requête BDD à chaque page ?** | |
| 233 | +Oui, une requête `app_settings` + une requête `user_totp` sont exécutées par le middleware pour chaque route protégée quand le setting est activé. Ces requêtes sont légères (lookup par clé primaire). Si la performance est critique, un cache en mémoire peut être envisagé ultérieurement. |
A
docs/CHANGELOG_SETTINGS_SPLIT.md
+85
-0
@@ -0,0 +1,85 @@
| 1 | +# Settings handler split — breaking change | |
| 2 | + | |
| 3 | +> **Date** : 2026-04-24 | |
| 4 | +> **Scope** : `rustwarden-ui` — reorganisation interne des handlers `/settings/*` | |
| 5 | +> **Comportement runtime** : inchange (memes routes HTTP, memes responses) | |
| 6 | +> **Breaking change** : oui, pour les apps consommatrices qui importent directement des symboles de `rustwarden_ui::handlers::settings_handler` | |
| 7 | + | |
| 8 | +## Contexte | |
| 9 | + | |
| 10 | +Le fichier `crates/rustwarden-ui/src/handlers/settings_handler.rs` grossit de facon continue (profil utilisateur, changement de mot de passe, sessions actives, preferences, 2FA...). Apres la branche `AddTeams`, il atteignait ~1673 lignes — difficile a relire, et chaque evolution touchait un fichier monolithique. | |
| 11 | + | |
| 12 | +Cette modification repartit les handlers par **domaine fonctionnel** dans un sous-module : | |
| 13 | + | |
| 14 | +``` | |
| 15 | +crates/rustwarden-ui/src/handlers/ | |
| 16 | +├── settings/ | |
| 17 | +│ ├── mod.rs # NavItem, build_nav_items_from_dto, filters Askama partages | |
| 18 | +│ ├── profile.rs # profil utilisateur (username, email, avatar) | |
| 19 | +│ ├── security.rs # mot de passe, sessions actives, entree 2FA | |
| 20 | +│ └── preferences.rs # locale, theme, notifications | |
| 21 | +``` | |
| 22 | + | |
| 23 | +Le fichier `settings_handler.rs` est supprime. | |
| 24 | + | |
| 25 | +## Migration des imports | |
| 26 | + | |
| 27 | +Les apps consommatrices qui importent directement des symboles doivent adapter leurs `use`. | |
| 28 | + | |
| 29 | +| Symbole | Ancien chemin | Nouveau chemin | | |
| 30 | +|---------|---------------|----------------| | |
| 31 | +| `update_profile` | `rustwarden_ui::handlers::settings_handler::update_profile` | `rustwarden_ui::handlers::settings::profile::update_profile` | | |
| 32 | +| `get_profile_page` | `rustwarden_ui::handlers::settings_handler::get_profile_page` | `rustwarden_ui::handlers::settings::profile::get_profile_page` | | |
| 33 | +| `UpdateProfileRequest` | `rustwarden_ui::handlers::settings_handler::UpdateProfileRequest` | `rustwarden_ui::handlers::settings::profile::UpdateProfileRequest` | | |
| 34 | +| `ProfileTemplate` | `rustwarden_ui::handlers::settings_handler::ProfileTemplate` | `rustwarden_ui::handlers::settings::profile::ProfileTemplate` | | |
| 35 | +| `change_password` | `rustwarden_ui::handlers::settings_handler::change_password` | `rustwarden_ui::handlers::settings::security::change_password` | | |
| 36 | +| `confirm_password_change` | `rustwarden_ui::handlers::settings_handler::confirm_password_change` | `rustwarden_ui::handlers::settings::security::confirm_password_change` | | |
| 37 | +| `cancel_pending_password_change` | `rustwarden_ui::handlers::settings_handler::cancel_pending_password_change` | `rustwarden_ui::handlers::settings::security::cancel_pending_password_change` | | |
| 38 | +| `list_sessions` | `rustwarden_ui::handlers::settings_handler::list_sessions` | `rustwarden_ui::handlers::settings::security::list_sessions` | | |
| 39 | +| `revoke_session` | `rustwarden_ui::handlers::settings_handler::revoke_session` | `rustwarden_ui::handlers::settings::security::revoke_session` | | |
| 40 | +| `get_password_page` | `rustwarden_ui::handlers::settings_handler::get_password_page` | `rustwarden_ui::handlers::settings::security::get_password_page` | | |
| 41 | +| `get_sessions_page` | `rustwarden_ui::handlers::settings_handler::get_sessions_page` | `rustwarden_ui::handlers::settings::security::get_sessions_page` | | |
| 42 | +| `ChangePasswordRequest` | `rustwarden_ui::handlers::settings_handler::ChangePasswordRequest` | `rustwarden_ui::handlers::settings::security::ChangePasswordRequest` | | |
| 43 | +| `ChangePasswordResponse` | `rustwarden_ui::handlers::settings_handler::ChangePasswordResponse` | `rustwarden_ui::handlers::settings::security::ChangePasswordResponse` | | |
| 44 | +| `ActiveSessionsResponse` | `rustwarden_ui::handlers::settings_handler::ActiveSessionsResponse` | `rustwarden_ui::handlers::settings::security::ActiveSessionsResponse` | | |
| 45 | +| `PasswordPageQuery` | `rustwarden_ui::handlers::settings_handler::PasswordPageQuery` | `rustwarden_ui::handlers::settings::security::PasswordPageQuery` | | |
| 46 | +| `ConfirmPasswordChangeQuery` | `rustwarden_ui::handlers::settings_handler::ConfirmPasswordChangeQuery` | `rustwarden_ui::handlers::settings::security::ConfirmPasswordChangeQuery` | | |
| 47 | +| `PasswordTemplate` | `rustwarden_ui::handlers::settings_handler::PasswordTemplate` | `rustwarden_ui::handlers::settings::security::PasswordTemplate` | | |
| 48 | +| `SessionsTemplate` | `rustwarden_ui::handlers::settings_handler::SessionsTemplate` | `rustwarden_ui::handlers::settings::security::SessionsTemplate` | | |
| 49 | +| `get_preferences` | `rustwarden_ui::handlers::settings_handler::get_preferences` | `rustwarden_ui::handlers::settings::preferences::get_preferences` | | |
| 50 | +| `update_preferences` | `rustwarden_ui::handlers::settings_handler::update_preferences` | `rustwarden_ui::handlers::settings::preferences::update_preferences` | | |
| 51 | +| `get_preferences_page` | `rustwarden_ui::handlers::settings_handler::get_preferences_page` | `rustwarden_ui::handlers::settings::preferences::get_preferences_page` | | |
| 52 | +| `UpdatePreferencesRequest` | `rustwarden_ui::handlers::settings_handler::UpdatePreferencesRequest` | `rustwarden_ui::handlers::settings::preferences::UpdatePreferencesRequest` | | |
| 53 | +| `PreferencesTemplate` | `rustwarden_ui::handlers::settings_handler::PreferencesTemplate` | `rustwarden_ui::handlers::settings::preferences::PreferencesTemplate` | | |
| 54 | + | |
| 55 | +## Ce qui n'a PAS change | |
| 56 | + | |
| 57 | +- **Les routes HTTP** : `/profile`, `/settings/password`, `/settings/sessions`, `/settings/preferences`, `/settings/security/*` sont toutes preservees, memes methodes, memes responses. | |
| 58 | +- **Les templates Askama** : `templates/settings/*.html` sont inchanges. | |
| 59 | +- **Le comportement** : aucune logique metier modifiee, aucune regression fonctionnelle attendue. Tests existants passent tels quels. | |
| 60 | +- **Les handlers TOTP / 2FA** : deja dans `handlers/totp_handler.rs`, hors du champ de cette reorganisation. | |
| 61 | + | |
| 62 | +## Comment adapter | |
| 63 | + | |
| 64 | +Si votre app consommatrice n'importe **aucun** symbole de `settings_handler` directement (cas le plus frequent, vous utilisez juste `rustwarden_ui::default_routes`), **il n'y a rien a faire**. Le serveur continue de servir les memes pages. | |
| 65 | + | |
| 66 | +Sinon, recherche-remplace global dans votre code : | |
| 67 | + | |
| 68 | +``` | |
| 69 | +settings_handler:: → settings::profile:: # pour profil | |
| 70 | +settings_handler:: → settings::security:: # pour password/sessions | |
| 71 | +settings_handler:: → settings::preferences:: # pour theme/locale | |
| 72 | +``` | |
| 73 | + | |
| 74 | +Ou bien, pour un filet de compatibilite local temporaire dans votre app : | |
| 75 | + | |
| 76 | +```rust | |
| 77 | +// Compat shim local — a retirer une fois tous vos imports adaptes | |
| 78 | +pub use rustwarden_ui::handlers::settings::preferences; | |
| 79 | +pub use rustwarden_ui::handlers::settings::profile; | |
| 80 | +pub use rustwarden_ui::handlers::settings::security; | |
| 81 | +``` | |
| 82 | + | |
| 83 | +## Pourquoi ce split maintenant | |
| 84 | + | |
| 85 | +Decide pendant la resolution de dette technique post-audit `AddTeams` (voir [docs/plans/2026-04-24-post-audit-addteams-debt.md](plans/2026-04-24-post-audit-addteams-debt.md)). Le fichier monolithique etait identifie comme dette prioritaire : chaque nouvelle fonctionnalite (2FA, Teams, preferences etendues) l'agrandissait sans que les domaines soient clairement separes. Le Product Owner a retenu l'option "Un sous-module par section (dossier)" pour preparer les futures extensions (notifications, webhooks...) sans toucher le meme fichier. |
A
docs/RUSTWARDEN_EVOLUTION_PROCESS.md
+434
-0
@@ -0,0 +1,434 @@
| 1 | +# Rustwarden-core — Processus generique d'evolution du framework | |
| 2 | + | |
| 3 | +Ce document definit une methodologie **agnostique de l'application consommatrice** pour proposer, evaluer, livrer et retirer des changements sur le framework `rustwarden-core`. Il s'adresse a la fois aux mainteneurs upstream et aux apps downstream (gitrust aujourd'hui, d'autres a venir). | |
| 4 | + | |
| 5 | +**Non-buts** : | |
| 6 | +- Ce n'est pas un catalogue des APIs actuelles (cf `README.md` du framework). | |
| 7 | +- Ce n'est pas le backlog des improvements specifiques a gitrust (cf [`UPSTREAM_RUSTWARDEN.md`](UPSTREAM_RUSTWARDEN.md)). | |
| 8 | +- Ce n'est pas un guide utilisateur (cf `docs/SERVICES_REFERENCE.md`). | |
| 9 | + | |
| 10 | +Le document vit dans gitrust parce que c'est actuellement le seul consommateur, mais son contenu doit rester generique. Quand un deuxieme consommateur apparait, ce fichier doit migrer dans le depot du framework (ou ses copies restent synchronisees via un simple `git diff`). | |
| 11 | + | |
| 12 | +--- | |
| 13 | + | |
| 14 | +## 1. Principes directeurs | |
| 15 | + | |
| 16 | +1. **SemVer strict** : tout changement d'API publique respecte `MAJOR.MINOR.PATCH`. Un renommage public, un ajout de parametre obligatoire, une modification du comportement par defaut sont **breaking** meme si le code compile. | |
| 17 | +2. **Backwards-compat par defaut** : quand deux solutions techniques sont equivalentes, on choisit celle qui preserve les signatures existantes. Une nouvelle signature vit a cote de l'ancienne, l'ancienne est `#[deprecated]` sur au moins 3 mineures avant suppression. | |
| 18 | +3. **Lowest common denominator** : le framework ne doit pas embarquer de logique specifique a une app. Si une feature n'a qu'un seul consommateur identifie, elle ne rentre pas dans le framework — elle reste dans l'app ou dans une extension. | |
| 19 | +4. **Zero-surprise** : un changement qui laisse silencieusement casser un flux existant (ex: token vide qui genere 403 sans erreur lisible) est bloquant jusqu'a clarification. | |
| 20 | +5. **Test en tant que documentation** : tout nouvel API publique est livree avec un exemple dans la rustdoc, execute comme doctest. | |
| 21 | +6. **Multi-tenant neutre** : le framework ne suppose ni une app SSR, ni une app SPA, ni une app API-only. Chaque API doit soit fonctionner dans les 3 contextes, soit etre clairement scopee (ex: feature flag). | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 2. Quand une RFC est obligatoire | |
| 26 | + | |
| 27 | +Une RFC (Request For Comments — un document formalise suivant le template section 3) est **obligatoire** pour tout changement qui remplit au moins un critere : | |
| 28 | + | |
| 29 | +- [x] Ajout, modification ou suppression d'un item `pub` exporte par le crate. | |
| 30 | +- [x] Changement d'un comportement par defaut (y compris backwards-compat, car les consommateurs peuvent dependre du defaut). | |
| 31 | +- [x] Ajout d'une nouvelle dependance directe dans `Cargo.toml`. | |
| 32 | +- [x] Introduction d'un nouveau middleware, service ou trait que plusieurs apps vont consommer. | |
| 33 | +- [x] Changement du schema DB ou ajout d'une migration. | |
| 34 | +- [x] Modification du format serialise (cookies, JWT claims, events JSON, metadata). | |
| 35 | +- [x] Changement de la politique de securite (ex: nouveau hash, nouveau flow d'auth, nouveau mode CSRF). | |
| 36 | + | |
| 37 | +**Pas de RFC** (PR direct suffit) : | |
| 38 | + | |
| 39 | +- Fix d'un bug sans changement d'API. | |
| 40 | +- Mise a jour de la doc ou du CHANGELOG. | |
| 41 | +- Refactor interne preservant toutes les signatures publiques. | |
| 42 | +- Bump de version d'une dependance en patch/minor. | |
| 43 | +- Ajout de test. | |
| 44 | + | |
| 45 | +En cas de doute : ecrire la RFC. Le cout d'une RFC prematuree est tres faible ; le cout d'un breaking change accidentel est tres eleve. | |
| 46 | + | |
| 47 | +--- | |
| 48 | + | |
| 49 | +## 3. Template RFC | |
| 50 | + | |
| 51 | +Chaque proposition est un fichier unique `docs/rfcs/NNNN-slug.md` dans le depot du framework. `NNNN` est un numero incremental (0001, 0002, ...), `slug` est un identifiant court kebab-case. | |
| 52 | + | |
| 53 | +```markdown | |
| 54 | +# RFC NNNN — <Titre court> | |
| 55 | + | |
| 56 | +- **Auteur** : <nom + repo d'origine> | |
| 57 | +- **Date** : YYYY-MM-DD | |
| 58 | +- **Statut** : Draft | Under Review | Accepted | Rejected | Superseded by #MMMM | |
| 59 | +- **Apps consommatrices concernees** : <liste des apps qui beneficient directement> | |
| 60 | + | |
| 61 | +## Summary (1 paragraphe) | |
| 62 | + | |
| 63 | +Que propose-t-on ? En une phrase qu'un dev non-specialiste doit pouvoir comprendre. | |
| 64 | + | |
| 65 | +## Motivation | |
| 66 | + | |
| 67 | +- Quel probleme concret cette RFC resout-elle ? | |
| 68 | +- Qui est impacte aujourd'hui (apps consommatrices, cas d'usage) ? | |
| 69 | +- Quelles sont les consequences si on ne fait rien ? | |
| 70 | +- Y a-t-il un delai/urgence (incident securite, deadline upstream) ? | |
| 71 | + | |
| 72 | +## Non-goals (explicite) | |
| 73 | + | |
| 74 | +Lister ce qui est **hors scope** pour eviter le scope creep. Ex: "cette RFC ne touche pas au flow OAuth2, qui est traite dans la RFC #XXXX". | |
| 75 | + | |
| 76 | +## API proposee | |
| 77 | + | |
| 78 | +Code Rust complet des nouvelles signatures publiques. Exemple d'utilisation cote consommateur (copie/collable dans un handler/service d'une app fictive). | |
| 79 | + | |
| 80 | +```rust | |
| 81 | +// Signature complete | |
| 82 | +pub fn new_api(...) -> ... { ... } | |
| 83 | + | |
| 84 | +// Exemple d'usage | |
| 85 | +async fn handler(...) -> Response { | |
| 86 | + let result = new_api(...)?; | |
| 87 | + // ... | |
| 88 | +} | |
| 89 | +``` | |
| 90 | + | |
| 91 | +## Analyse de compatibilite | |
| 92 | + | |
| 93 | +Checklist : | |
| 94 | + | |
| 95 | +- [ ] API existante non modifiee → **non-breaking** (patch/minor possible). | |
| 96 | +- [ ] API existante etendue avec defaut → **non-breaking** (minor). | |
| 97 | +- [ ] API existante modifiee ou supprimee → **breaking** (major requis + guide migration). | |
| 98 | +- [ ] Nouveau behavior par defaut → evaluer l'impact sur les consommateurs, feature-flagger si risque. | |
| 99 | +- [ ] Changement de schema DB → prevoir une migration reversible. | |
| 100 | +- [ ] Changement de format serialise → prevoir un migrateur de donnees persistantes existantes. | |
| 101 | + | |
| 102 | +## Alternatives considerees | |
| 103 | + | |
| 104 | +**Minimum 3 alternatives** avec analyse pros/cons, y compris "ne rien faire". Sans ca la RFC est incomplete : on ne peut pas juger une proposition sans son cout relatif. | |
| 105 | + | |
| 106 | +### Alternative A (recommandee) : ... | |
| 107 | +**Pros** : ... | |
| 108 | +**Cons** : ... | |
| 109 | + | |
| 110 | +### Alternative B : ... | |
| 111 | +**Pros** : ... | |
| 112 | +**Cons** : ... | |
| 113 | + | |
| 114 | +### Alternative C (ne rien faire / garder le workaround) : ... | |
| 115 | +**Pros** : zero cout, zero risque. | |
| 116 | +**Cons** : dette accumulee dans les apps consommatrices, risque de divergence. | |
| 117 | + | |
| 118 | +## Testing plan | |
| 119 | + | |
| 120 | +Tests unitaires obligatoires : | |
| 121 | +- Happy path (au moins 1). | |
| 122 | +- 3 cas d'erreur minimum (input invalide, contrainte violee, timeout/panic path). | |
| 123 | + | |
| 124 | +Tests d'integration si la RFC touche : | |
| 125 | +- Un service : test avec DB in-memory. | |
| 126 | +- Un middleware : test avec client axum fake. | |
| 127 | +- Une migration : test up/down avec verif des donnees existantes. | |
| 128 | + | |
| 129 | +Tests de non-regression : | |
| 130 | +- Code consommateur pre-RFC doit encore compiler sans warning. | |
| 131 | + | |
| 132 | +Benchs si sensitive-perf : | |
| 133 | +- Comparaison avant/apres. | |
| 134 | +- Regression > 10% bloque le merge sans justification ecrite. | |
| 135 | + | |
| 136 | +## Migration guide | |
| 137 | + | |
| 138 | +Section a copier telle quelle dans le `CHANGELOG.md` au moment du merge. Format : | |
| 139 | + | |
| 140 | +```markdown | |
| 141 | +### API changes | |
| 142 | + | |
| 143 | +**Before (vX.Y.Z)** : | |
| 144 | +```rust | |
| 145 | +old_api(foo, bar); | |
| 146 | +``` | |
| 147 | + | |
| 148 | +**After (vX'.Y'.Z')** : | |
| 149 | +```rust | |
| 150 | +new_api(foo, bar, NewConfig::default()); | |
| 151 | +``` | |
| 152 | + | |
| 153 | +### Migration steps | |
| 154 | + | |
| 155 | +1. Bump `Cargo.toml` a vX'.Y'.Z'. | |
| 156 | +2. `cargo fix --edition-idioms` handles most of it. | |
| 157 | +3. Manual: pour chaque appel a `old_api`, decider si `NewConfig::default()` convient. | |
| 158 | +``` | |
| 159 | + | |
| 160 | +## Open questions | |
| 161 | + | |
| 162 | +Liste des points en suspens. La RFC ne peut pas etre acceptee tant qu'ils ne sont pas resolus : | |
| 163 | + | |
| 164 | +- [ ] Question 1 | |
| 165 | +- [ ] Question 2 | |
| 166 | + | |
| 167 | +## Tracking issue | |
| 168 | + | |
| 169 | +Lien vers l'issue GitHub ou la RFC est discutee. | |
| 170 | +``` | |
| 171 | + | |
| 172 | +--- | |
| 173 | + | |
| 174 | +## 4. Grille d'evaluation | |
| 175 | + | |
| 176 | +Une fois la RFC deposee, elle est notee sur 7 criteres (1-5 chacun, 35 max). Minimum **25/35** pour passer en "Accepted" : | |
| 177 | + | |
| 178 | +| Critere | 1 (mauvais) | 3 (neutre) | 5 (excellent) | | |
| 179 | +|---------|-------------|------------|---------------| | |
| 180 | +| **Generalite** | Sert 1 seul consommateur, logique app-specifique | Sert au moins 1 consommateur et anticipe >=1 autre | Resout un besoin architectural commun a toute app d'auth | | |
| 181 | +| **Stabilite API** | Breaking change necessaire | Non-breaking avec feature flag | Non-breaking par ajout pur | | |
| 182 | +| **Performance** | Overhead mesure > 5% sur hot path | Overhead imperceptible | Amelioration mesurable | | |
| 183 | +| **Securite** | Regression possible sans mitigation | Neutre vs l'existant | Hardening net (fix faille, defense-in-depth) | | |
| 184 | +| **Complexite impl** | > 500 lignes, requiert unsafe/refactor majeur | < 200 lignes, patterns existants reutilises | < 50 lignes, trivial | | |
| 185 | +| **Charge maintenance** | Dependance externe non maintenue, cas edge nombreux | Normale (1 mainteneur peut gerer) | Zero : pure composition de briques stables | | |
| 186 | +| **Documentation** | Pas d'exemple, pas de rustdoc, pas de CHANGELOG | Rustdoc + exemple inline | Rustdoc + exemple + entree CHANGELOG + guide migration | | |
| 187 | + | |
| 188 | +Un score < 25 n'est pas automatiquement un rejet : c'est un signal qu'il faut reecrire la RFC ou choisir une alternative. Un score >= 30 peut accelerer le merge sans second avis. | |
| 189 | + | |
| 190 | +--- | |
| 191 | + | |
| 192 | +## 5. Strategie de versioning | |
| 193 | + | |
| 194 | +### Feature flags pour APIs experimentales | |
| 195 | + | |
| 196 | +Toute nouvelle API introduite pour un besoin emergent (ex: seul 1 consommateur identifie) vit derriere un feature flag Cargo : | |
| 197 | + | |
| 198 | +```toml | |
| 199 | +# rustwarden-core/Cargo.toml | |
| 200 | +[features] | |
| 201 | +default = ["core"] | |
| 202 | +core = [] | |
| 203 | +experimental-ssr-forms = [] # CSRF form body parsing | |
| 204 | +experimental-oauth-github = [] # Provider GitHub OAuth | |
| 205 | +``` | |
| 206 | + | |
| 207 | +Cycle de vie d'un feature flag : | |
| 208 | + | |
| 209 | +1. **Birth (minor release)** : la feature sort en `experimental-*`, non incluse par defaut. Les consommateurs opt-in dans leur `Cargo.toml`. | |
| 210 | +2. **Adoption** : suivi dans le CHANGELOG — "X apps ont active `experimental-ssr-forms`". | |
| 211 | +3. **Promotion (minor release)** : apres **2+ apps adoptantes** et **30 jours sans bug critique**, la feature est promue en `default`. Les deux noms (`experimental-*` et nom final) coexistent pendant 1 minor pour la migration. | |
| 212 | +4. **Cleanup (major release)** : le flag `experimental-*` est supprime. | |
| 213 | + | |
| 214 | +Pas de feature pour quelque chose que tout le monde voudrait : directement en default. | |
| 215 | + | |
| 216 | +### Depreciation | |
| 217 | + | |
| 218 | +```rust | |
| 219 | +#[deprecated( | |
| 220 | + since = "0.8.0", | |
| 221 | + note = "use `csrf_protection_with_config` instead (accepts form body). Will be removed in 1.0." | |
| 222 | +)] | |
| 223 | +pub async fn csrf_protection(...) -> ... { ... } | |
| 224 | +``` | |
| 225 | + | |
| 226 | +Regles : | |
| 227 | + | |
| 228 | +- **Duree minimale** : N mineures depuis la depreciation (N >= 3). Ex : deprecie en 0.8.0 → supprimable a partir de 1.1.0 minimum. | |
| 229 | +- **Note obligatoire** : pointer explicitement vers le remplacement avec exemple. | |
| 230 | +- **CHANGELOG** : entree "Deprecated" dans chaque release entre depreciation et suppression. | |
| 231 | +- **Tests** : les tests de l'ancienne API continuent de tourner jusqu'a suppression (avec `#[allow(deprecated)]`). | |
| 232 | + | |
| 233 | +### Breaking changes | |
| 234 | + | |
| 235 | +Aggreges dans une **release major uniquement** : | |
| 236 | + | |
| 237 | +- Pas de breaking dans une patch (`x.y.Z+1`). | |
| 238 | +- Pas de breaking dans une minor (`x.Y+1.0`). | |
| 239 | +- Une seule major par 6-12 mois, regroupant plusieurs depreciations echues + nouveaux defauts. | |
| 240 | + | |
| 241 | +Chaque major est accompagnee d'un guide migration exhaustif (fichier `MIGRATION-VX.md`) avec tous les changements et leurs remediations. | |
| 242 | + | |
| 243 | +--- | |
| 244 | + | |
| 245 | +## 6. Garanties de compatibilite | |
| 246 | + | |
| 247 | +### Stables (part du contrat public) | |
| 248 | + | |
| 249 | +- Tous les items `pub` exportes par le `lib.rs` du crate. | |
| 250 | +- Signatures des handlers (`services/*::*`). | |
| 251 | +- Colonnes et types des tables de migration (ajout de colonne optionnel OK, changement ou suppression = breaking). | |
| 252 | +- Format serialise des cookies de session, JWT claims, events JSON. | |
| 253 | +- Noms des variantes d'erreur (kind `AppError::NotFound` par exemple). | |
| 254 | +- Codes HTTP retournes par les handlers "happy path". | |
| 255 | + | |
| 256 | +### Non-stables (peuvent changer en minor) | |
| 257 | + | |
| 258 | +- Items exportes dans `pub(crate)` ou derriere `pub` mais dans des modules `internal/*`, `utils/*`, `impl/*`. | |
| 259 | +- Format des logs `tracing` (tags, niveaux). | |
| 260 | +- Ordre des champs dans les erreurs (mais pas leur presence). | |
| 261 | +- Parametres par defaut non documentes comme API publique. | |
| 262 | + | |
| 263 | +**Quand c'est ambigu** : le merge suppose que c'est stable. Un refactor pretendu "interne" mais visible par les consommateurs compte comme breaking. | |
| 264 | + | |
| 265 | +--- | |
| 266 | + | |
| 267 | +## 7. Exigences de tests | |
| 268 | + | |
| 269 | +Chaque PR implementant une RFC doit livrer : | |
| 270 | + | |
| 271 | +| Categorie | Obligatoire ? | Contenu minimum | | |
| 272 | +|-----------|---------------|-----------------| | |
| 273 | +| **Unit** | Oui | Happy path + 3 cas d'erreur par fonction publique | | |
| 274 | +| **Doctest** | Oui | Au moins 1 exemple copie/collable par item publique | | |
| 275 | +| **Integration** | Si touche service/middleware | Test avec DB in-memory (sea-orm mock ou sqlite) ou client axum fake | | |
| 276 | +| **Compat** | Si touche API existante | Le code consommateur pre-RFC compile sans warning nouveau | | |
| 277 | +| **Bench** | Si touche hot path | Comparison avant/apres, regression > 10% = justification ecrite | | |
| 278 | +| **Fuzz** | Si touche parsing/deserialisation | `cargo fuzz` round pendant 10 min sur l'input exterieur | | |
| 279 | + | |
| 280 | +Les consommateurs (gitrust, autres) peuvent etre invites a tourner leur propre CI contre la version RC du framework avant merge — c'est un "compat test" tiers gratuit. | |
| 281 | + | |
| 282 | +--- | |
| 283 | + | |
| 284 | +## 8. Exigences de documentation | |
| 285 | + | |
| 286 | +Tout merge inclut : | |
| 287 | + | |
| 288 | +1. **Rustdoc** sur chaque nouvel item public, avec exemple testable. | |
| 289 | +2. **Entree CHANGELOG.md** dans la categorie correcte (`Added` / `Changed` / `Deprecated` / `Removed` / `Fixed` / `Security`). | |
| 290 | +3. **Mise a jour du `README.md`** si la feature etend les cas d'usage documentes. | |
| 291 | +4. **Guide de migration** (dans le CHANGELOG ou fichier dedie) si la feature remplace une API existante. | |
| 292 | +5. **Exemple dans `examples/`** si la feature introduit un pattern non-trivial (middleware, service compose, flow async). | |
| 293 | + | |
| 294 | +Pour une app consommatrice, le reflet de ce merge est : | |
| 295 | +- Bump de version dans son `Cargo.toml`. | |
| 296 | +- Mise a jour de son propre fichier `UPSTREAM_<framework>.md` (ajout de la section "resolu upstream"). | |
| 297 | +- Eventuelle mise a jour des workarounds internes. | |
| 298 | + | |
| 299 | +--- | |
| 300 | + | |
| 301 | +## 9. Processus de release | |
| 302 | + | |
| 303 | +### Cadence | |
| 304 | + | |
| 305 | +| Type | Frequence | Contenu | | |
| 306 | +|------|-----------|---------| | |
| 307 | +| **Patch** `x.y.Z+1` | A la demande (bug ou securite) | Fixes sans API change, pas de nouvelle feature | | |
| 308 | +| **Minor** `x.Y+1.0` | Toutes les 2 semaines | Features non-breaking accumulees depuis la derniere minor | | |
| 309 | +| **Major** `X+1.0.0` | Tous les 6-12 mois | Depreciations echues, breaking changes, nouveaux defauts | | |
| 310 | + | |
| 311 | +### Checklist de release | |
| 312 | + | |
| 313 | +- [ ] `CHANGELOG.md` a jour avec toutes les entrees depuis la release precedente. | |
| 314 | +- [ ] Tests : `cargo test --all-features` passe. | |
| 315 | +- [ ] Lint : `cargo clippy --workspace --all-features -- -D warnings` passe. | |
| 316 | +- [ ] Doc : `cargo doc --no-deps` sans warning. | |
| 317 | +- [ ] Benchs : pas de regression > 10% non justifiee. | |
| 318 | +- [ ] Tag git annote : `v0.8.0`. | |
| 319 | +- [ ] GitHub Release avec les notes du CHANGELOG. | |
| 320 | +- [ ] `cargo publish` sur crates.io. | |
| 321 | +- [ ] Issue de tracking ouverte sur chaque app consommatrice listant les PRs d'adoption. | |
| 322 | + | |
| 323 | +### Notification des consommateurs | |
| 324 | + | |
| 325 | +A chaque release, un commentaire est poste sur : | |
| 326 | +- Le tracking issue upstream (liste des apps consommatrices). | |
| 327 | +- Le fichier `UPSTREAM_RUSTWARDEN.md` de chaque app (via PR automatique ou manuelle). | |
| 328 | + | |
| 329 | +Le consommateur est libre d'adopter la version a son rythme, **sauf pour les patches securite** ou une fenetre de 14 jours d'adoption est attendue. | |
| 330 | + | |
| 331 | +--- | |
| 332 | + | |
| 333 | +## 10. Strategie de rollback | |
| 334 | + | |
| 335 | +Si une release contient un bug critique detecte apres publication : | |
| 336 | + | |
| 337 | +1. **Sous 24h** : `cargo yank` sur la version defectueuse (empeche les nouvelles installs). | |
| 338 | +2. **Sous 48h** : patch release `x.y.Z+1` avec le fix, CHANGELOG clairement marque "Security" ou "Critical fix". | |
| 339 | +3. **Sous 7j** : post-mortem public sur le tracking issue upstream. Sections : contexte, impact, root cause, timeline, mesures pour eviter recurrence. | |
| 340 | + | |
| 341 | +Les consommateurs qui ont deja bumpe vers la version yanked doivent eux-memes bumper vers le patch — un message automatise dans leur `UPSTREAM_<framework>.md` les y invite. | |
| 342 | + | |
| 343 | +--- | |
| 344 | + | |
| 345 | +## 11. Guide des consommateurs | |
| 346 | + | |
| 347 | +Chaque app consommatrice (gitrust, futurs) est recommandee de : | |
| 348 | + | |
| 349 | +### 11.1 Pinning | |
| 350 | + | |
| 351 | +- Pendant alpha/beta : **pin exact** `rustwarden-core = "=0.8.3"`. Evite les auto-updates surprises. | |
| 352 | +- En production stable : **pin minor** `rustwarden-core = "~0.8.3"` (accepte 0.8.x, refuse 0.9.0). Migre a la main sur major. | |
| 353 | + | |
| 354 | +### 11.2 Fichier de tracking | |
| 355 | + | |
| 356 | +Chaque app maintient un `docs/UPSTREAM_<framework>.md` (cf [`UPSTREAM_RUSTWARDEN.md`](UPSTREAM_RUSTWARDEN.md) dans gitrust). Ce fichier liste : | |
| 357 | + | |
| 358 | +- Les services framework utilises (catalog). | |
| 359 | +- Les hooks implementes. | |
| 360 | +- Les workarounds en cours avec renvoi vers la RFC correspondante. | |
| 361 | +- Les breaking changes a venir a partir du changelog upstream. | |
| 362 | + | |
| 363 | +### 11.3 Contribution | |
| 364 | + | |
| 365 | +Quand un consommateur rencontre un gap : | |
| 366 | + | |
| 367 | +1. **Ecrire un workaround local** (comme [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) cote gitrust). | |
| 368 | +2. **Documenter dans son `UPSTREAM_<framework>.md`** : contexte, workaround, propositions pour le framework. | |
| 369 | +3. **Deposer une RFC upstream** des que le workaround dure > 1 release mineure — l'objectif est que le code du workaround finisse par disparaitre au profit d'une solution framework. | |
| 370 | +4. **Contribuer l'implementation** si possible. Le framework est plus attractif quand les gros consommateurs y contribuent, et l'app consommatrice beneficie d'une dette reduite. | |
| 371 | + | |
| 372 | +### 11.4 Anti-patterns | |
| 373 | + | |
| 374 | +A eviter cote consommateur : | |
| 375 | + | |
| 376 | +- **Forker le framework** : bloque l'adoption des fixes securite upstream. Toujours privilegier un workaround dans le crate de l'app. | |
| 377 | +- **Modifier `crates/rustwarden-core/*`** : idem, casse la synchronisation. Toute evolution passe par une RFC upstream. | |
| 378 | +- **Dupliquer une service du framework** dans l'app : dette certaine. Meme si le service upstream est incomplet, l'etendre via wrapper/trait impl plutot que le re-implementer. | |
| 379 | +- **Masquer les workarounds** : chaque contournement doit etre documente pour que la dette reste visible. | |
| 380 | + | |
| 381 | +--- | |
| 382 | + | |
| 383 | +## 12. Communication | |
| 384 | + | |
| 385 | +### Canaux officiels | |
| 386 | + | |
| 387 | +| Sujet | Canal | | |
| 388 | +|-------|-------| | |
| 389 | +| Discussion RFC | Issue GitHub upstream avec label `rfc` | | |
| 390 | +| Question usage | README + rustdoc ; en derniere instance issue label `question` | | |
| 391 | +| Bug report | Issue GitHub upstream avec label `bug` + reproducer minimal | | |
| 392 | +| Coordination consommateurs | Pull request sur `UPSTREAM_<framework>.md` de chaque app | | |
| 393 | + | |
| 394 | +### Synchro reguliere | |
| 395 | + | |
| 396 | +Une fois les consommateurs > 1, un sync mensuel entre mainteneurs upstream et consommateurs : | |
| 397 | +- Revue des RFCs ouvertes. | |
| 398 | +- Adoption prochaine (qui bumpe quand). | |
| 399 | +- Backlog partage. | |
| 400 | + | |
| 401 | +En attendant le second consommateur : la coordination se fait via ce document + `UPSTREAM_RUSTWARDEN.md` de gitrust. | |
| 402 | + | |
| 403 | +--- | |
| 404 | + | |
| 405 | +## 13. Exemples concrets | |
| 406 | + | |
| 407 | +Pour illustrer la methode, voici deux exemples tires du contexte actuel. | |
| 408 | + | |
| 409 | +### 13.1 Exemple RFC : CSRF compatible SSR form | |
| 410 | + | |
| 411 | +Le gap "middleware CSRF ne lit pas le form body" (cf [`UPSTREAM_RUSTWARDEN.md` section 1](UPSTREAM_RUSTWARDEN.md)) est un candidat type pour une RFC. La note technique est deja redigee, il suffit de la formaliser : | |
| 412 | + | |
| 413 | +- **Summary** : ajouter `accept_form_body: bool` dans une config optionnelle de `csrf_protection`. | |
| 414 | +- **Motivation** : toutes les apps SSR futures auront le meme probleme. | |
| 415 | +- **Alternatives** : (A) config + middleware etendu, (B) helper `extract_csrf_from_form`, (C) feature flag. | |
| 416 | +- **Migration** : gitrust bump + suppression de `admin_csrf_protection`. | |
| 417 | + | |
| 418 | +Score prevu : Generalite=5, Stabilite=5 (additif), Perf=4, Secu=5, Complexite=3, Maintenance=4, Doc=5 → 31/35. Accepte direct. | |
| 419 | + | |
| 420 | +### 13.2 Exemple NON-RFC : fix typo dans un message d'erreur | |
| 421 | + | |
| 422 | +Correction de "Invalidd token" → "Invalid token" dans un `AppError`. PR direct, changelog entry "Fixed". Pas de RFC necessaire. | |
| 423 | + | |
| 424 | +--- | |
| 425 | + | |
| 426 | +## 14. Revisions de ce document | |
| 427 | + | |
| 428 | +Ce document lui-meme evolue. Chaque changement majeur est trace : | |
| 429 | + | |
| 430 | +| Date | Auteur | Changement | | |
| 431 | +|------|--------|------------| | |
| 432 | +| 2026-04-21 | EBO (gitrust) | Creation initiale, inspire du processus observe lors de l'iteration CSRF ssh-guard. | | |
| 433 | + | |
| 434 | +Pour proposer une modification : PR directe sur ce fichier, revue par au moins un mainteneur framework et un mainteneur consommateur. |
M
docs/SERVICES_REFERENCE.md
+480
-48
@@ -17,13 +17,18 @@
| 17 | 17 | 6. [Mots de passe](#6-mots-de-passe) |
| 18 | 18 | 7. [Tokens et sessions](#7-tokens-et-sessions) |
| 19 | 19 | 8. [Ressources et partage](#8-ressources-et-partage) |
| 20 | -9. [Configuration applicative](#9-configuration-applicative) | |
| 21 | -10. [Preferences utilisateur](#10-preferences-utilisateur) | |
| 22 | -11. [Email](#11-email) | |
| 23 | -12. [Chiffrement](#12-chiffrement) | |
| 24 | -13. [OAuth](#13-oauth-feature-oauth) | |
| 25 | -14. [Utilitaires](#14-utilitaires) | |
| 26 | -15. [Patterns communs](#15-patterns-communs) | |
| 20 | +9. [Teams (groupes d'utilisateurs)](#9-teams-groupes-dutilisateurs) | |
| 21 | +10. [Configuration applicative](#10-configuration-applicative) | |
| 22 | +11. [Preferences utilisateur](#11-preferences-utilisateur) | |
| 23 | +12. [Email](#12-email) | |
| 24 | +13. [Chiffrement](#13-chiffrement) | |
| 25 | +14. [OAuth](#14-oauth-feature-oauth) | |
| 26 | +15. [Rate limiting](#15-rate-limiting) | |
| 27 | +16. [Notifications temps-reel et SSE](#16-notifications-temps-reel-et-sse) | |
| 28 | +17. [Hooks lifecycle et audit](#17-hooks-lifecycle-et-audit) | |
| 29 | +18. [Middleware (CSRF, auth, rate limit)](#18-middleware-csrf-auth-rate-limit) | |
| 30 | +19. [Utilitaires](#19-utilitaires) | |
| 31 | +20. [Patterns communs](#20-patterns-communs) | |
| 27 | 32 | |
| 28 | 33 | --- |
| 29 | 34 |
@@ -43,14 +48,20 @@ Handler (axum) → Service (logique metier) → SeaORM (base de donnees)
| 43 | 48 | - Aucun SQL brut — tout passe par SeaORM. |
| 44 | 49 | |
| 45 | 50 | **Services exportes dans le prelude** (`use rustwarden_core::prelude::*`) : |
| 46 | -- `UserService`, `RoleService`, `PermissionService`, `ResourceService` | |
| 47 | -- `AuthUser`, `Claims`, `JwtService` | |
| 48 | -- `AppError`, `RustwardenHooks`, `RustwardenApp` | |
| 51 | +- Users / RBAC : `UserService`, `RoleService`, `PermissionService` | |
| 52 | +- Ressources : `ResourceService`, `Principal` | |
| 53 | +- Teams : `TeamService`, `TeamRole`, `TeamSlug` | |
| 54 | +- Tokens API : `PersonalTokenService`, `TokenScope` | |
| 55 | +- Auth : `AuthUser`, `Claims`, `JwtService` | |
| 56 | +- App : `AppError`, `RustwardenHooks`, `RustwardenApp` | |
| 49 | 57 | |
| 50 | 58 | Les autres services sont accessibles via leur chemin complet : |
| 51 | 59 | ```rust |
| 52 | 60 | use rustwarden_core::services::refresh_token_service::RefreshTokenService; |
| 53 | 61 | use rustwarden_core::services::app_settings_service::AppSettingsService; |
| 62 | +use rustwarden_core::services::rate_limit_service::RateLimitService; | |
| 63 | +use rustwarden_core::notifications::{NotificationBus, InMemoryBus, sse::sse_response}; | |
| 64 | +use rustwarden_core::middleware::csrf::{csrf_protection, csrf_protection_with_config, CsrfProtectionConfig}; | |
| 54 | 65 | // etc. |
| 55 | 66 | ``` |
| 56 | 67 |
@@ -279,9 +290,11 @@ ce qui evite de bloquer le runtime Tokio.
| 279 | 290 | |
| 280 | 291 | Flux de changement de mot de passe en deux etapes (demande + confirmation par email). |
| 281 | 292 | |
| 293 | +Rate limiting delegue a [`RateLimitService`](#15-rate-limiting) via `check_sliding_window("pwd_change:{user_id}", 3, 1h)` — retourne `AppError::TooManyRequests { retry_after_secs }` (HTTP 429 + header `Retry-After`) au-dela. | |
| 294 | + | |
| 282 | 295 | | Methode | Signature | Description | |
| 283 | 296 | |---------|-----------|-------------| |
| 284 | -| `create_pending_request` | `(&self, db, user_id: Uuid, new_password: &str) → Result<(Uuid, String)>` | Cree une demande pendante. Rate limited (3/heure/utilisateur). Retourne `(request_id, plain_token)`. | | |
| 297 | +| `create_pending_request` | `(&self, db, user_id: Uuid, new_password: &str) → Result<(Uuid, String)>` | Cree une demande pendante. Rate limited. Retourne `(request_id, plain_token)`. | | |
| 285 | 298 | | `confirm_request` | `(&self, db, token: &str) → Result<Uuid>` | Confirme le changement via le token email. Applique le nouveau hash et **revoque tous les refresh tokens** (SEC-M7). | |
| 286 | 299 | | `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). | |
| 287 | 300 | | `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. | |
@@ -298,7 +311,7 @@ Flux "Mot de passe oublie" (reset par email).
| 298 | 311 | |
| 299 | 312 | | Methode | Signature | Description | |
| 300 | 313 | |---------|-----------|-------------| |
| 301 | -| `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 (3/heure). | | |
| 314 | +| `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`. | | |
| 302 | 315 | | `validate_token` | `(&self, db, token: &str) → Result<Uuid>` | Valide un token de reset. Retourne le `user_id`. | |
| 303 | 316 | | `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). | |
| 304 | 317 |
@@ -366,6 +379,69 @@ Blacklist des JWT revoques (logout).
| 366 | 379 | | `cleanup_expired_tokens` | `(db) → Result<u64>` | Supprime les entrees expirees (tache de fond automatique). | |
| 367 | 380 | | `hash_jwt_token` | `(token: &str) → String` | Hash SHA-256 (utilitaire public). | |
| 368 | 381 | |
| 382 | +### `PersonalTokenService<S: TokenScope>` | |
| 383 | + | |
| 384 | +**Module** : `rustwarden_core::services::personal_token_service` | |
| 385 | +**Export** : prelude (`PersonalTokenService`, `TokenScope`) | |
| 386 | + | |
| 387 | +Tokens API longue-duree (Personal Access Tokens) pour protocoles qui ne peuvent pas supporter la rotation JWT (git push HTTPS, webhooks, integrations tierces). Complete — ne remplace pas — `RefreshTokenService`. | |
| 388 | + | |
| 389 | +**Securite** : | |
| 390 | +- Plaintext (32 octets random hex, 64 chars) retourne **une seule fois** a la creation, jamais persiste. | |
| 391 | +- Stockage uniquement en SHA-256 hex. | |
| 392 | +- Comparaison constant-time a la verification (`subtle::ConstantTimeEq`). | |
| 393 | +- Erreur uniforme `AppError::InvalidToken` sur tout rejet (pas de leak). | |
| 394 | + | |
| 395 | +**Generique sur un trait `TokenScope`** implemente par chaque app consommatrice : | |
| 396 | + | |
| 397 | +```rust | |
| 398 | +use rustwarden_core::prelude::{PersonalTokenService, TokenScope}; | |
| 399 | + | |
| 400 | +#[derive(Clone, Debug)] | |
| 401 | +enum RepoScope { Read, Write } | |
| 402 | + | |
| 403 | +impl TokenScope for RepoScope { | |
| 404 | + fn as_str(&self) -> &str { | |
| 405 | + match self { Self::Read => "read:repo", Self::Write => "write:repo" } | |
| 406 | + } | |
| 407 | + fn parse(s: &str) -> Option<Self> { | |
| 408 | + match s { | |
| 409 | + "read:repo" => Some(Self::Read), | |
| 410 | + "write:repo" => Some(Self::Write), | |
| 411 | + _ => None, | |
| 412 | + } | |
| 413 | + } | |
| 414 | +} | |
| 415 | +``` | |
| 416 | + | |
| 417 | +| Methode | Signature | Description | | |
| 418 | +|---------|-----------|-------------| | |
| 419 | +| `create` | `(db, user_id, name: &str, scopes: &[S], expires_at: Option<DateTime<Utc>>) → Result<(Model, String)>` | Cree un PAT. Retourne `(record, plaintext)`. `expires_at=None` = permanent. `AppError::Validation` si `name` vide. | | |
| 420 | +| `verify` | `(db, token_clear: &str) → Result<(Model, Vec<S>)>` | Valide un PAT et retourne le record + scopes parses. Update `last_used_at` best-effort. Refuse revoked/expired avec `AppError::InvalidToken`. | | |
| 421 | +| `list_for_user` | `(db, user_id: Uuid) → Result<Vec<Model>>` | Liste les PATs actifs (non revokes), tries desc par `created_at`. | | |
| 422 | +| `revoke` | `(db, token_id: Uuid, user_id: Uuid) → Result<()>` | Revoque un PAT. Idempotent. `AppError::Forbidden` si le token n'appartient pas a `user_id`. | | |
| 423 | +| `cleanup_expired` | `(db) → Result<u64>` | Hard-delete les tokens `expired AND revoked`. Tokens expired-seulement gardes pour audit. Appele par [`PersonalTokenCleanupProcessor`](crate::tasks::personal_token_cleanup). | | |
| 424 | + | |
| 425 | +**Modele `personal_access_token::Model`** : | |
| 426 | +- `id: Uuid`, `user_id: Uuid`, `name: String`, `token_hash: String` (64 hex chars) | |
| 427 | +- `scopes: Vec<String>`, `expires_at: Option<DateTime<Utc>>`, `last_used_at: Option<DateTime<Utc>>` | |
| 428 | +- `revoked_at: Option<DateTime<Utc>>`, `created_at: DateTime<Utc>` | |
| 429 | +- Helpers : `is_expired()`, `is_revoked()`, `is_active()` | |
| 430 | + | |
| 431 | +**Background task** : `PersonalTokenCleanupProcessor::<S>::new(db).start()` — intervalle via env `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS` (default 3600). | |
| 432 | + | |
| 433 | +**Pattern d'extension `AuthUser`** cote consommateur (exemple gitrust pour git HTTPS) : | |
| 434 | +```rust | |
| 435 | +// Dans votre extractor custom : tenter JWT Bearer puis PAT Bearer | |
| 436 | +if let Some(jwt) = extract_bearer_jwt(headers) { /* ... */ } | |
| 437 | +if let Some(plain) = extract_bearer_pat(headers) { | |
| 438 | + let (record, scopes) = PersonalTokenService::<RepoScope>::verify(&db, &plain).await?; | |
| 439 | + // Construire un AuthUser derivé du token | |
| 440 | +} | |
| 441 | +``` | |
| 442 | + | |
| 443 | +> **Integration consommateur** : voir [DEVELOPER.md §14 — Nettoyage des Personal Access Tokens](../DEVELOPER.md#taches-de-fond) pour l'exemple complet de definition du scope et de demarrage du `PersonalTokenCleanupProcessor<S>` dans `main.rs`. | |
| 444 | + | |
| 369 | 445 | --- |
| 370 | 446 | |
| 371 | 447 | ## 8. Ressources et partage |
@@ -392,21 +468,38 @@ Systeme generique de registre de ressources et de partage avec controle d'acces.
| 392 | 468 | |
| 393 | 469 | #### Partage |
| 394 | 470 | |
| 471 | +Les partages acceptent deux types de principals : | |
| 472 | + | |
| 473 | +```rust | |
| 474 | +pub enum Principal { | |
| 475 | + User(Uuid), | |
| 476 | + Team(Uuid), | |
| 477 | +} | |
| 478 | +``` | |
| 479 | + | |
| 480 | +`Principal` est exporte via le prelude. Un share row porte exactement un des deux identifiants (contrainte CHECK au niveau DB). | |
| 481 | + | |
| 395 | 482 | | Methode | Signature | Description | |
| 396 | 483 | |---------|-----------|-------------| |
| 397 | -| `share` | `(db, resource_type, resource_id, shared_with_user_id, permission_level: &str, shared_by_user_id) → Result<resource_share::Model>` | Partage une ressource. Met a jour le partage existant si deja partage. | | |
| 484 | +| `share_with` | `(db, resource_type, resource_id, principal: Principal, permission_level: &str, shared_by_user_id) → Result<resource_share::Model>` | **API moderne**. Partage avec un user ou une team. Update si deja partage (uniqueness par `(resource_id, principal)` via partial unique indexes). Refuse si principal == owner. | | |
| 485 | +| `share` | `(db, resource_type, resource_id, shared_with_user_id, permission_level, shared_by_user_id) → Result<resource_share::Model>` | **Legacy wrapper** sur `share_with(Principal::User(..))`, conserve pour retrocompat. | | |
| 398 | 486 | | `revoke_share` | `(db, share_id: Uuid) → Result<()>` | Revoque un partage par ID. | |
| 399 | -| `revoke_user_access` | `(db, resource_type, resource_id, user_id) → Result<()>` | Revoque tous les partages pour un couple (ressource, utilisateur). | | |
| 400 | -| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<resource_share::Model>>` | Liste les partages d'une ressource. | | |
| 487 | +| `revoke_user_access` | `(db, resource_type, resource_id, user_id) → Result<()>` | Revoque tous les partages user pour un couple (ressource, utilisateur). | | |
| 488 | +| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<resource_share::Model>>` | Liste tous les partages d'une ressource (user + team). | | |
| 401 | 489 | |
| 402 | 490 | #### Controle d'acces |
| 403 | 491 | |
| 404 | 492 | | Methode | Signature | Description | |
| 405 | 493 | |---------|-----------|-------------| |
| 406 | -| `user_can_access` | `(db, user_id, resource_type, resource_id, required_level: &str) → Result<bool>` | Verifie si un utilisateur peut acceder a une ressource au niveau requis. Verifie dans l'ordre : proprietaire → public+read → partage. | | |
| 407 | -| `effective_permission` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | Retourne la permission effective : `"owner"`, le niveau de partage, `"read"` (public), ou `None`. | | |
| 408 | -| `shared_with_user` | `(db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>>` | Ressources partagees avec l'utilisateur. | | |
| 409 | -| `accessible_by_user` | `(db, user_id, resource_type) → Result<Vec<resource::Model>>` | Toutes les ressources accessibles (possedees + partagees + publiques). | | |
| 494 | +| `user_can_access` | `(db, user_id, resource_type, resource_id, required_level: &str) → Result<bool>` | Acces au niveau requis. Verifie : proprietaire → public+read → partage direct (user). | | |
| 495 | +| `effective_permission` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | **Legacy** — ignore les teams. Conservee pour retrocompat. | | |
| 496 | +| `effective_permission_for_user` | `(db, user_id, resource_type, resource_id) → Result<Option<String>>` | **API moderne** — calcule le **max** sur : ownership → partage user direct → partages via chaque team du user → public. Single query avec `Condition::any()`, pas de N+1. | | |
| 497 | +| `shared_with_user` | `(db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>>` | Ressources partagees directement (partage user, pas team). | | |
| 498 | +| `accessible_by_user` | `(db, user_id, resource_type) → Result<Vec<resource::Model>>` | Toutes les ressources accessibles (possedees + partagees directement + publiques). | | |
| 499 | + | |
| 500 | +**Modele `resource_share::Model`** : | |
| 501 | +- `id: Uuid`, `resource_id: Uuid`, `permission_level: String`, `shared_by_user_id: Uuid`, `created_at` | |
| 502 | +- `shared_with_user_id: Option<Uuid>` et `shared_with_team_id: Option<Uuid>` — exactement un des deux est `Some`. | |
| 410 | 503 | |
| 411 | 504 | **Exemple d'utilisation dans un projet consommateur** : |
| 412 | 505 |
@@ -416,26 +509,87 @@ use rustwarden_core::prelude::*;
| 416 | 509 | // Enregistrer un "wall" PasterMan comme ressource |
| 417 | 510 | ResourceService::register(&db, "wall", wall.id, user.user_id).await?; |
| 418 | 511 | |
| 419 | -// Partager en lecture | |
| 420 | -ResourceService::share(&db, "wall", wall.id, other_user_id, "read", user.user_id).await?; | |
| 512 | +// Partager en lecture avec un user | |
| 513 | +ResourceService::share_with(&db, "wall", wall.id, Principal::User(alice), "read", user.user_id).await?; | |
| 421 | 514 | |
| 422 | -// Verifier l'acces | |
| 423 | -if ResourceService::user_can_access(&db, visitor_id, "wall", wall.id, "read").await? { | |
| 424 | - // Afficher le wall | |
| 425 | -} | |
| 515 | +// Partager avec une team (tous les membres heritent) | |
| 516 | +ResourceService::share_with(&db, "wall", wall.id, Principal::Team(backend_team_id), "write", user.user_id).await?; | |
| 426 | 517 | |
| 427 | -// Permission effective | |
| 428 | -match ResourceService::effective_permission(&db, visitor_id, "wall", wall.id).await? { | |
| 429 | - Some(ref p) if p == "owner" => /* acces total */, | |
| 430 | - Some(ref p) if p == "write" => /* edition permise */, | |
| 431 | - Some(ref p) if p == "read" => /* lecture seule */, | |
| 432 | - _ => /* acces refuse */, | |
| 518 | +// Permission effective (prend en compte user + teams, max) | |
| 519 | +match ResourceService::effective_permission_for_user(&db, visitor, "wall", wall.id).await? { | |
| 520 | + Some(p) if p == "owner" => /* acces total */, | |
| 521 | + Some(p) if p == "admin" => /* edition + partage */, | |
| 522 | + Some(p) if p == "write" => /* edition */, | |
| 523 | + Some(p) if p == "read" => /* lecture */, | |
| 524 | + _ => /* pas d'acces */, | |
| 433 | 525 | } |
| 434 | 526 | ``` |
| 435 | 527 | |
| 436 | 528 | --- |
| 437 | 529 | |
| 438 | -## 9. Configuration applicative | |
| 530 | +## 9. Teams (groupes d'utilisateurs) | |
| 531 | + | |
| 532 | +### `TeamService` | |
| 533 | + | |
| 534 | +**Module** : `rustwarden_core::services::team_service` | |
| 535 | +**Export** : prelude (`TeamService`, `TeamRole`) | |
| 536 | + | |
| 537 | +Groupes d'utilisateurs avec membership, owner, role par membre. Un `Principal::Team(id)` est accepte par [`ResourceService::share_with`](#8-ressources-et-partage) — tous les membres heritent alors du share via `effective_permission_for_user`. | |
| 538 | + | |
| 539 | +**Enum `TeamRole`** : `Owner | Admin | Member` — persiste en DB comme `"owner"|"admin"|"member"`. Le createur est automatiquement insere comme `owner` member au `create`. | |
| 540 | + | |
| 541 | +| Methode | Signature | Description | | |
| 542 | +|---------|-----------|-------------| | |
| 543 | +| `create` | `(db, owner_id, slug: TeamSlug, name: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<team::Model>` | Cree une team + insere l'owner comme premier membre (role `Owner`). Cable `on_team_created`. Valide `name` (1-128 chars). | | |
| 544 | +| `find_by_id` | `(db, team_id) → Result<Option<team::Model>>` | Recherche par ID. | | |
| 545 | +| `find_by_slug` | `(db, slug: &TeamSlug) → Result<Option<team::Model>>` | Recherche par slug. | | |
| 546 | +| `add_member` | `(db, team_id, user_id, role: TeamRole, hooks) → Result<team_member::Model>` | **Idempotent** : update le role si l'edge existe, insere sinon. Cable `on_team_member_added`. | | |
| 547 | +| `remove_member` | `(db, team_id, user_id, hooks) → Result<()>` | Supprime un membre. **Refuse de retirer l'owner** (`AppError::Validation`) — utiliser `delete` pour drop la team. Cable `on_team_member_removed`. | | |
| 548 | +| `list_members` | `(db, team_id) → Result<Vec<team_member::Model>>` | Liste les membres, tri desc par `joined_at`. | | |
| 549 | +| `teams_of_user` | `(db, user_id) → Result<Vec<team::Model>>` | Les teams dont le user est membre (inner join). | | |
| 550 | +| `team_ids_of_user` | `(db, user_id) → Result<Vec<Uuid>>` | Juste les IDs — helper perf pour la resolution de permission. | | |
| 551 | +| `delete` | `(db, team_id, hooks) → Result<()>` | Hard-delete la team. Cascade DB sur `team_members` + `resource_shares` avec ce team comme principal. Cable `on_team_deleted`. | | |
| 552 | + | |
| 553 | +### `TeamSlug` (value object) | |
| 554 | + | |
| 555 | +**Module** : `rustwarden_core::types::team_slug` | |
| 556 | +**Export** : prelude | |
| 557 | + | |
| 558 | +Slug URL-safe pour une team. Construction via `TeamSlug::parse(&str)` qui valide : | |
| 559 | + | |
| 560 | +- 1 a 64 caracteres | |
| 561 | +- `[a-z0-9-]+` (lowercase ASCII + digits + hyphen) | |
| 562 | +- Ni debut ni fin avec `-` | |
| 563 | +- Pas un des ~15 noms reserves (`admin`, `api`, `teams`, `settings`, ...) | |
| 564 | + | |
| 565 | +```rust | |
| 566 | +use rustwarden_core::prelude::*; | |
| 567 | + | |
| 568 | +let slug = TeamSlug::parse("backend")?; | |
| 569 | +let team = TeamService::create(&db, owner_id, slug, "Backend Team", Some(&hooks)).await?; | |
| 570 | +TeamService::add_member(&db, team.id, alice_id, TeamRole::Member, Some(&hooks)).await?; | |
| 571 | + | |
| 572 | +// Partager un repo avec la team : | |
| 573 | +ResourceService::share_with(&db, "repo", repo_id, Principal::Team(team.id), "write", owner_id).await?; | |
| 574 | + | |
| 575 | +// Alice herite automatiquement du write access : | |
| 576 | +let perm = ResourceService::effective_permission_for_user(&db, alice_id, "repo", repo_id).await?; | |
| 577 | +assert_eq!(perm.as_deref(), Some("write")); | |
| 578 | +``` | |
| 579 | + | |
| 580 | +**Modeles** : | |
| 581 | +- `team::Model` : `id`, `slug: String`, `name: String`, `owner_id: Uuid`, `created_at` | |
| 582 | +- `team_member::Model` (PK composite `(team_id, user_id)`) : `team_id`, `user_id`, `role: String`, `joined_at` | |
| 583 | + | |
| 584 | +**Cascade** : | |
| 585 | +- DELETE user → supprime les teams dont il est owner (cascade) et ses memberships. | |
| 586 | +- DELETE team → supprime `team_members` + `resource_shares` via `shared_with_team_id`. | |
| 587 | + | |
| 588 | +> **Integration consommateur** : `TeamService` est framework-only. Les apps consommatrices exposent leurs propres handlers/routes/templates team selon leur UX. Voir [DEVELOPER.md §12 — Hooks teams](../DEVELOPER.md#hooks--intercepter-les-evenements) pour brancher un audit log sur les evenements team. | |
| 589 | + | |
| 590 | +--- | |
| 591 | + | |
| 592 | +## 10. Configuration applicative | |
| 439 | 593 | |
| 440 | 594 | ### `AppSettingsService` |
| 441 | 595 |
@@ -468,7 +622,7 @@ Stockage cle-valeur en base pour la configuration dynamique.
| 468 | 622 | |
| 469 | 623 | --- |
| 470 | 624 | |
| 471 | -## 10. Preferences utilisateur | |
| 625 | +## 11. Preferences utilisateur | |
| 472 | 626 | |
| 473 | 627 | ### `UserPreferencesService` |
| 474 | 628 |
@@ -487,7 +641,7 @@ Stockage cle-valeur en base pour la configuration dynamique.
| 487 | 641 | |
| 488 | 642 | --- |
| 489 | 643 | |
| 490 | -## 11. Email | |
| 644 | +## 12. Email | |
| 491 | 645 | |
| 492 | 646 | ### `EmailService` |
| 493 | 647 |
@@ -529,7 +683,7 @@ Verification d'email par token.
| 529 | 683 | | `new` | `(config: EmailConfig) → Self` | Constructeur. | |
| 530 | 684 | | `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. | |
| 531 | 685 | | `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. | |
| 532 | -| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited (3/heure). | | |
| 686 | +| `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`. | | |
| 533 | 687 | |
| 534 | 688 | ### `ImapBounceService` |
| 535 | 689 |
@@ -547,7 +701,7 @@ Le service parse les messages DSN (RFC 3464) et met a jour la table `email_deliv
| 547 | 701 | |
| 548 | 702 | --- |
| 549 | 703 | |
| 550 | -## 12. Chiffrement | |
| 704 | +## 13. Chiffrement | |
| 551 | 705 | |
| 552 | 706 | ### `CryptoService` |
| 553 | 707 |
@@ -564,7 +718,7 @@ Chiffrement AES-256-GCM pour les secrets en base (secrets OAuth, etc.).
| 564 | 718 | |
| 565 | 719 | --- |
| 566 | 720 | |
| 567 | -## 13. OAuth (feature `oauth`) | |
| 721 | +## 14. OAuth (feature `oauth`) | |
| 568 | 722 | |
| 569 | 723 | > Disponible uniquement si la feature `oauth` est activee dans `Cargo.toml`. |
| 570 | 724 |
@@ -598,7 +752,253 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
| 598 | 752 | |
| 599 | 753 | --- |
| 600 | 754 | |
| 601 | -## 14. Utilitaires | |
| 755 | +## 15. Rate limiting | |
| 756 | + | |
| 757 | +### `RateLimitService` | |
| 758 | + | |
| 759 | +**Module** : `rustwarden_core::services::rate_limit_service` | |
| 760 | + | |
| 761 | +Rate limiter generique in-process, backing de toutes les limites du framework (auth, public share, password flows, email validation) et reutilisable par les apps consommatrices. | |
| 762 | + | |
| 763 | +| Methode | Signature | Description | | |
| 764 | +|---------|-----------|-------------| | |
| 765 | +| `new` | `() → Self` | Nouveau service (maps internes vides). | | |
| 766 | +| `check` | `(&self, key: &str, max_per_minute: u32) → Result<(), AppError>` | Fixed window 60s. Renvoie `AppError::TooManyRequests { retry_after_secs }` au depassement. | | |
| 767 | +| `check_window` | `(&self, key: &str, max: u32, window: Duration) → Result<(), AppError>` | Idem avec fenetre personnalisable. | | |
| 768 | +| `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). | | |
| 769 | +| `cleanup_loop` | `(self, interval: Duration, window: Duration)` async | Tache de fond qui purge les buckets expires sur les deux maps (fixed + sliding). | | |
| 770 | +| `len` / `sliding_len` / `is_empty` | | Compteurs exposes pour metrics/tests. | | |
| 771 | + | |
| 772 | +**Cle convention** : `"<scope>:<identifier>"` — ex. `"auth:1.2.3.4"`, `"share:1.2.3.4"`, `"pwd_reset:user@example.com"`, `"import:123e4567-..."`. | |
| 773 | + | |
| 774 | +**AppError::TooManyRequests** : mappe vers HTTP **429** + header `Retry-After: N` + body JSON `{"error","status":429,"retry_after_secs":N}`. | |
| 775 | + | |
| 776 | +Exemple d'usage pour proteger un endpoint d'import cote app consommateur : | |
| 777 | +```rust | |
| 778 | +use std::sync::Arc; | |
| 779 | +use rustwarden_core::services::rate_limit_service::RateLimitService; | |
| 780 | + | |
| 781 | +// Au demarrage de l'app | |
| 782 | +let limiter = Arc::new(RateLimitService::new()); | |
| 783 | +// Dans un handler | |
| 784 | +limiter.check(&format!("import:{ip}"), 120)?; // 120/min | |
| 785 | +``` | |
| 786 | + | |
| 787 | +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)`. | |
| 788 | + | |
| 789 | +> **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. | |
| 790 | + | |
| 791 | +--- | |
| 792 | + | |
| 793 | +## 16. Notifications temps-reel et SSE | |
| 794 | + | |
| 795 | +### Trait `NotificationBus<T>` et `InMemoryBus` | |
| 796 | + | |
| 797 | +**Module** : `rustwarden_core::notifications` | |
| 798 | + | |
| 799 | +Pub/sub bus generique pour pousser des evenements aux clients (Server-Sent Events, progress bars, live logs). Le trait est swappable (futur backend Redis) — aujourd'hui seul `InMemoryBus` est fourni. | |
| 800 | + | |
| 801 | +```rust | |
| 802 | +#[async_trait] | |
| 803 | +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { | |
| 804 | + async fn publish(&self, event: T); | |
| 805 | + fn subscribe(&self, filter: EventFilter<T>) -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 806 | +} | |
| 807 | + | |
| 808 | +pub type EventFilter<T> = Arc<dyn Fn(&T) -> bool + Send + Sync>; | |
| 809 | +``` | |
| 810 | + | |
| 811 | +**`InMemoryBus<T>`** — backing `tokio::sync::broadcast` : | |
| 812 | +- `new(capacity)` / `Default` (capacity 256). | |
| 813 | +- `receiver_count()` pour metrics. | |
| 814 | +- Slow consumer drop proprement via `BroadcastStream::filter_map` (pas de panic, pas de stream closure). | |
| 815 | +- Publisher ignore `Err` quand aucun receiver. | |
| 816 | + | |
| 817 | +### Helper `sse::sse_response` | |
| 818 | + | |
| 819 | +**Module** : `rustwarden_core::notifications::sse` | |
| 820 | + | |
| 821 | +```rust | |
| 822 | +pub const DEFAULT_SSE_KEEPALIVE: Duration = Duration::from_secs(30); | |
| 823 | + | |
| 824 | +pub fn sse_response<T, S>(stream: S) -> Sse<impl Stream<Item = Result<Event, Infallible>>> | |
| 825 | +where | |
| 826 | + T: Serialize + Send + 'static, | |
| 827 | + S: Stream<Item = T> + Send + 'static; | |
| 828 | +``` | |
| 829 | + | |
| 830 | +Wrap un stream en reponse axum `Sse` avec keep-alive 30s + serialisation JSON par event + drop silencieux des events non serialisables. | |
| 831 | + | |
| 832 | +**Exemple complet** (cote app) : | |
| 833 | +```rust | |
| 834 | +use std::sync::Arc; | |
| 835 | +use axum::extract::Extension; | |
| 836 | +use axum::response::IntoResponse; | |
| 837 | +use rustwarden_core::notifications::{InMemoryBus, NotificationBus, sse::sse_response}; | |
| 838 | + | |
| 839 | +#[derive(Clone, serde::Serialize)] | |
| 840 | +enum AppEvent { | |
| 841 | + ImportProgress { job_id: uuid::Uuid, pct: u8 }, | |
| 842 | + CiLog { job_id: uuid::Uuid, line: String }, | |
| 843 | +} | |
| 844 | + | |
| 845 | +async fn import_stream( | |
| 846 | + Extension(bus): Extension<Arc<dyn NotificationBus<AppEvent>>>, | |
| 847 | + axum::extract::Path(job_id): axum::extract::Path<uuid::Uuid>, | |
| 848 | +) -> impl IntoResponse { | |
| 849 | + let filter = Arc::new(move |e: &AppEvent| matches!(e, AppEvent::ImportProgress { job_id: j, .. } if *j == job_id)); | |
| 850 | + sse_response(bus.subscribe(filter)) | |
| 851 | +} | |
| 852 | +``` | |
| 853 | + | |
| 854 | +> **Integration consommateur** : voir [DEVELOPER.md §15 — Notifications temps-reel (SSE)](../DEVELOPER.md#rate-limiting-et-notifications-temps-reel) pour l'injection du bus dans le builder et l'abonnement filtre. | |
| 855 | + | |
| 856 | +--- | |
| 857 | + | |
| 858 | +## 17. Hooks lifecycle et audit | |
| 859 | + | |
| 860 | +### Trait `RustwardenHooks` | |
| 861 | + | |
| 862 | +**Module** : `rustwarden_core::hooks` | |
| 863 | +**Export** : prelude | |
| 864 | + | |
| 865 | +Trait implementable par les apps consommatrices pour reagir aux evenements lifecycle du framework. Toutes les methodes ont un default `Ok(())` — overridez celles qui vous interessent. | |
| 866 | + | |
| 867 | +**Event lifecycle (19 hooks)** : | |
| 868 | + | |
| 869 | +| Hook | Signature (after `&self, db: &DatabaseConnection`) | Description | | |
| 870 | +|------|-----|-------------| | |
| 871 | +| `on_user_registered` | `user_id, username` | Nouveau user cree. | | |
| 872 | +| `on_user_login` | `user_id, username` | Login reussi. | | |
| 873 | +| `on_user_logout` | `user_id` | Logout. | | |
| 874 | +| `on_user_deleted` | `user_id, username` | **Avant** cascade DB — cleanup des ressources metier (walls, repos, ...). | | |
| 875 | +| `on_password_changed` | `user_id` | Mot de passe modifie (flow direct ou confirme). | | |
| 876 | +| `on_email_verified` | `user_id` | Email verifie. | | |
| 877 | +| `on_role_changed` | `user_id, roles: &[String]` | Roles modifies. | | |
| 878 | +| `on_totp_enabled` / `on_totp_disabled` | `user_id` | 2FA toggle. | | |
| 879 | +| `on_oauth_login` | `user_id, provider, is_new_user` | Login OAuth. | | |
| 880 | +| `on_resource_shared` | `rt, rid, shared_with, level, shared_by` | Ressource partagee. | | |
| 881 | +| `on_resource_unshared` | `rt, rid, user_id` | Partage revoke. | | |
| 882 | +| `on_resource_registered` | `rt, rid, owner_id` | Nouvelle ressource enregistree. | | |
| 883 | +| `on_resource_unregistered` | `rt, rid` | Ressource supprimee du registre. | | |
| 884 | +| `on_team_created` | `team_id, owner_id` | Team creee. | | |
| 885 | +| `on_team_deleted` | `team_id` | Team supprimee. | | |
| 886 | +| `on_team_member_added` / `on_team_member_removed` | `team_id, user_id` | Membership. | | |
| 887 | +| `on_audit_event` | `event: AuditEvent<'_>` | **Escape hatch generique** pour events qui ne meritent pas leur propre hook. | | |
| 888 | + | |
| 889 | +### Struct `AuditEvent<'a>` | |
| 890 | + | |
| 891 | +```rust | |
| 892 | +pub struct AuditEvent<'a> { | |
| 893 | + pub kind: &'a str, // "auth.login_blocked", "pr.merged", ... | |
| 894 | + pub actor_id: Option<Uuid>, | |
| 895 | + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) | |
| 896 | + pub metadata: &'a serde_json::Value, | |
| 897 | +} | |
| 898 | +``` | |
| 899 | + | |
| 900 | +Emis par : | |
| 901 | +- `AuthLoggingService::log_blocked_login` → `kind="auth.login_blocked"`, metadata `{email, reason}`. | |
| 902 | +- Apps consommatrices pour leurs propres events (issues, PR, walls, ...). | |
| 903 | + | |
| 904 | +Les hooks implementant `on_audit_event` recoivent tous ces events : un sink unique (SIEM, Langfuse, indexation OpenSearch) se branche une fois pour tout auditer. | |
| 905 | + | |
| 906 | +**Exemple d'implementation cote app** : | |
| 907 | +```rust | |
| 908 | +use rustwarden_core::hooks::{AuditEvent, RustwardenHooks}; | |
| 909 | +use async_trait::async_trait; | |
| 910 | +use sea_orm::DatabaseConnection; | |
| 911 | +use uuid::Uuid; | |
| 912 | + | |
| 913 | +pub struct MyHooks; | |
| 914 | + | |
| 915 | +#[async_trait] | |
| 916 | +impl RustwardenHooks for MyHooks { | |
| 917 | + async fn on_user_deleted(&self, _db: &DatabaseConnection, user_id: Uuid, _: &str) -> anyhow::Result<()> { | |
| 918 | + // Cleanup du repertoire utilisateur sur le filesystem | |
| 919 | + std::fs::remove_dir_all(format!("/var/data/users/{user_id}")).ok(); | |
| 920 | + Ok(()) | |
| 921 | + } | |
| 922 | + | |
| 923 | + async fn on_audit_event(&self, _db: &DatabaseConnection, event: AuditEvent<'_>) -> anyhow::Result<()> { | |
| 924 | + // Forward vers un SIEM externe | |
| 925 | + my_siem::send(event.kind, event.actor_id, event.metadata).await?; | |
| 926 | + Ok(()) | |
| 927 | + } | |
| 928 | +} | |
| 929 | +``` | |
| 930 | + | |
| 931 | +Installation : `RustwardenApp::builder().hooks(Arc::new(MyHooks)).build()?`. | |
| 932 | + | |
| 933 | +> **Integration consommateur** : voir [DEVELOPER.md §12 — Hooks](../DEVELOPER.md#hooks--intercepter-les-evenements) pour l'exemple complet d'un `AuditHooks` implementant les 4 hooks teams. | |
| 934 | + | |
| 935 | +--- | |
| 936 | + | |
| 937 | +## 18. Middleware (CSRF, auth, rate limit) | |
| 938 | + | |
| 939 | +### CSRF | |
| 940 | + | |
| 941 | +**Module** : `rustwarden_core::middleware::csrf` | |
| 942 | + | |
| 943 | +- `csrf_protection` — middleware par defaut, verifie le header `X-CSRF-Token`. | |
| 944 | +- `csrf_protection_with_config` — lit aussi le body `application/x-www-form-urlencoded` (pour apps SSR sans JS). | |
| 945 | +- `csrf_protection_impl` — variante qui prend `&DatabaseConnection` au lieu de `State<_>`. | |
| 946 | +- `get_or_create_csrf_token(&Session)` — acces au token pour injection dans les templates. | |
| 947 | +- `validate_csrf_token(&Session, &str)` — validation manuelle depuis un handler. | |
| 948 | +- `extract_csrf_from_form(&[u8]) -> Option<String>` — helper public pour les apps qui bufferisent le body elles-memes. | |
| 949 | + | |
| 950 | +**`CsrfProtectionConfig`** : | |
| 951 | +```rust | |
| 952 | +pub struct CsrfProtectionConfig { | |
| 953 | + pub accept_form_body: bool, // default: false | |
| 954 | + pub max_form_body_bytes: usize, // default: 1 MiB | |
| 955 | +} | |
| 956 | +``` | |
| 957 | + | |
| 958 | +Wire dans le router : | |
| 959 | +```rust | |
| 960 | +use std::sync::Arc; | |
| 961 | +use axum::middleware; | |
| 962 | +use rustwarden_core::middleware::csrf::{csrf_protection_with_config, CsrfProtectionConfig}; | |
| 963 | + | |
| 964 | +let cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() }); | |
| 965 | +let router = Router::new() | |
| 966 | + .route("/forms/submit", post(handler)) | |
| 967 | + .layer(middleware::from_fn_with_state((db.clone(), cfg), csrf_protection_with_config)); | |
| 968 | +``` | |
| 969 | + | |
| 970 | +**Securite** : | |
| 971 | +- Comparaison constant-time (SEC-H4, `subtle::ConstantTimeEq`). | |
| 972 | +- Skip uniquement pour requetes `Authorization: Bearer ...` (API clients non CSRF-vulnerables). | |
| 973 | +- Multipart jamais bufferise (skip + 403). | |
| 974 | +- Body > cap → 413 `PAYLOAD_TOO_LARGE`. | |
| 975 | + | |
| 976 | +### Rate limiting helpers | |
| 977 | + | |
| 978 | +**Module** : `rustwarden_core::middleware::rate_limit` | |
| 979 | + | |
| 980 | +Wrapper pratiques par-dessus `RateLimitService` : | |
| 981 | + | |
| 982 | +| Helper | Signature | Description | | |
| 983 | +|--------|-----------|-------------| | |
| 984 | +| `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). | | |
| 985 | +| `check_public_share_rate_limit` | `(&Request) → Result<(), Response>` | Protege les endpoints de public share. Cle `share:{ip}`, 30/min. | | |
| 986 | +| `check_public_share_rate_limit_from_headers` | `(&HeaderMap) → Result<(), Response>` | Idem depuis un `HeaderMap` (extractor deja consomme le Request). | | |
| 987 | +| `extract_client_ip` / `extract_client_ip_from_headers` | | IP utilisateur avec support `X-Forwarded-For` (gated par `TRUSTED_PROXY_HOPS`). | | |
| 988 | +| `AdminRateLimitConfig::*` | | Layers Tower pre-construits (`admin_limit`, `user_management_limit`, `reset_password_limit`). | | |
| 989 | + | |
| 990 | +### Auth / Admin | |
| 991 | + | |
| 992 | +**Module** : `rustwarden_core::middleware` | |
| 993 | + | |
| 994 | +- `auth_required::require_auth` — middleware qui exige un JWT valide (rejette 401 sinon). | |
| 995 | +- `admin_required::require_admin` — exige le role `admin`. | |
| 996 | +- `auth_service::auth_middleware` — auth primaire (extraction JWT + validation + injection `Claims` en Extension). | |
| 997 | +- `password_complexity::enforce_password_complexity` — helper de validation. | |
| 998 | + | |
| 999 | +--- | |
| 1000 | + | |
| 1001 | +## 19. Utilitaires | |
| 602 | 1002 | |
| 603 | 1003 | ### `AuthLoggingService` |
| 604 | 1004 |
@@ -606,7 +1006,7 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
| 606 | 1006 | |
| 607 | 1007 | | Methode | Signature | Description | |
| 608 | 1008 | |---------|-----------|-------------| |
| 609 | -| `log_blocked_login` | `(db, email: &str, reason: &str) → Result<()>` | Enregistre une tentative de login bloquee dans `audit_logs`. Best-effort (les erreurs ne font pas echouer le flux principal). | | |
| 1009 | +| `log_blocked_login` | `(db, email: &str, reason: &str, hooks: Option<&Arc<dyn RustwardenHooks>>) → Result<()>` | Enregistre dans `audit_logs` + fan-out `on_audit_event(kind="auth.login_blocked", metadata={email,reason})` si hooks fournis. Best-effort — un echec hook/DB logue un warn sans casser le flux auth. | | |
| 610 | 1010 | |
| 611 | 1011 | ### `email_sanitization` |
| 612 | 1012 |
@@ -627,7 +1027,7 @@ Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
| 627 | 1027 | |
| 628 | 1028 | --- |
| 629 | 1029 | |
| 630 | -## 15. Patterns communs | |
| 1030 | +## 20. Patterns communs | |
| 631 | 1031 | |
| 632 | 1032 | ### Gestion d'erreurs |
| 633 | 1033 |
@@ -637,32 +1037,64 @@ Tous les services retournent `Result<T, AppError>`. Les variantes principales :
| 637 | 1037 | |----------|------|-------| |
| 638 | 1038 | | `AppError::NotFound(msg)` | 404 | Ressource introuvable | |
| 639 | 1039 | | `AppError::Validation(msg)` | 400 | Donnees invalides | |
| 640 | -| `AppError::Forbidden` | 403 | Acces refuse (RBAC) | | |
| 1040 | +| `AppError::Forbidden` | 403 | Acces refuse (RBAC, ownership) | | |
| 641 | 1041 | | `AppError::Unauthorized` | 401 | Non authentifie | |
| 1042 | +| `AppError::InvalidToken` | 401 | JWT / PAT / token reset/verify invalide | | |
| 642 | 1043 | | `AppError::TokenExpired` | 401 | JWT expire | |
| 643 | 1044 | | `AppError::TokenReused` | 401 | Refresh token reutilise (attaque detectee) | |
| 1045 | +| `AppError::TooManyRequests { retry_after_secs }` | **429** | Rate limit depasse. **Header `Retry-After: N` ajoute automatiquement.** | | |
| 644 | 1046 | | `AppError::Conflict(msg)` | 409 | Unicite violee (username, email) | |
| 645 | 1047 | | `AppError::Internal(msg)` | 500 | Erreur interne | |
| 646 | 1048 | | `AppError::Config(msg)` | 500 | Configuration manquante | |
| 647 | 1049 | |
| 648 | 1050 | ### Rate limiting |
| 649 | 1051 | |
| 650 | -Les services sensibles implementent un rate limiter en memoire : | |
| 1052 | +Toutes les limites passent par [`RateLimitService`](#15-rate-limiting) avec des cles prefixees (single source of truth, pas de duplication) : | |
| 1053 | + | |
| 1054 | +| Cle | Limite | Window type | Expose via | | |
| 1055 | +|-----|--------|-------------|------------| | |
| 1056 | +| `auth:{ip}` | env `RATE_LIMIT_AUTH_PER_MINUTE` (default 10/min) | Fixed | `check_auth_rate_limit(headers)` | | |
| 1057 | +| `share:{ip}` | 30/min | Fixed | `check_public_share_rate_limit*` | | |
| 1058 | +| `pwd_change:{user_id}` | 3/hour (config) | Sliding strict | `PasswordChangeService::create_pending_request` | | |
| 1059 | +| `pwd_reset:{email}` | 3/hour | Sliding strict | `PasswordResetService::request_reset` | | |
| 1060 | +| `email_verify:{email}` | 3/hour | Sliding strict | `EmailValidationService::resend_verification` | | |
| 1061 | +| `*` (vos cles) | libre | les deux | `RateLimitService::check*` public | | |
| 651 | 1062 | |
| 652 | -| Service | Limite | Cle | | |
| 653 | -|---------|--------|-----| | |
| 654 | -| `PasswordChangeService` | 3/heure | `user_id` | | |
| 655 | -| `PasswordResetService` | 3/heure | `email` | | |
| 656 | -| `EmailValidationService` | 3/heure | `email` | | |
| 1063 | +Un depassement renvoie systematiquement `AppError::TooManyRequests { retry_after_secs }` → HTTP 429 + `Retry-After`. | |
| 657 | 1064 | |
| 658 | 1065 | ### Tokens hashes en base |
| 659 | 1066 | |
| 660 | -Les tokens sensibles (refresh, reset, verification, changement de mot de passe) sont **toujours stockes en SHA-256** en base. Le token en clair n'est retourne qu'a la creation et jamais persiste. | |
| 1067 | +Les tokens sensibles sont **toujours stockes en SHA-256** en base. Le plaintext n'est retourne qu'a la creation et jamais persiste. Concerne : | |
| 1068 | + | |
| 1069 | +- Refresh tokens (rotation, detection reuse). | |
| 1070 | +- Password reset / email validation / password change (tokens email). | |
| 1071 | +- Personal Access Tokens (PATs, static long-lived). | |
| 661 | 1072 | |
| 662 | 1073 | ### Revocation en cascade (SEC-M7) |
| 663 | 1074 | |
| 664 | 1075 | Les operations critiques revoquent automatiquement tous les refresh tokens de l'utilisateur : |
| 665 | 1076 | - `PasswordChangeService::confirm_request()` |
| 666 | 1077 | - `PasswordResetService::reset_password()` |
| 1078 | +- `UserService::admin_reset_password()` / `change_password_direct()` | |
| 1079 | + | |
| 1080 | +### Principal generique (User ou Team) | |
| 1081 | + | |
| 1082 | +Les partages acceptent `Principal::User(uuid) | Principal::Team(uuid)`. La resolution de permission (`effective_permission_for_user`) prend le **max** sur user direct + tous les teams dont l'user est membre — en une seule query SQL avec `Condition::any()` (pas de N+1). | |
| 1083 | + | |
| 1084 | +### Observabilite via hooks | |
| 1085 | + | |
| 1086 | +Plutot que de forker le framework pour ajouter du monitoring, implementez [`RustwardenHooks`](#17-hooks-lifecycle-et-audit) : | |
| 1087 | + | |
| 1088 | +- 18 hooks cibles (`on_user_*`, `on_team_*`, `on_resource_*`, ...) pour de la business logic. | |
| 1089 | +- `on_audit_event(AuditEvent<'_>)` comme escape hatch generique — `AuthLoggingService` l'emet deja pour `auth.login_blocked`. | |
| 1090 | + | |
| 1091 | +### Layouts (CSRF + metadata) | |
| 1092 | + | |
| 1093 | +`LayoutContext::from_session(&session, title)` peuple automatiquement `csrf_token`, `app_name` et `app_version` depuis la session. Retire le boilerplate `get_or_create_csrf_token` dans les handlers SSR : | |
| 1094 | + | |
| 1095 | +```rust | |
| 1096 | +use rustwarden_core::templates::LayoutContext; | |
| 1097 | +let layout = LayoutContext::from_session(&session, "Dashboard").await; | |
| 1098 | +``` | |
| 667 | 1099 | |
| 668 | 1100 | Cela force la reconnexion sur tous les appareils apres un changement de mot de passe. |
M
docs/UPSTREAM_RUSTWARDEN.md
+547
-67
@@ -1,112 +1,592 @@
| 1 | -# Notes d'amelioration pour RustWarden-core | |
| 1 | +# Notes sur l'utilisation de RustWarden-core dans Gitrust | |
| 2 | 2 | |
| 3 | -## 1. `UserService::delete_user` — IMPLEMENTE | |
| 3 | +Ce fichier documente les interactions entre Gitrust et le framework RustWarden-core. | |
| 4 | 4 | |
| 5 | -**Statut : IMPLEMENTE** | |
| 5 | +## Services framework utilises | |
| 6 | 6 | |
| 7 | -Methode ajoutee avec : | |
| 8 | -- Protection contre la suppression du dernier admin | |
| 9 | -- Hook `on_user_deleted` appele avant la suppression (permet aux consommateurs de nettoyer leurs donnees metier) | |
| 10 | -- Revocation de tous les refresh tokens | |
| 11 | -- Cascade DB pour user_roles, resources, shares, oauth_accounts | |
| 7 | +| Service | Usage dans Gitrust | | |
| 8 | +|---------|-------------------| | |
| 9 | +| `UserService` | CRUD users, roles, `delete_user`, `admin_reset_password`, `change_password_direct`, `unassign_role_from_user` | | |
| 10 | +| `RoleService` | Liste/creation/suppression roles, `get_role_by_name` | | |
| 11 | +| `PasswordService` | `hash_password` (async), `verify_password` (async), `generate_secure_password` | | |
| 12 | +| `ResourceService` | `register`/`unregister` repos, `set_public`, `effective_permission`, `find_by_type_and_id` | | |
| 13 | +| `AuthService` | `authenticate_user` (login) | | |
| 14 | +| `JwtService` | `generate_token` (login) | | |
| 15 | +| `EmailValidationService` | Verification email | | |
| 16 | +| `PasswordResetService` | Flux "mot de passe oublie" | | |
| 17 | +| `AppSettingsService` | `get_bool` pour `registration_enabled` | | |
| 18 | + | |
| 19 | +## Hook `on_user_deleted` implemente | |
| 20 | + | |
| 21 | +Le hook `on_user_deleted` dans `gitrust-hooks` supprime le repertoire utilisateur sur le filesystem (`repos_base_path/username/`) avant la cascade DB. Appele automatiquement par `UserService::delete_user`. | |
| 22 | + | |
| 23 | +## Aucun contournement en cours | |
| 24 | + | |
| 25 | +Tous les anciens contournements (entity directes, hash manuels) ont ete remplaces par les services du framework suite a la mise a jour du 2026-03-26. | |
| 26 | + | |
| 27 | +--- | |
| 28 | + | |
| 29 | +## Propositions d'amelioration framework (backlog upstream) | |
| 30 | + | |
| 31 | +Cette section liste les limitations observees cote gitrust lors de l'integration et les ameliorations qui benificieraient a toutes les apps batissant sur rustwarden-core. Chaque entree decrit : le contexte, la limitation actuelle, le workaround cote gitrust (temporaire) et la(les) proposition(s) a soumettre upstream. | |
| 32 | + | |
| 33 | +### ~~1. Middleware CSRF incompatible avec les formulaires SSR `application/x-www-form-urlencoded`~~ — RESOLU 2026-04-22 | |
| 34 | + | |
| 35 | +**Status** : Implemente en Phase 2.1 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 36 | + | |
| 37 | +Proposition A retenue : | |
| 38 | +- Nouveau [`CsrfProtectionConfig`](../crates/rustwarden-core/src/middleware/csrf.rs) avec `accept_form_body: bool` + `max_form_body_bytes: usize` (default 1 MiB). | |
| 39 | +- Nouveau middleware [`csrf_protection_with_config`](../crates/rustwarden-core/src/middleware/csrf.rs) qui buffer le body url-encoded, extrait `csrf_token`, valide constant-time, reconstruit le body. | |
| 40 | +- Helper public bonus [`extract_csrf_from_form(body: &[u8])`](../crates/rustwarden-core/src/middleware/csrf.rs) (proposition B). | |
| 41 | +- Multipart skip, body > cap → 413, 11 tests TDD. | |
| 42 | +- `csrf_protection` existante inchangee (backwards-compat). | |
| 43 | + | |
| 44 | +--- | |
| 45 | + | |
| 46 | +### 1bis. Details historiques (avant resolution) | |
| 47 | + | |
| 48 | +**Date de detection** : 2026-04-21 (iteration CSRF admin ssh-guard). | |
| 49 | + | |
| 50 | +**Contexte** : Gitrust est une app SSR pure — pas de JavaScript global, pas de fetch interceptor qui injecterait automatiquement un header `X-CSRF-Token`. Tous les formulaires POST envoient leur token via un champ cache `<input type="hidden" name="csrf_token" value="{{ layout.csrf_token }}">` dans un body `application/x-www-form-urlencoded`. | |
| 51 | + | |
| 52 | +**Limitation du framework** : le middleware [`csrf_protection`](../crates/rustwarden-core/src/middleware/csrf.rs) ne lit que le header `X-CSRF-Token` — il ne parse pas le body. Le commentaire dans le code ([csrf.rs:81-83](../crates/rustwarden-core/src/middleware/csrf.rs#L81)) l'indique explicitement : | |
| 53 | + | |
| 54 | +```rust | |
| 55 | +// For form data, we would need to parse the body | |
| 56 | +// This is handled in handlers that process form data | |
| 57 | +``` | |
| 58 | + | |
| 59 | +Consequence : appliquer `csrf_protection` comme layer axum sur un routeur qui sert des formulaires SSR fait systematiquement renvoyer `403 CSRF token required` — le middleware ne voit jamais le token qui est pourtant dans le body. Les apps SSR sont forcees de : | |
| 60 | +- soit implementer leur propre middleware buffer-body (duplication + risque d'incoherence), | |
| 61 | +- soit appeler `validate_csrf_token` manuellement dans chaque handler POST (invasion de boilerplate). | |
| 62 | + | |
| 63 | +**Workaround actuel cote gitrust** : middleware [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) qui : | |
| 64 | +1. Accepte les methodes safe (`GET`/`HEAD`/`OPTIONS`) sans verification. | |
| 65 | +2. Skip les requetes `Authorization: Bearer` (API JWT non vulnerable). | |
| 66 | +3. Essaie d'abord le header `X-CSRF-Token` (compat future avec HTMX/fetch). | |
| 67 | +4. A defaut, bufferise le body (cap 1 MiB), extrait `csrf_token=...` via parsing URL-encoded, valide via `rustwarden_core::middleware::csrf::validate_csrf_token`, reconstruit le body avec `Body::from(bytes)` pour que le handler downstream puisse re-decoder sa struct `Form<T>`. | |
| 68 | + | |
| 69 | +Ce middleware est **duplicat de logique framework** : il reimplemente la detection bearer, le skip des methodes safe, et le flow de validation. Toute evolution du framework (nouveau type d'auth, changement de flow CSRF) necessite une mise a jour synchronisee cote gitrust. | |
| 70 | + | |
| 71 | +**Propositions d'amelioration** (par ordre de preference) : | |
| 72 | + | |
| 73 | +#### Proposition A — Extension de `csrf_protection` avec option `accept_form_body` | |
| 74 | + | |
| 75 | +Ajouter un parametre optionnel au middleware pour activer la lecture du body : | |
| 76 | + | |
| 77 | +```rust | |
| 78 | +// Nouveau dans rustwarden_core::middleware::csrf | |
| 79 | +pub struct CsrfProtectionConfig { | |
| 80 | + /// Si true, lit le champ `csrf_token` du body form-urlencoded | |
| 81 | + /// quand le header `X-CSRF-Token` est absent. Bufferise le body | |
| 82 | + /// en RAM (attention aux uploads multipart — ignores). | |
| 83 | + /// Defaut : false (compatibilite avec le comportement existant). | |
| 84 | + pub accept_form_body: bool, | |
| 85 | + /// Taille max du body bufferise. Defaut : 1 MiB. | |
| 86 | + pub max_form_body_bytes: usize, | |
| 87 | +} | |
| 88 | + | |
| 89 | +impl Default for CsrfProtectionConfig { | |
| 90 | + fn default() -> Self { | |
| 91 | + Self { | |
| 92 | + accept_form_body: false, | |
| 93 | + max_form_body_bytes: 1024 * 1024, | |
| 94 | + } | |
| 95 | + } | |
| 96 | +} | |
| 97 | + | |
| 98 | +/// Nouveau constructeur pour apps SSR. | |
| 99 | +pub async fn csrf_protection_with_config( | |
| 100 | + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>, | |
| 101 | + session: Session, | |
| 102 | + uri: Uri, | |
| 103 | + request: Request, | |
| 104 | + next: Next, | |
| 105 | +) -> Result<Response, AppError> { | |
| 106 | + // ... flow existant + branche buffer-body si cfg.accept_form_body | |
| 107 | +} | |
| 108 | +``` | |
| 109 | + | |
| 110 | +**Pros** : change pas l'API existante (backwards-compatible), offre directement la feature aux apps SSR, centralise la logique dans le framework. | |
| 111 | +**Cons** : ajoute de la complexite (buffer-body logic dans le framework), risque edge-cases (multipart/form-data, content-type personnalise, body stream non-buffered). | |
| 112 | + | |
| 113 | +#### Proposition B — Helper `extract_csrf_from_form(body: &[u8]) -> Option<String>` | |
| 114 | + | |
| 115 | +Exporter la logique de parsing en helper public pour que les apps SSR l'utilisent dans leur propre middleware : | |
| 116 | + | |
| 117 | +```rust | |
| 118 | +// Nouveau dans rustwarden_core::middleware::csrf | |
| 119 | +/// Extrait le champ `csrf_token` d'un body `application/x-www-form-urlencoded` | |
| 120 | +/// URL-encode. Retourne `None` si le champ est absent ou le body malforme. | |
| 121 | +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { ... } | |
| 122 | +``` | |
| 123 | + | |
| 124 | +**Pros** : intervention minimale, pas de changement de signature middleware, bibliotheque de petites briques. | |
| 125 | +**Cons** : chaque app SSR doit quand meme ecrire son middleware complet ; duplication reste partielle. | |
| 126 | + | |
| 127 | +#### Proposition C — Feature flag `ssr-forms` qui ajoute un middleware dedie | |
| 128 | + | |
| 129 | +```rust | |
| 130 | +// Dans rustwarden_core (derriere feature = "ssr-forms") | |
| 131 | +pub async fn csrf_protection_ssr( | |
| 132 | + State(db): State<DatabaseConnection>, | |
| 133 | + session: Session, | |
| 134 | + uri: Uri, | |
| 135 | + request: Request, | |
| 136 | + next: Next, | |
| 137 | +) -> Result<Response, AppError> { | |
| 138 | + // Flow complet avec buffer-body + validation | |
| 139 | +} | |
| 140 | +``` | |
| 141 | + | |
| 142 | +**Pros** : isole la logique SSR derriere une feature (pas de overhead pour les apps API-only), nom explicite. | |
| 143 | +**Cons** : eclate l'API CSRF en deux (risque de confusion pour les devs), impose aux apps SSR d'activer la feature. | |
| 144 | + | |
| 145 | +**Recommendation** : proposition A (`accept_form_body` en config). C'est la moins invasive du point de vue de l'API, elle garde le comportement par defaut intact, et elle deplace la complexite buffer-body dans le framework ou elle est testable de facon centralisee. | |
| 146 | + | |
| 147 | +**Criteres d'acceptation** pour le PR upstream : | |
| 148 | +- [ ] `csrf_protection` existante continue de fonctionner sans changement (backwards-compat). | |
| 149 | +- [ ] Nouveau helper `csrf_protection_with_config` disponible avec `accept_form_body: true`. | |
| 150 | +- [ ] Gere proprement `application/x-www-form-urlencoded` : buffer → parse → reconstruct body. | |
| 151 | +- [ ] Refuse `multipart/form-data` avec 415 ou skip (trop risque a bufferiser). | |
| 152 | +- [ ] Respecte `max_form_body_bytes` et renvoie 413 sur depassement. | |
| 153 | +- [ ] Tests unitaires : (a) token en header valide, (b) token en form body valide, (c) header absent + form body absent → 403, (d) mismatch → 403, (e) body > cap → 413. | |
| 154 | +- [ ] Doc inline precisant la trade-off buffer vs streaming. | |
| 155 | + | |
| 156 | +**Migration cote gitrust apres merge upstream** : | |
| 157 | +1. Bump version rustwarden-core dans `Cargo.toml`. | |
| 158 | +2. Supprimer [`admin_csrf_protection`](../crates/gitrust-web/src/middleware.rs) et ses tests. | |
| 159 | +3. Remplacer `.layer(axum::middleware::from_fn(admin_csrf_protection))` par `.layer(axum::middleware::from_fn_with_state((db, Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() })), csrf_protection_with_config))`. | |
| 160 | +4. Supprimer cette section du fichier (ou la deplacer dans `CHANGELOG.md` comme "resolu"). | |
| 161 | + | |
| 162 | +### ~~2. `LayoutContext::new(title, csrf_token)` impose le token en second argument~~ — RESOLU 2026-04-22 | |
| 163 | + | |
| 164 | +**Status** : Implemente en Phase 1.1 du plan d'evolution. | |
| 165 | + | |
| 166 | +Nouveau [`LayoutContext::from_session(session, title)`](../crates/rustwarden-core/src/templates/mod.rs) async qui fetch le token via `get_or_create_csrf_token` avec fallback log+empty sur erreur. Signature existante `new()` inchangee. | |
| 167 | + | |
| 168 | +--- | |
| 169 | + | |
| 170 | +### 2bis. Details historiques (avant resolution) | |
| 171 | + | |
| 172 | +**Contexte** : chaque handler qui rend un template doit fetcher manuellement le token et le passer a `LayoutContext::new`. 27+ occurrences de `LayoutContext::new(title, "")` dans gitrust-web passent un token vide, ce qui casse silencieusement le CSRF cote client. | |
| 173 | + | |
| 174 | +**Limitation** : la signature de [`LayoutContext::new(title: &str, csrf_token: &str)`](../crates/rustwarden-core/src/templates.rs) traite le token comme un parametre obligatoire sans possibilite de remplissage automatique. Il n'existe pas de helper `LayoutContext::from_session(session, title)` ou `LayoutContext::builder().title(...).finish_async(&session)`. | |
| 175 | + | |
| 176 | +**Proposition** : ajouter un constructeur async qui fait le fetch du token : | |
| 177 | + | |
| 178 | +```rust | |
| 179 | +impl LayoutContext { | |
| 180 | + /// Version async qui peuple `csrf_token` depuis la session tower-sessions. | |
| 181 | + /// Fallback silencieux sur `""` si la session est indisponible (log warn). | |
| 182 | + pub async fn from_session(session: &tower_sessions::Session, title: &str) -> Self { | |
| 183 | + let token = rustwarden_core::middleware::csrf::get_or_create_csrf_token(session) | |
| 184 | + .await | |
| 185 | + .unwrap_or_default(); | |
| 186 | + Self::new(title, &token) | |
| 187 | + } | |
| 188 | +} | |
| 189 | +``` | |
| 190 | + | |
| 191 | +**Pros** : reduit le boilerplate par handler, empeche le pattern "`LayoutContext::new(title, "")`" qui echappe aux revues. | |
| 192 | +**Cons** : nouvelle dependance entre `templates` et `middleware::csrf` (cross-module). Alternative : placer le helper dans `crate::middleware::csrf` plutot que dans `templates`. | |
| 193 | + | |
| 194 | +**Criteres d'acceptation** : | |
| 195 | +- [ ] Pas de changement de signature sur `LayoutContext::new` (backwards-compat). | |
| 196 | +- [ ] Nouveau `from_session` disponible, teste avec une session mock. | |
| 197 | +- [ ] Doc inline avec exemple de handler qui l'utilise. | |
| 198 | + | |
| 199 | +**Migration cote gitrust apres merge** : | |
| 200 | +1. Remplacer tous les `LayoutContext::new(title, &csrf_token)` par `LayoutContext::from_session(&session, title).await`. | |
| 201 | +2. Retirer les appels `get_or_create_csrf_token` qui etaient inlines avant. | |
| 202 | + | |
| 203 | +### ~~3. `csrf_protection_impl` redondant avec `csrf_protection`~~ — RESOLU 2026-04-22 | |
| 204 | + | |
| 205 | +**Status** : Implemente en Phase 1.2 du plan d'evolution. | |
| 206 | + | |
| 207 | +Factorise via la fonction interne `csrf_check` + enum `CsrfOutcome`. Les deux publics (`csrf_protection`, `csrf_protection_impl`, plus le nouveau `csrf_protection_with_config`) deviennent des wrappers minces (3-4 lignes chacun). ~80 lignes de duplication supprimees. | |
| 208 | + | |
| 209 | +--- | |
| 210 | + | |
| 211 | +### 3bis. Details historiques (avant resolution) | |
| 212 | + | |
| 213 | +**Contexte** : le framework expose deux middlewares quasi-identiques ([csrf.rs:123](../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [csrf.rs:175](../crates/rustwarden-core/src/middleware/csrf.rs#L175)). Seule difference : `csrf_protection_impl` prend `db` par reference au lieu de passer par `State<DatabaseConnection>`. | |
| 214 | + | |
| 215 | +**Proposition** : deduplication via une fonction interne commune + 2 wrappers : | |
| 216 | +```rust | |
| 217 | +async fn csrf_protection_inner(session: &Session, request: &Request) -> Result<Option<Response>, AppError> { ... } | |
| 218 | +pub async fn csrf_protection(State(_db), session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 219 | +pub async fn csrf_protection_impl(_db, session, uri, request, next) -> Result<Response, AppError> { ... } | |
| 220 | +``` | |
| 221 | + | |
| 222 | +**Priorite** : basse (DRY seulement, pas de bug). | |
| 223 | + | |
| 224 | +### ~~4. Pas de service Personal Access Tokens (PAT)~~ — RESOLU 2026-04-23 | |
| 225 | + | |
| 226 | +**Status** : Implemente dans Phase 4 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 227 | + | |
| 228 | +Livre : | |
| 229 | +- Migration [`m20260422_000007_create_personal_access_tokens`](../crates/rustwarden-core/migrations/src/m20260422_000007_create_personal_access_tokens.rs) (table + indexes unique `(user_id, name)`, `token_hash`, `expires_at`). | |
| 230 | +- Entity SeaORM [`personal_access_token::Model`](../crates/rustwarden-core/src/models/personal_access_token.rs) avec helpers `is_expired`, `is_revoked`, `is_active`. | |
| 231 | +- Service generique [`PersonalTokenService<S: TokenScope>`](../crates/rustwarden-core/src/services/personal_token_service.rs) : `create`, `verify`, `list_for_user`, `revoke`, `cleanup_expired`. Hash SHA-256 constant-time, `last_used_at` best-effort, revoke idempotent + ownership check. | |
| 232 | +- Background cleanup task [`PersonalTokenCleanupProcessor<S>`](../crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) (config `PERSONAL_TOKEN_CLEANUP_INTERVAL_SECS`, default 3600s). | |
| 233 | +- Prelude exporte `PersonalTokenService` et `TokenScope`. | |
| 234 | +- 15 tests (5 entity + 10 integration DB). | |
| 235 | + | |
| 236 | +--- | |
| 237 | + | |
| 238 | +### 4bis. Details historiques (avant resolution) | |
| 239 | + | |
| 240 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 11). | |
| 241 | + | |
| 242 | +**Contexte** : gitrust a besoin d'une auth API long terme pour `git clone/push` via HTTPS (Smart HTTP — LOT 10). Le workflow est : l'utilisateur genere un token depuis l'UI, le colle dans `git config` ou dans l'URL `https://user:token@host/repo.git`, et le client Git le renvoie identique a chaque appel. | |
| 243 | + | |
| 244 | +**Limitation du framework** : [`RefreshTokenService`](../crates/rustwarden-core/src/services/refresh_token_service.rs) gere uniquement la rotation JWT (un usage = un nouveau token, detection de reuse, plafond `MAX_ACTIVE_TOKENS_PER_USER=10`). Ce modele est **incompatible** avec un PAT statique : `git push` ne peut pas "tourner" le token entre deux invocations. Aucun service ne couvre : | |
| 245 | +- Token SHA-256 en base, verifie a chaque appel (pas de rotation). | |
| 246 | +- Scopes structures (`read:repo`, `write:repo`, `admin:repo`). | |
| 247 | +- `last_used_at` pour UX et nettoyage. | |
| 248 | +- Expiration optionnelle (`expires_at NULL` = token permanent). | |
| 249 | + | |
| 250 | +**Workaround actuel cote gitrust** : non demarre — LOT 11 attend la decision. Piste pretraitee : | |
| 251 | +- Migration `personal_access_tokens` (id, user_id, name, token_hash, scopes text[], expires_at, last_used_at, created_at). | |
| 252 | +- Service `gitrust-core/src/services/personal_token_service.rs` calque sur `RefreshTokenService` (hash SHA-256, cleanup tache de fond). | |
| 253 | +- Reutiliser `TokenHash` (Zeroize) deja present dans `gitrust-core/src/types.rs`. | |
| 254 | +- Extension de `AuthUser` : extractor gitrust local qui tente `Authorization: Bearer <jwt>` (framework) puis `Authorization: Bearer gtr_<pat>` (gitrust). | |
| 255 | + | |
| 256 | +**Proposition upstream** : `PersonalTokenService<S: TokenScope>` generique avec trait de scope parametrable par consommateur. | |
| 12 | 257 | |
| 13 | 258 | ```rust |
| 14 | -pub async fn delete_user( | |
| 15 | - db: &DatabaseConnection, | |
| 16 | - user_id: Uuid, | |
| 17 | - hooks: Option<&dyn RustwardenHooks>, | |
| 18 | -) -> Result<(), AppError> | |
| 259 | +pub trait TokenScope: Sized + Clone + Send + Sync + 'static { | |
| 260 | + fn as_str(&self) -> &str; | |
| 261 | + fn parse(s: &str) -> Option<Self>; | |
| 262 | +} | |
| 263 | + | |
| 264 | +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> } | |
| 265 | + | |
| 266 | +impl<S: TokenScope> PersonalTokenService<S> { | |
| 267 | + pub async fn create(db, user_id, name, scopes: Vec<S>, expires_at) -> Result<(PatRecord, String /*clair*/)>; | |
| 268 | + pub async fn verify(db, token_clear: &str) -> Result<(PatRecord, Vec<S>)>; | |
| 269 | + pub async fn list_for_user(db, user_id) -> Result<Vec<PatRecord>>; | |
| 270 | + pub async fn revoke(db, token_id, user_id) -> Result<()>; | |
| 271 | + pub async fn cleanup_expired(db) -> Result<u64>; | |
| 272 | +} | |
| 19 | 273 | ``` |
| 20 | 274 | |
| 21 | -Le parametre `hooks` est optionnel (`None` si pas de nettoyage metier necessaire). | |
| 275 | +Gitrust instancierait `PersonalTokenService<RepoScope>`, PasterMan `PersonalTokenService<WallScope>`. | |
| 276 | + | |
| 277 | +**Pros** : couvre tous les apps qui veulent une auth API statique. Mutualise le hash/scopes/cleanup. Prefix de token (`gtr_`, `ptm_`) reste au choix de l'app. | |
| 278 | +**Cons** : trait `TokenScope` impose un vocabulaire partage. Alternative : scopes en `Vec<String>` et validation cote app (moins type-safe). | |
| 279 | + | |
| 280 | +**Criteres d'acceptation** : | |
| 281 | +- [ ] Migration generique `personal_access_tokens` ajoutable au migrator de l'app. | |
| 282 | +- [ ] Hash SHA-256 en base, token clair jamais stocke. | |
| 283 | +- [ ] API `create`/`verify`/`list`/`revoke`/`cleanup_expired`. | |
| 284 | +- [ ] Tests : creation + verify ok, scope mismatch, token expire, revoke idempotent. | |
| 285 | +- [ ] Documentation du pattern d'extension `AuthUser` cote consommateur (tentative JWT puis PAT). | |
| 286 | + | |
| 287 | +**Migration cote gitrust apres merge** : | |
| 288 | +1. Bump `rustwarden-core`. | |
| 289 | +2. Remplacer `personal_token_service` local par `PersonalTokenService<RepoScope>`. | |
| 290 | +3. Garder le prefix `gtr_` et l'extractor d'auth gitrust (specifique au protocole Smart HTTP). | |
| 291 | + | |
| 292 | +### ~~5. Pas de TeamService (groupes d'utilisateurs)~~ — RESOLU 2026-04-23 | |
| 293 | + | |
| 294 | +**Status** : Implemente en Phase 5 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 295 | + | |
| 296 | +Livre : | |
| 297 | +- Migration [`m20260423_000008_create_teams`](../crates/rustwarden-core/migrations/src/m20260423_000008_create_teams.rs) : tables `teams` + `team_members` (composite PK), evolution `resource_shares` avec `shared_with_team_id` + CHECK constraint (exactement un de `user_id`/`team_id` non-null) + partial unique indexes par principal. | |
| 298 | +- Value object [`TeamSlug`](../crates/rustwarden-core/src/types/team_slug.rs) : `[a-z0-9-]{1,64}`, no leading/trailing `-`, 15 reserved names. 8 tests. | |
| 299 | +- [`TeamService`](../crates/rustwarden-core/src/services/team_service.rs) : `create`, `find_by_id/slug`, `add_member` (idempotent + role update), `remove_member` (refuse de supprimer l'owner), `list_members`, `teams_of_user`, `team_ids_of_user`, `delete`. Hooks `on_team_*` cables. 8 integration tests. | |
| 300 | +- [`ResourceService::Principal`](../crates/rustwarden-core/src/services/resource_service.rs) enum (`User | Team`) + `share_with` + `effective_permission_for_user` qui calcule `max(direct_user_share, max(team_shares))`. Legacy `share()` conserve comme wrapper. 6 integration tests couvrant : user-principal backwards-compat, owner share refuse, team grant a tous membres, max cross user+teams, remove from team drop access, idempotent team upsert. | |
| 301 | +- Prelude exporte `Principal`, `TeamService`, `TeamRole`, `TeamSlug`. | |
| 302 | + | |
| 303 | +Total : 22 tests nouveaux sur DB reelle, 0 regression. | |
| 22 | 304 | |
| 23 | 305 | --- |
| 24 | 306 | |
| 25 | -## 2. `UserService::unassign_role_from_user` — IMPLEMENTE | |
| 307 | +### 5bis. Details historiques (avant resolution) | |
| 26 | 308 | |
| 27 | -**Statut : IMPLEMENTE** | |
| 309 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOT 14). | |
| 28 | 310 | |
| 29 | -Methode ajoutee avec protection contre le retrait du role admin au dernier administrateur. | |
| 311 | +**Contexte** : gitrust veut permettre le partage d'un repo a une **equipe** (ex. `@backend`), pas seulement a des utilisateurs individuels. C'est une attente standard (GitHub, GitLab, Forgejo). PasterMan a le meme besoin pour partager des walls a une equipe. | |
| 312 | + | |
| 313 | +**Limitation du framework** : rustwarden-core n'expose que : | |
| 314 | +- Des **roles globaux** (`admin`, `user`, `app_manager`) via `RoleService`. | |
| 315 | +- Un **partage 1-to-1** (`ResourceService::share(user_id, permission)`). | |
| 316 | + | |
| 317 | +**Aucune notion de team/groupe.** La section gitrust de [CLAUDE.md](../CLAUDE.md) mentionnait "teams upstream" : c'etait une supposition incorrecte, a corriger. | |
| 318 | + | |
| 319 | +**Workaround actuel cote gitrust** : non demarre — LOT 14 attend la decision. Piste pretraitee : | |
| 320 | +- Migrations `teams` (slug, name, owner_id, created_at) + `team_members` (team_id, user_id, role, joined_at). | |
| 321 | +- Service `gitrust-core/src/services/team_service.rs` (CRUD + membership + validation slug via `TeamSlug` deja present). | |
| 322 | +- Bridge `ResourceService` : partage d'un repo a une team = expansion en partages individuels au moment du check ACL, OU extension `ResourceService::share(shared_with_id, shared_with_type: "user" | "team")`. | |
| 323 | + | |
| 324 | +**Proposition upstream** : `TeamService` generique + evolution de `ResourceService::share` pour accepter `shared_with_type`. | |
| 30 | 325 | |
| 31 | 326 | ```rust |
| 32 | -pub async fn unassign_role_from_user( | |
| 33 | - db: &DatabaseConnection, | |
| 34 | - user_id: Uuid, | |
| 35 | - role_id: Uuid, | |
| 36 | -) -> Result<(), AppError> | |
| 327 | +pub struct TeamService; | |
| 328 | + | |
| 329 | +impl TeamService { | |
| 330 | + pub async fn create(db, owner_id, slug, name) -> Result<TeamRecord>; | |
| 331 | + pub async fn add_member(db, team_id, user_id, role: TeamRole) -> Result<()>; | |
| 332 | + pub async fn remove_member(db, team_id, user_id) -> Result<()>; | |
| 333 | + pub async fn list_members(db, team_id) -> Result<Vec<(UserRecord, TeamRole)>>; | |
| 334 | + pub async fn teams_of_user(db, user_id) -> Result<Vec<TeamRecord>>; | |
| 335 | + pub async fn delete(db, team_id, hooks: &Arc<dyn RustwardenHooks>) -> Result<()>; | |
| 336 | +} | |
| 337 | + | |
| 338 | +// Evolution de ResourceService | |
| 339 | +pub enum Principal { User(Uuid), Team(Uuid) } | |
| 340 | +impl ResourceService { | |
| 341 | + pub async fn share_with(db, resource_type, resource_id, principal: Principal, permission) -> Result<()>; | |
| 342 | + pub async fn effective_permission_for_user(db, resource_type, resource_id, user_id) -> Result<Option<Permission>>; | |
| 343 | + // Calcule max(perm_direct, max(perm_via_teams)) | |
| 344 | +} | |
| 37 | 345 | ``` |
| 38 | 346 | |
| 347 | +**Pros** : pattern reutilisable (gitrust, PasterMan). La resolution `effective_permission_for_user` encapsule la logique d'expansion (transparente pour le consommateur). Hooks `on_team_created/deleted/member_added/removed` a ajouter pour les consommateurs qui veulent un audit. | |
| 348 | +**Cons** : touche a `ResourceService` (service central) — risque de regression. Besoin de tests de charge (expansion team peut balayer N utilisateurs a chaque check ACL — prevoir cache ou requete SQL join). | |
| 349 | + | |
| 350 | +**Criteres d'acceptation** : | |
| 351 | +- [ ] Migration `teams` + `team_members` ajoutable au migrator. | |
| 352 | +- [ ] `TeamSlug` (reserved names, lowercase, 1-64 chars) — peut reutiliser le pattern gitrust. | |
| 353 | +- [ ] `ResourceService::effective_permission_for_user` prend en compte memberships (test dedie). | |
| 354 | +- [ ] Hooks `on_team_*` ajoutes a `RustwardenHooks` (cf. gap-7 ci-dessous). | |
| 355 | +- [ ] Suppression d'une team : cascade sur `team_members` + revocation des shares `Team(id)`. | |
| 356 | +- [ ] Tests : user membre de 2 teams avec permissions differentes → max l'emporte. User retire d'une team perd l'acces au repo si c'etait sa seule source. | |
| 357 | + | |
| 358 | +**Migration cote gitrust apres merge** : | |
| 359 | +1. Bump `rustwarden-core`. | |
| 360 | +2. Corriger [CLAUDE.md](../CLAUDE.md) section "Roles" — referencer `TeamService` framework. | |
| 361 | +3. Ajouter UI `/teams` + templates. | |
| 362 | +4. Etendre `RepositoryService` pour appeler `ResourceService::share_with(Principal::Team(team_id))`. | |
| 363 | + | |
| 364 | +### ~~6. Pas de RateLimitService generique expose~~ — RESOLU 2026-04-22 | |
| 365 | + | |
| 366 | +**Status** : Implemente en Phase 2.3 du plan d'evolution. | |
| 367 | + | |
| 368 | +Livre : | |
| 369 | +- [`RateLimitService`](../crates/rustwarden-core/src/services/rate_limit_service.rs) generique : `check`, `check_window` (fixed), `check_sliding_window` (strict), `cleanup_loop`. | |
| 370 | +- [`AppError::TooManyRequests { retry_after_secs }`](../crates/rustwarden-core/src/error.rs) → HTTP 429 + `Retry-After` header + body JSON. | |
| 371 | +- 5 call-sites internes dedupliques : `AuthRateLimiter` + `PublicShareRateLimiter` (rate_limit middleware) + password_change + password_reset + email_validation services — tous migrent vers `RateLimitService` avec prefixes de cle (`auth:{ip}`, `share:{ip}`, `pwd_change:{user_id}`, etc.). | |
| 372 | +- Bonus : race TOCTOU elimine (check+record atomique), `oauth_handlers` renvoie desormais 429 au lieu d'un faux 400. | |
| 373 | + | |
| 39 | 374 | --- |
| 40 | 375 | |
| 41 | -## 3. `UserService::admin_reset_password` — IMPLEMENTE | |
| 376 | +### 6bis. Details historiques (avant resolution) | |
| 377 | + | |
| 378 | +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-preview-rate-limit.md`). | |
| 42 | 379 | |
| 43 | -**Statut : IMPLEMENTE** | |
| 380 | +**Contexte** : gitrust doit rate-limiter les endpoints d'import (`GET /import/preview`, `POST /import`) avec des cles arbitraires et un TTL configurable par env var (`RATE_LIMIT_IMPORT_PREVIEW_PER_MINUTE=120`). Le meme besoin revient pour tout endpoint critique (future API PAT, webhook receive-pack, etc.). | |
| 44 | 381 | |
| 45 | -Methode ajoutee avec : | |
| 46 | -- Hash bcrypt async (via `spawn_blocking`) | |
| 47 | -- `must_change_password = true` force au prochain login | |
| 48 | -- `password_changed_at` mis a jour | |
| 49 | -- SEC-M7 : revocation de tous les refresh tokens | |
| 382 | +**Limitation du framework** : les rate limiters existants ([password_change_service](../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service](../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service](../crates/rustwarden-core/src/services/email_validation_service.rs)) sont : | |
| 383 | +- **Internes** a chaque service, non reutilisables. | |
| 384 | +- **Cles hardcodees** (`user_id`, `email`) — impossible de passer une cle arbitraire. | |
| 385 | +- Pas d'API publique `RateLimitService::check(key, per_minute)`. | |
| 386 | + | |
| 387 | +Resultat : chaque app reinvente son rate limiter (gitrust l'a deja fait pour la login dans Phase 4 via `check_auth_rate_limit`, c'est un bon exemple mais specifique login). | |
| 388 | + | |
| 389 | +**Workaround actuel cote gitrust** : non demarre pour l'import (plan `import-preview-rate-limit.md` en attente). Piste pretraitee : | |
| 390 | +- Module `gitrust-core/src/rate_limit.rs` : rate limiter in-process (DashMap<String, WindowCounter>) avec fenetre glissante. | |
| 391 | +- Signature : `check_rate_limit(key: &str, per_minute: u32) -> Result<(), AppError::TooManyRequests>`. | |
| 392 | +- Pas de persistence (reset au restart acceptable pour ce scope). | |
| 393 | + | |
| 394 | +**Proposition upstream** : extraire un `RateLimitService` public generique. Refactor des 3 rate limiters existants pour l'utiliser (dog-fooding). | |
| 50 | 395 | |
| 51 | 396 | ```rust |
| 52 | -pub async fn admin_reset_password( | |
| 53 | - db: &DatabaseConnection, | |
| 54 | - user_id: Uuid, | |
| 55 | - new_password: &str, | |
| 56 | -) -> Result<(), AppError> | |
| 397 | +pub struct RateLimitService { | |
| 398 | + buckets: Arc<DashMap<String, WindowCounter>>, | |
| 399 | +} | |
| 400 | + | |
| 401 | +impl RateLimitService { | |
| 402 | + pub fn new() -> Self; | |
| 403 | + | |
| 404 | + /// Verifie et incremente. Renvoie Err(TooManyRequests) si depassement. | |
| 405 | + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>; | |
| 406 | + | |
| 407 | + /// Variante avec fenetre custom (ex. 1h pour reset password). | |
| 408 | + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>; | |
| 409 | + | |
| 410 | + /// Purge periodique des buckets expires (tache de fond). | |
| 411 | + pub async fn cleanup_loop(&self, interval: Duration); | |
| 412 | +} | |
| 57 | 413 | ``` |
| 58 | 414 | |
| 415 | +Ajouter `AppError::TooManyRequests { retry_after_secs: u64 }` mappe sur HTTP 429 avec header `Retry-After`. | |
| 416 | + | |
| 417 | +**Pros** : mutualise 3 impls existantes. API generique servable a tout consommateur. `AppError::TooManyRequests` standardise la reponse 429 (absent aujourd'hui dans `AppError`). | |
| 418 | +**Cons** : migration interne du framework (refactor des 3 services existants) — risque de regression. Impl in-process seulement ; pour Redis multi-instance, prevoir un trait `RateLimitBackend` (out-of-scope v1). | |
| 419 | + | |
| 420 | +**Criteres d'acceptation** : | |
| 421 | +- [ ] `RateLimitService::check(key, per_minute)` public. | |
| 422 | +- [ ] `AppError::TooManyRequests { retry_after_secs }` avec mapping HTTP 429 + header `Retry-After`. | |
| 423 | +- [ ] `PasswordChangeService`, `PasswordResetService`, `EmailValidationService` refactors pour utiliser le service (tests existants passent sans modif). | |
| 424 | +- [ ] Cleanup loop documente (evite la fuite memoire sur les cles mortes). | |
| 425 | +- [ ] Tests : 10 appels OK dans la fenetre, 11e refuse, expiration de la fenetre libere. | |
| 426 | + | |
| 427 | +**Migration cote gitrust apres merge** : | |
| 428 | +1. Bump `rustwarden-core`. | |
| 429 | +2. Supprimer le module gitrust `rate_limit.rs` si cree temporairement. | |
| 430 | +3. Injecter `RateLimitService` en Extension dans `main.rs` et l'appeler dans les handlers d'import. | |
| 431 | + | |
| 432 | +### ~~7. Pas de SSE broadcaster (bus de notifications temps reel)~~ — RESOLU 2026-04-23 | |
| 433 | + | |
| 434 | +**Status** : Implemente dans Phase 6 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 435 | + | |
| 436 | +Livre : | |
| 437 | +- Trait [`NotificationBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) generique avec `publish` et `subscribe(filter)`. | |
| 438 | +- Impl par defaut [`InMemoryBus<T>`](../crates/rustwarden-core/src/notifications/mod.rs) basee sur `tokio::sync::broadcast` — slow consumer drop proprement via `BroadcastStream::filter_map`, publisher ne panique pas sur receiver drop. | |
| 439 | +- Helper axum [`sse::sse_response`](../crates/rustwarden-core/src/notifications/sse.rs) avec `KeepAlive(30s)` + JSON serialisation + drop silencieux des events non serialisables. | |
| 440 | +- 7 tests (no-subscriber noop, delivery, multi-subscribers, filter, drop-safe, lag handling, sse response integration). | |
| 441 | +- Feature `redis-bus` documentee comme extension v2 (hors scope v1). | |
| 442 | + | |
| 59 | 443 | --- |
| 60 | 444 | |
| 61 | -## 4. `UserService::change_password_direct` — IMPLEMENTE | |
| 445 | +### 7bis. Details historiques (avant resolution) | |
| 446 | + | |
| 447 | +**Date de detection** : 2026-04-21 (inventaire dette technique — plan `import-async-worker.md`). | |
| 448 | + | |
| 449 | +**Contexte** : plusieurs fonctionnalites gitrust necessitent de pousser des evenements temps reel au navigateur via Server-Sent Events : | |
| 450 | +- Barre de progression d'import de repo (`GET /imports/:id/stream`). | |
| 451 | +- Notifications CI (deja en prod cote gitrust — Phase 5). | |
| 452 | +- Futurs besoins : live logs push/receive-pack, notifications d'issue/PR. | |
| 62 | 453 | |
| 63 | -**Statut : IMPLEMENTE** | |
| 454 | +**Limitation du framework** : aucun bus de notifications dans rustwarden-core. Le plan initial de l'import async evoquait "reutiliser `sse_broadcaster` framework" — c'etait une supposition erronee (verification grep : aucun `broadcast::`, aucun `axum::response::sse`, aucun `Sse::new` dans `crates/rustwarden-core/`). | |
| 64 | 455 | |
| 65 | -Methode ajoutee avec : | |
| 66 | -- Verification de l'ancien mot de passe | |
| 67 | -- Validation de complexite via `validate_password_complexity` | |
| 68 | -- Hash bcrypt async | |
| 69 | -- `must_change_password = false`, `password_changed_at` mis a jour | |
| 70 | -- SEC-M7 : revocation de tous les refresh tokens | |
| 456 | +**Workaround actuel cote gitrust** : bus SSE existe cote gitrust pour la Phase 5 (notifications + CI live logs). Emplacement a verifier (probable `crates/gitrust-web/src/sse.rs` ou `crates/gitrust-core/src/notifications/`). L'import async-worker viendrait republier dessus ses `ImportProgress { job_id, received_bytes, total_bytes, phase }`. | |
| 457 | + | |
| 458 | +**Proposition upstream** : trait generique `NotificationBus<T>` dans le framework avec impl in-memory par defaut. | |
| 71 | 459 | |
| 72 | 460 | ```rust |
| 73 | -pub async fn change_password_direct( | |
| 74 | - db: &DatabaseConnection, | |
| 75 | - user_id: Uuid, | |
| 76 | - old_password: &str, | |
| 77 | - new_password: &str, | |
| 78 | -) -> Result<(), AppError> | |
| 461 | +#[async_trait] | |
| 462 | +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { | |
| 463 | + /// Publie a tous les abonnes matchant le filtre. | |
| 464 | + async fn publish(&self, event: T); | |
| 465 | + | |
| 466 | + /// Souscrit avec filtre. Retourne un flux tokio-stream. | |
| 467 | + fn subscribe(&self, filter: impl Fn(&T) -> bool + Send + Sync + 'static) | |
| 468 | + -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 469 | +} | |
| 470 | + | |
| 471 | +pub struct InMemoryBus<T> { tx: broadcast::Sender<T> } | |
| 472 | +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { ... } | |
| 473 | + | |
| 474 | +// Helper axum pour convertir une souscription en reponse SSE | |
| 475 | +pub fn sse_response<T: Serialize>(stream: impl Stream<Item = T>) -> Sse<impl Stream<...>>; | |
| 79 | 476 | ``` |
| 80 | 477 | |
| 478 | +Feature optionnelle `redis-bus` pour impl multi-instance. | |
| 479 | + | |
| 480 | +**Pros** : reutilisable par toutes les apps (gitrust import/CI, PasterMan wall updates). Le helper `sse_response` encapsule `KeepAlive` + serialisation JSON + gestion deconnexion (boilerplate aujourd'hui repete). | |
| 481 | +**Cons** : generique `T` complique (certains consommateurs voudront un bus global heterogene — alternative : `NotificationBus<serde_json::Value>` avec `event_type` tag). Impl Redis feature-gated pour eviter de tirer la dep. | |
| 482 | + | |
| 483 | +**Criteres d'acceptation** : | |
| 484 | +- [ ] Trait `NotificationBus<T>` + impl `InMemoryBus` sur `tokio::sync::broadcast`. | |
| 485 | +- [ ] Helper `sse_response(stream)` produisant `axum::response::sse::Sse` avec `KeepAlive` (30s par defaut). | |
| 486 | +- [ ] Gestion de la deconnexion client : le stream drop ne doit pas faire paniquer le publisher. | |
| 487 | +- [ ] Tests : publish + multiple subscribers + filter discriminant + lag handling (broadcast `Lagged`). | |
| 488 | +- [ ] Doc inline avec exemple complet (worker publie, handler souscrit, client JS `EventSource`). | |
| 489 | + | |
| 490 | +**Migration cote gitrust apres merge** : | |
| 491 | +1. Bump `rustwarden-core`. | |
| 492 | +2. Remplacer le bus gitrust local par `InMemoryBus<NotificationEvent>` framework. | |
| 493 | +3. Remplacer les handlers SSE custom par `sse_response(bus.subscribe(filter))`. | |
| 494 | +4. Publier `ImportProgress` sur le bus depuis `import_worker`. | |
| 495 | + | |
| 496 | +### ~~8. Hooks `RustwardenHooks` — manquent audit generique + team events + resource lifecycle~~ — RESOLU 2026-04-22 | |
| 497 | + | |
| 498 | +**Status** : Implemente dans Phase 3 du plan d'evolution ([docs/plans/2026-04-22-upstream-rustwarden-evolution.md](plans/2026-04-22-upstream-rustwarden-evolution.md)). | |
| 499 | + | |
| 500 | +Ajoutes avec impl par defaut no-op (backwards-compatible) : | |
| 501 | +- `on_resource_registered(db, resource_type, resource_id, owner_id)` | |
| 502 | +- `on_resource_unregistered(db, resource_type, resource_id)` | |
| 503 | +- `on_team_created(db, team_id, owner_id)` / `on_team_deleted(db, team_id)` | |
| 504 | +- `on_team_member_added(db, team_id, user_id)` / `on_team_member_removed(db, team_id, user_id)` | |
| 505 | +- `on_audit_event(db, AuditEvent { kind, actor_id, target, metadata })` — generique | |
| 506 | + | |
| 507 | +`AuthLoggingService::log_blocked_login` emet maintenant un `AuditEvent { kind: "auth.login_blocked" }` quand des hooks sont cables. Tests d'integration DB vert (5 tests). | |
| 508 | + | |
| 81 | 509 | --- |
| 82 | 510 | |
| 83 | -## Hook ajoute : `on_user_deleted` | |
| 511 | +### 8bis. Details historiques (avant resolution) | |
| 512 | + | |
| 513 | +**Date de detection** : 2026-04-21 (inventaire dette technique — LOTs 12/13 Issues/PR). | |
| 514 | +**Revision** : 2026-04-22 — audit du code a revele que le trait expose deja 12 hooks (et non 4 comme ecrit initialement). La proposition est recentree sur les vrais manques. | |
| 515 | + | |
| 516 | +**Etat actuel** : [`RustwardenHooks`](../crates/rustwarden-core/src/hooks.rs) expose deja 12 evenements : | |
| 517 | +- User lifecycle : `on_user_registered`, `on_user_login`, `on_user_logout`, `on_user_deleted` | |
| 518 | +- Credentials : `on_password_changed`, `on_email_verified`, `on_totp_enabled`, `on_totp_disabled`, `on_oauth_login` | |
| 519 | +- RBAC : `on_role_changed` | |
| 520 | +- Resources : `on_resource_shared`, `on_resource_unshared` | |
| 521 | + | |
| 522 | +**Manques reels** pour les futurs LOTs gitrust (Issues, Pull Requests, Teams) et pour tout audit transverse, les consommateurs veulent : | |
| 523 | +- Un hook generique `on_audit_event(kind, actor, target, metadata)` pour centraliser la logique de log/notification/indexation sans polluer chaque service. | |
| 524 | +- Des hooks cibles `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` (prerequis du gap-5). | |
| 525 | +- Un hook `on_resource_registered` / `on_resource_unregistered` (gitrust-hooks loggue deja pour "repository" sans hook : code plus propre avec un hook). | |
| 526 | + | |
| 527 | +**Limitation du framework** : le trait est ferme par design (pas de methode par defaut pour les nouveaux variants sans casser les implementeurs). | |
| 84 | 528 | |
| 85 | -**Trait `RustwardenHooks`** — nouveau hook : | |
| 529 | +**Workaround actuel cote gitrust** : | |
| 530 | +- Pour les evenements sensibles : appel direct a [`AuthLoggingService::log_*`](../crates/rustwarden-core/src/services/auth_logging_service.rs) depuis les handlers (pattern deja en place pour ban manuel, acl add). | |
| 531 | +- Pour les Issues/PR : prevoir des hooks **gitrust-locaux** (`GitrustHooks` trait dans `gitrust-core`) sans modifier le framework. Cout : fragmentation (deux systemes de hooks coexistants). | |
| 532 | + | |
| 533 | +**Proposition upstream** : evoluer `RustwardenHooks` en deux temps. | |
| 534 | + | |
| 535 | +**Phase A** — ajouter les hooks manquants avec impl par defaut `async { Ok(()) }` (backwards-compatible) : | |
| 86 | 536 | |
| 87 | 537 | ```rust |
| 88 | -async fn on_user_deleted( | |
| 89 | - &self, | |
| 90 | - _db: &DatabaseConnection, | |
| 91 | - _user_id: Uuid, | |
| 92 | - _username: &str, | |
| 93 | -) -> anyhow::Result<()> { | |
| 94 | - Ok(()) | |
| 538 | +#[async_trait] | |
| 539 | +pub trait RustwardenHooks: Send + Sync { | |
| 540 | + // Existants | |
| 541 | + async fn on_user_registered(&self, user: &UserRecord) -> Result<()> { Ok(()) } | |
| 542 | + async fn on_user_deleted(&self, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 543 | + async fn on_resource_shared(&self, rt: &str, rid: Uuid, target: Uuid, perm: &str) -> Result<()> { Ok(()) } | |
| 544 | + async fn on_resource_unshared(&self, rt: &str, rid: Uuid, target: Uuid) -> Result<()> { Ok(()) } | |
| 545 | + | |
| 546 | + // Nouveaux | |
| 547 | + async fn on_resource_registered(&self, rt: &str, rid: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 548 | + async fn on_resource_unregistered(&self, rt: &str, rid: Uuid) -> Result<()> { Ok(()) } | |
| 549 | + async fn on_team_created(&self, team_id: Uuid, owner_id: Uuid) -> Result<()> { Ok(()) } | |
| 550 | + async fn on_team_deleted(&self, team_id: Uuid) -> Result<()> { Ok(()) } | |
| 551 | + async fn on_team_member_added(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 552 | + async fn on_team_member_removed(&self, team_id: Uuid, user_id: Uuid) -> Result<()> { Ok(()) } | |
| 553 | + | |
| 554 | + // Generique (escape hatch pour evenements custom) | |
| 555 | + async fn on_audit_event(&self, event: AuditEvent<'_>) -> Result<()> { Ok(()) } | |
| 556 | +} | |
| 557 | + | |
| 558 | +pub struct AuditEvent<'a> { | |
| 559 | + pub kind: &'a str, // ex. "issue.created", "pr.merged" | |
| 560 | + pub actor_id: Option<Uuid>, | |
| 561 | + pub target: Option<(&'a str, Uuid)>, // (resource_type, resource_id) | |
| 562 | + pub metadata: &'a serde_json::Value, | |
| 95 | 563 | } |
| 96 | 564 | ``` |
| 97 | 565 | |
| 98 | -Appele par `delete_user` avant la suppression en base. Permet aux consommateurs de | |
| 99 | -nettoyer leurs donnees metier (walls, repos, workspaces, etc.). | |
| 566 | +**Phase B** (optionnelle) — cabler `AuthLoggingService::log_*` pour appeler aussi `on_audit_event` afin que les consommateurs puissent brancher du monitoring sans reecrire la liaison. | |
| 567 | + | |
| 568 | +**Pros** : backwards-compatible (methodes par defaut). `on_audit_event` offre un escape hatch pour les LOTs gitrust (Issues, PR) sans forcer a ajouter un hook framework pour chaque type d'evenement. Prepare le terrain pour le gap-5 (TeamService). | |
| 569 | +**Cons** : proliferation de methodes dans le trait (11 methodes a terme). Alternative : un seul `on_event(event: FrameworkEvent<'_>)` avec enum exhaustif — mais bloque les consommateurs sur l'enum framework. | |
| 570 | + | |
| 571 | +**Criteres d'acceptation** : | |
| 572 | +- [ ] Nouveaux hooks ajoutes avec `async { Ok(()) }` par defaut (`gitrust-hooks` existant compile sans modif). | |
| 573 | +- [ ] `AuditEvent<'_>` documente avec 2-3 exemples d'usage. | |
| 574 | +- [ ] `AuthLoggingService::log_*` appelle `on_audit_event` (phase B). | |
| 575 | +- [ ] Tests : impl vide compile, impl complete recoit bien chaque variant, erreur dans hook est propagee. | |
| 576 | + | |
| 577 | +**Migration cote gitrust apres merge** : | |
| 578 | +1. Bump `rustwarden-core`. | |
| 579 | +2. `gitrust-hooks` implemente `on_resource_registered` (replace la logique actuelle qui filtre par `resource_type == "repository"`). | |
| 580 | +3. Implementer `on_team_*` quand LOT 14 arrive. | |
| 581 | +4. Utiliser `on_audit_event` pour les Issues/PR (LOTs 12/13) au lieu de creer un `GitrustHooks` local. | |
| 100 | 582 | |
| 101 | 583 | --- |
| 102 | 584 | |
| 103 | -## Contournements obsoletes dans les projets consommateurs | |
| 585 | +## Protocole de remontee upstream | |
| 104 | 586 | |
| 105 | -Les contournements suivants peuvent etre remplaces par les methodes du framework : | |
| 587 | +Quand une amelioration est mergee upstream : | |
| 588 | +1. Ajouter une ligne dans `CHANGELOG.md` cote gitrust : `resolved upstream: <nom>`. | |
| 589 | +2. Supprimer la section correspondante de ce fichier. | |
| 590 | +3. Creer un ticket de migration si necessaire (decompactage du workaround + bump de version). | |
| 106 | 591 | |
| 107 | -| Contournement | Remplacer par | | |
| 108 | -|--------------|---------------| | |
| 109 | -| `Entity::delete_by_id()` direct | `UserService::delete_user(db, user_id, Some(&hooks))` | | |
| 110 | -| `user_role::Entity::delete_many()` direct | `UserService::unassign_role_from_user(db, user_id, role_id)` | | |
| 111 | -| `hash_password()` + `update_password_hash()` manuels | `UserService::admin_reset_password(db, user_id, new_password)` | | |
| 112 | -| `verify_password()` + `hash_password()` + `update_password_hash()` | `UserService::change_password_direct(db, user_id, old, new)` | | |
| 592 | +Les sections restent dans ce fichier tant que l'amelioration n'est pas dispo en release stable du framework — c'est la source unique pour eviter d'oublier un workaround actif. |
A
docs/plans/2026-04-22-upstream-rustwarden-evolution.md
+494
-0
@@ -0,0 +1,494 @@
| 1 | +# Plan d'evolution Rustwarden — roadmap upstream etape par etape | |
| 2 | + | |
| 3 | +**Source** : [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) | |
| 4 | +**Date** : 2026-04-22 | |
| 5 | +**Auteur** : Claude Code (audit + planification) | |
| 6 | + | |
| 7 | +## Context | |
| 8 | + | |
| 9 | +Rustwarden est consomme par plusieurs projets (Gitrust, PasterMan). Ces consommateurs ont identifie 8 gaps dans le framework qui les forcent a dupliquer de la logique ou a ecrire des workarounds locaux. Ce plan traduit les propositions du doc upstream en une sequence d'implementation verifiee, ordonnee par impact/risque, avec criteres d'acceptation concrets. | |
| 10 | + | |
| 11 | +**Verification prealable effectuee** (2026-04-22) : | |
| 12 | + | |
| 13 | +| # | Gap | Verdict | | |
| 14 | +|---|---|---| | |
| 15 | +| 1 | CSRF form body | Confirme — [csrf.rs:73-84](../../crates/rustwarden-core/src/middleware/csrf.rs#L73-L84) | | |
| 16 | +| 2 | `LayoutContext::from_session` | Confirme — [templates/mod.rs:42-51](../../crates/rustwarden-core/src/templates/mod.rs#L42-L51) | | |
| 17 | +| 3 | `csrf_protection_impl` duplique | Confirme — [csrf.rs:123](../../crates/rustwarden-core/src/middleware/csrf.rs#L123) et [L175](../../crates/rustwarden-core/src/middleware/csrf.rs#L175) | | |
| 18 | +| 4 | PAT service | Confirme — absent | | |
| 19 | +| 5 | TeamService | Confirme — absent | | |
| 20 | +| 6 | RateLimitService generique | Confirme + aggrave — 2 copies quasi-identiques (`AuthRateLimiter` [L307](../../crates/rustwarden-core/src/middleware/rate_limit.rs#L307) et `PublicShareRateLimiter` [L96](../../crates/rustwarden-core/src/middleware/rate_limit.rs#L96)) + 3 rate limiters inlines dans les services password/email | | |
| 21 | +| 7 | SSE broadcaster | Confirme — absent | | |
| 22 | +| 8 | Hooks limites | **Obsolete a corriger** — [hooks.rs](../../crates/rustwarden-core/src/hooks.rs) expose deja 12 hooks. Manquent vraiment : `on_team_*`, `on_audit_event`, `on_resource_registered/unregistered` | | |
| 23 | + | |
| 24 | +## Principes directeurs | |
| 25 | + | |
| 26 | +- **Backwards-compat systematique** : toute API publique existante reste intacte. Les nouveautes arrivent en ajout (`*_with_config`, `from_session`, nouvelles methodes trait avec defaults). | |
| 27 | +- **Dog-fooding** : chaque nouveau service generique (rate limit, bus SSE) refactore les impls internes existantes pour eviter les duplications. | |
| 28 | +- **Tests d'abord** pour les refactors a risque (Phase 5 ResourceService) : couverture >95% avant modification. | |
| 29 | +- **Bump de version semver** : ajouts mineurs = minor bump, breaking = major (aucun breaking prevu dans ce plan). | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +## Phase 1 — Quick wins (1-2 jours, risque faible) | |
| 34 | + | |
| 35 | +### Etape 1.1 — `LayoutContext::from_session()` | |
| 36 | + | |
| 37 | +**Fichiers** : | |
| 38 | +- [crates/rustwarden-core/src/templates/mod.rs](../../crates/rustwarden-core/src/templates/mod.rs) — ajouter methode async | |
| 39 | +- [crates/rustwarden-core/src/lib.rs](../../crates/rustwarden-core/src/lib.rs) — verifier re-export dans `prelude` | |
| 40 | + | |
| 41 | +**Implementation** : | |
| 42 | +```rust | |
| 43 | +impl LayoutContext { | |
| 44 | + pub async fn from_session(session: &tower_sessions::Session, page_title: &str) -> Self { | |
| 45 | + let token = crate::middleware::csrf::get_or_create_csrf_token(session) | |
| 46 | + .await | |
| 47 | + .unwrap_or_default(); | |
| 48 | + Self::new(page_title, &token) | |
| 49 | + } | |
| 50 | +} | |
| 51 | +``` | |
| 52 | + | |
| 53 | +**Critere d'acceptation** : | |
| 54 | +- [ ] `LayoutContext::new` inchangee. | |
| 55 | +- [ ] Nouvelle methode `from_session` disponible. | |
| 56 | +- [ ] Test unitaire avec session mock (tower-sessions memory store). | |
| 57 | +- [ ] Doc inline avec exemple handler. | |
| 58 | + | |
| 59 | +### Etape 1.2 — Dedup CSRF middleware | |
| 60 | + | |
| 61 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](../../crates/rustwarden-core/src/middleware/csrf.rs) | |
| 62 | + | |
| 63 | +**Implementation** : extraire `csrf_check_inner(session, uri, &request) -> Option<Response>` et faire des deux publics (`csrf_protection`, `csrf_protection_impl`) des wrappers minces. | |
| 64 | + | |
| 65 | +**Critere d'acceptation** : | |
| 66 | +- [ ] Les 2 signatures publiques restent identiques. | |
| 67 | +- [ ] Tests existants passent sans modif. | |
| 68 | +- [ ] Plus de code duplique entre [L123-L172](../../crates/rustwarden-core/src/middleware/csrf.rs#L123-L172) et [L175-L210](../../crates/rustwarden-core/src/middleware/csrf.rs#L175-L210). | |
| 69 | + | |
| 70 | +### Etape 1.3 — Corriger le doc upstream | |
| 71 | + | |
| 72 | +**Fichier** : [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) | |
| 73 | + | |
| 74 | +**Action** : section 8 — remplacer "limites a 4 evenements" par la liste reelle (12 hooks existants) et recentrer la proposition sur les vrais manques. | |
| 75 | + | |
| 76 | +--- | |
| 77 | + | |
| 78 | +## Phase 2 — CSRF SSR + RateLimitService (3-5 jours, risque moyen) | |
| 79 | + | |
| 80 | +### Etape 2.1 — `csrf_protection_with_config(accept_form_body)` | |
| 81 | + | |
| 82 | +**Fichier** : [crates/rustwarden-core/src/middleware/csrf.rs](../../crates/rustwarden-core/src/middleware/csrf.rs) | |
| 83 | + | |
| 84 | +**Dependances a ajouter** : aucune (utiliser `http_body_util::BodyExt::collect` deja transitif via axum). | |
| 85 | + | |
| 86 | +**Implementation** : | |
| 87 | +```rust | |
| 88 | +#[derive(Clone)] | |
| 89 | +pub struct CsrfProtectionConfig { | |
| 90 | + pub accept_form_body: bool, | |
| 91 | + pub max_form_body_bytes: usize, | |
| 92 | +} | |
| 93 | + | |
| 94 | +impl Default for CsrfProtectionConfig { | |
| 95 | + fn default() -> Self { | |
| 96 | + Self { accept_form_body: false, max_form_body_bytes: 1024 * 1024 } | |
| 97 | + } | |
| 98 | +} | |
| 99 | + | |
| 100 | +pub async fn csrf_protection_with_config( | |
| 101 | + State((db, cfg)): State<(DatabaseConnection, Arc<CsrfProtectionConfig>)>, | |
| 102 | + session: Session, | |
| 103 | + uri: Uri, | |
| 104 | + request: Request, | |
| 105 | + next: Next, | |
| 106 | +) -> Result<Response, AppError> { /* ... */ } | |
| 107 | + | |
| 108 | +pub fn extract_csrf_from_form(body: &[u8]) -> Option<String> { /* url-decode */ } | |
| 109 | +``` | |
| 110 | + | |
| 111 | +**Sequence du flow buffered** : | |
| 112 | +1. Safe method → passthrough. | |
| 113 | +2. Bearer → passthrough. | |
| 114 | +3. Header `X-CSRF-Token` present → valider et continuer (pas de buffering). | |
| 115 | +4. Sinon si `accept_form_body` et Content-Type commence par `application/x-www-form-urlencoded` : | |
| 116 | + a. `Body::to_bytes` avec cap `max_form_body_bytes` → 413 si depasse. | |
| 117 | + b. Parse via `form_urlencoded::parse`, extraire `csrf_token`. | |
| 118 | + c. Valider (constant-time). | |
| 119 | + d. Reconstruire `Request::from_parts(parts, Body::from(bytes))`. | |
| 120 | +5. Sinon → 403. | |
| 121 | + | |
| 122 | +**Critere d'acceptation** : | |
| 123 | +- [ ] `csrf_protection` existante inchangee. | |
| 124 | +- [ ] Nouveau wrapper disponible avec config. | |
| 125 | +- [ ] `multipart/form-data` skip avec warning (trop risque). | |
| 126 | +- [ ] Respect `max_form_body_bytes` → 413 + header `Content-Length-Required`. | |
| 127 | +- [ ] Tests : (a) header valide, (b) form token valide, (c) absent, (d) mismatch, (e) body > cap, (f) multipart ignore. | |
| 128 | +- [ ] Doc inline avec exemple d'integration dans `RustwardenApp::builder`. | |
| 129 | + | |
| 130 | +### Etape 2.2 — `AppError::TooManyRequests` | |
| 131 | + | |
| 132 | +**Fichier** : [crates/rustwarden-core/src/error.rs](../../crates/rustwarden-core/src/error.rs) | |
| 133 | + | |
| 134 | +**Implementation** : | |
| 135 | +```rust | |
| 136 | +pub enum AppError { | |
| 137 | + // ... existants | |
| 138 | + TooManyRequests { retry_after_secs: u64 }, | |
| 139 | +} | |
| 140 | + | |
| 141 | +// IntoResponse | |
| 142 | +AppError::TooManyRequests { retry_after_secs } => ( | |
| 143 | + StatusCode::TOO_MANY_REQUESTS, | |
| 144 | + [("Retry-After", retry_after_secs.to_string())], | |
| 145 | + Json(json!({ "error": "Too many requests" })), | |
| 146 | +).into_response(), | |
| 147 | +``` | |
| 148 | + | |
| 149 | +### Etape 2.3 — `RateLimitService` generique | |
| 150 | + | |
| 151 | +**Fichiers** : | |
| 152 | +- Nouveau : `crates/rustwarden-core/src/services/rate_limit_service.rs` | |
| 153 | +- Refactor : [rate_limit.rs](../../crates/rustwarden-core/src/middleware/rate_limit.rs), [password_change_service.rs](../../crates/rustwarden-core/src/services/password_change_service.rs), [password_reset_service.rs](../../crates/rustwarden-core/src/services/password_reset_service.rs), [email_validation_service.rs](../../crates/rustwarden-core/src/services/email_validation_service.rs) | |
| 154 | +- [services/mod.rs](../../crates/rustwarden-core/src/services/mod.rs) — declarer le module. | |
| 155 | + | |
| 156 | +**Dependance** : `dashmap = "6"` (ajouter dans `Cargo.toml` si absent). | |
| 157 | + | |
| 158 | +**Implementation** : | |
| 159 | +```rust | |
| 160 | +pub struct RateLimitService { | |
| 161 | + buckets: Arc<DashMap<String, WindowCounter>>, | |
| 162 | +} | |
| 163 | + | |
| 164 | +struct WindowCounter { count: u32, window_start: Instant } | |
| 165 | + | |
| 166 | +impl RateLimitService { | |
| 167 | + pub fn new() -> Self; | |
| 168 | + pub fn check(&self, key: &str, max_per_minute: u32) -> Result<(), AppError>; | |
| 169 | + pub fn check_window(&self, key: &str, max: u32, window: Duration) -> Result<(), AppError>; | |
| 170 | + pub async fn cleanup_loop(&self, interval: Duration); // task de fond | |
| 171 | +} | |
| 172 | +``` | |
| 173 | + | |
| 174 | +**Refactor dog-food** : remplacer dans cet ordre (tests a chaque etape) : | |
| 175 | +1. `AuthRateLimiter` → `RateLimitService` avec `key = format!("auth:{ip}")`. | |
| 176 | +2. `PublicShareRateLimiter` → idem avec `key = format!("share:{ip}")`. | |
| 177 | +3. Limiteurs inlines dans `password_change_service`, `password_reset_service`, `email_validation_service`. | |
| 178 | + | |
| 179 | +**Critere d'acceptation** : | |
| 180 | +- [ ] `RateLimitService::check(key, per_minute)` public + documente. | |
| 181 | +- [ ] `AppError::TooManyRequests` avec mapping HTTP 429 + `Retry-After`. | |
| 182 | +- [ ] Les 5 call-sites internes utilisent le service (suppression de ~200 lignes dupliquees). | |
| 183 | +- [ ] Tests : 10 OK, 11e refuse, expiration libere, cleanup purge les cles mortes. | |
| 184 | +- [ ] Cleanup loop cable dans `RustwardenApp::builder` (nouvelle background task). | |
| 185 | + | |
| 186 | +--- | |
| 187 | + | |
| 188 | +## Phase 3 — Hooks d'audit et de ressources (2-3 jours, risque faible) | |
| 189 | + | |
| 190 | +### Etape 3.1 — Ajouter hooks manquants | |
| 191 | + | |
| 192 | +**Fichier** : [crates/rustwarden-core/src/hooks.rs](../../crates/rustwarden-core/src/hooks.rs) | |
| 193 | + | |
| 194 | +**Implementation** : | |
| 195 | +```rust | |
| 196 | +pub struct AuditEvent<'a> { | |
| 197 | + pub kind: &'a str, | |
| 198 | + pub actor_id: Option<Uuid>, | |
| 199 | + pub target: Option<(&'a str, Uuid)>, | |
| 200 | + pub metadata: &'a serde_json::Value, | |
| 201 | +} | |
| 202 | + | |
| 203 | +#[async_trait] | |
| 204 | +pub trait RustwardenHooks: Send + Sync { | |
| 205 | + // ... 12 hooks existants inchanges | |
| 206 | + | |
| 207 | + async fn on_resource_registered(&self, _db: &DatabaseConnection, _rt: &str, _rid: Uuid, _owner_id: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 208 | + async fn on_resource_unregistered(&self, _db: &DatabaseConnection, _rt: &str, _rid: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 209 | + async fn on_team_created(&self, _db: &DatabaseConnection, _team_id: Uuid, _owner_id: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 210 | + async fn on_team_deleted(&self, _db: &DatabaseConnection, _team_id: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 211 | + async fn on_team_member_added(&self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 212 | + async fn on_team_member_removed(&self, _db: &DatabaseConnection, _team_id: Uuid, _user_id: Uuid) -> anyhow::Result<()> { Ok(()) } | |
| 213 | + async fn on_audit_event(&self, _db: &DatabaseConnection, _event: AuditEvent<'_>) -> anyhow::Result<()> { Ok(()) } | |
| 214 | +} | |
| 215 | +``` | |
| 216 | + | |
| 217 | +### Etape 3.2 — Cabler `on_audit_event` dans `AuthLoggingService` | |
| 218 | + | |
| 219 | +**Fichier** : [crates/rustwarden-core/src/services/auth_logging.rs](../../crates/rustwarden-core/src/services/auth_logging.rs) | |
| 220 | + | |
| 221 | +**Action** : chaque `log_*` appelle aussi `hooks.on_audit_event(AuditEvent { kind: "auth.login_success", ... })`. Permet aux consommateurs de brancher monitoring/SIEM sans reecrire la liaison. | |
| 222 | + | |
| 223 | +**Critere d'acceptation** : | |
| 224 | +- [ ] Impl existante `NoopHooks` compile sans modif. | |
| 225 | +- [ ] Gitrust-hooks existant compile sans modif (defaults no-op). | |
| 226 | +- [ ] Tests : impl complete recoit chaque nouveau variant ; erreur dans hook propagee (pas swallow). | |
| 227 | +- [ ] Doc inline avec 3 exemples d'usage de `on_audit_event`. | |
| 228 | +- [ ] Section 8 de [UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) marquee "resolu upstream". | |
| 229 | + | |
| 230 | +--- | |
| 231 | + | |
| 232 | +## Phase 4 — PersonalTokenService (5-7 jours, risque moyen) | |
| 233 | + | |
| 234 | +### Etape 4.1 — Migration + entity SeaORM | |
| 235 | + | |
| 236 | +**Fichiers** : | |
| 237 | +- Nouveau : `crates/rustwarden-core/migrations/src/m{YYYYMMDD}_000NNN_personal_access_tokens.rs` | |
| 238 | +- Declarer dans [migrations/src/lib.rs](../../crates/rustwarden-core/migrations/src/lib.rs) **et** [src/migrations.rs](../../crates/rustwarden-core/src/migrations.rs) (double declaration obligatoire — cf. CLAUDE.md). | |
| 239 | +- Nouveau : `crates/rustwarden-core/src/models/personal_access_token.rs` + declaration dans `models/mod.rs`. | |
| 240 | + | |
| 241 | +**Schema** : | |
| 242 | +```sql | |
| 243 | +CREATE TABLE personal_access_tokens ( | |
| 244 | + id UUID PRIMARY KEY, | |
| 245 | + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 246 | + name VARCHAR(128) NOT NULL, | |
| 247 | + token_hash VARCHAR(64) NOT NULL UNIQUE, -- SHA-256 hex | |
| 248 | + scopes TEXT[] NOT NULL DEFAULT '{}', | |
| 249 | + expires_at TIMESTAMPTZ NULL, | |
| 250 | + last_used_at TIMESTAMPTZ NULL, | |
| 251 | + revoked_at TIMESTAMPTZ NULL, | |
| 252 | + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 253 | + CONSTRAINT pat_name_unique_per_user UNIQUE (user_id, name) | |
| 254 | +); | |
| 255 | +CREATE INDEX idx_pat_token_hash ON personal_access_tokens(token_hash); | |
| 256 | +CREATE INDEX idx_pat_user_id ON personal_access_tokens(user_id); | |
| 257 | +``` | |
| 258 | + | |
| 259 | +### Etape 4.2 — `PersonalTokenService<S: TokenScope>` | |
| 260 | + | |
| 261 | +**Fichier** : nouveau `crates/rustwarden-core/src/services/personal_token_service.rs` | |
| 262 | + | |
| 263 | +**Implementation** : | |
| 264 | +```rust | |
| 265 | +pub trait TokenScope: Sized + Clone + Send + Sync + 'static { | |
| 266 | + fn as_str(&self) -> &str; | |
| 267 | + fn parse(s: &str) -> Option<Self>; | |
| 268 | +} | |
| 269 | + | |
| 270 | +pub struct PersonalTokenService<S: TokenScope> { _phantom: PhantomData<S> } | |
| 271 | + | |
| 272 | +impl<S: TokenScope> PersonalTokenService<S> { | |
| 273 | + pub async fn create(db: &DatabaseConnection, user_id: Uuid, name: &str, scopes: Vec<S>, expires_at: Option<DateTime<Utc>>) -> Result<(PatRecord, String)>; | |
| 274 | + pub async fn verify(db: &DatabaseConnection, token_clear: &str) -> Result<(PatRecord, Vec<S>)>; | |
| 275 | + pub async fn list_for_user(db: &DatabaseConnection, user_id: Uuid) -> Result<Vec<PatRecord>>; | |
| 276 | + pub async fn revoke(db: &DatabaseConnection, token_id: Uuid, user_id: Uuid) -> Result<()>; | |
| 277 | + pub async fn cleanup_expired(db: &DatabaseConnection) -> Result<u64>; | |
| 278 | +} | |
| 279 | +``` | |
| 280 | + | |
| 281 | +**Securite** : | |
| 282 | +- Hash : `sha2::Sha256` (32 bytes → 64 hex). Jamais stocker le clair. | |
| 283 | +- Generation : 32 octets random hex (64 chars clair) + prefix au choix du consommateur (`gtr_`, `ptm_`). | |
| 284 | +- Verify : timing-safe via `subtle::ConstantTimeEq` sur le hash. | |
| 285 | +- `last_used_at` : update async (non bloquant). | |
| 286 | + | |
| 287 | +### Etape 4.3 — Background task cleanup | |
| 288 | + | |
| 289 | +**Fichier** : [crates/rustwarden-core/src/config/tasks.rs](../../crates/rustwarden-core/src/config/tasks.rs) | |
| 290 | + | |
| 291 | +**Action** : ajouter `pat_cleanup_task` qui tourne toutes les heures → `PersonalTokenService::cleanup_expired`. | |
| 292 | + | |
| 293 | +**Critere d'acceptation** : | |
| 294 | +- [ ] Migration ajoutable, rollback testee. | |
| 295 | +- [ ] Hash SHA-256 verifie en constant-time. | |
| 296 | +- [ ] API complete (`create`/`verify`/`list`/`revoke`/`cleanup`). | |
| 297 | +- [ ] Tests : create + verify ok, scope match/mismatch, token expire refuse, revoke idempotent, `last_used_at` update. | |
| 298 | +- [ ] Doc : pattern d'extension `AuthUser` cote consommateur (JWT puis PAT). | |
| 299 | +- [ ] Section 4 de [UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) marquee "resolu". | |
| 300 | + | |
| 301 | +--- | |
| 302 | + | |
| 303 | +## Phase 5 — TeamService + ResourceService::Principal (7-10 jours, risque ELEVE) | |
| 304 | + | |
| 305 | +**Prerequis** : Phase 3 mergee (hooks `on_team_*`). | |
| 306 | + | |
| 307 | +### Etape 5.1 — Couverture de test preventive | |
| 308 | + | |
| 309 | +Avant tout touch de `ResourceService` : **porter la couverture a >95%** sur : | |
| 310 | +- [crates/rustwarden-core/src/services/resource_service.rs](../../crates/rustwarden-core/src/services/resource_service.rs) | |
| 311 | +- [crates/rustwarden-core/src/services/permission_service.rs](../../crates/rustwarden-core/src/services/permission_service.rs) | |
| 312 | + | |
| 313 | +Sans filet, le risque de regression sur un service central est inacceptable. | |
| 314 | + | |
| 315 | +### Etape 5.2 — Migrations + entities | |
| 316 | + | |
| 317 | +**Fichiers** : migrations + entities `teams`, `team_members`. | |
| 318 | + | |
| 319 | +**Schema** : | |
| 320 | +```sql | |
| 321 | +CREATE TABLE teams ( | |
| 322 | + id UUID PRIMARY KEY, | |
| 323 | + slug VARCHAR(64) NOT NULL UNIQUE, | |
| 324 | + name VARCHAR(128) NOT NULL, | |
| 325 | + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 326 | + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() | |
| 327 | +); | |
| 328 | +CREATE TABLE team_members ( | |
| 329 | + team_id UUID NOT NULL REFERENCES teams(id) ON DELETE CASCADE, | |
| 330 | + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 331 | + role VARCHAR(32) NOT NULL DEFAULT 'member', -- 'owner' | 'admin' | 'member' | |
| 332 | + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), | |
| 333 | + PRIMARY KEY (team_id, user_id) | |
| 334 | +); | |
| 335 | +CREATE INDEX idx_team_members_user ON team_members(user_id); | |
| 336 | +``` | |
| 337 | + | |
| 338 | +### Etape 5.3 — `TeamSlug` value object | |
| 339 | + | |
| 340 | +**Fichier** : nouveau `crates/rustwarden-core/src/types/team_slug.rs` | |
| 341 | + | |
| 342 | +Regles : lowercase, `[a-z0-9-]{1,64}`, reserved names (`admin`, `api`, `settings`, ...). | |
| 343 | + | |
| 344 | +### Etape 5.4 — `TeamService` | |
| 345 | + | |
| 346 | +**Fichier** : nouveau `crates/rustwarden-core/src/services/team_service.rs` | |
| 347 | + | |
| 348 | +API : `create`, `delete` (avec hook), `add_member`, `remove_member`, `list_members`, `teams_of_user`. | |
| 349 | + | |
| 350 | +### Etape 5.5 — `ResourceService::share_with(Principal)` | |
| 351 | + | |
| 352 | +**Fichier** : [crates/rustwarden-core/src/services/resource_service.rs](../../crates/rustwarden-core/src/services/resource_service.rs) | |
| 353 | + | |
| 354 | +**Implementation** : | |
| 355 | +```rust | |
| 356 | +pub enum Principal { User(Uuid), Team(Uuid) } | |
| 357 | + | |
| 358 | +impl ResourceService { | |
| 359 | + // Nouveau (garder share() existant comme wrapper pour backward-compat) | |
| 360 | + pub async fn share_with(db, rt, rid, principal: Principal, permission) -> Result<()>; | |
| 361 | + | |
| 362 | + // Nouveau : calcul effective permission cote user (inclut team memberships) | |
| 363 | + pub async fn effective_permission_for_user(db, rt, rid, user_id) -> Result<Option<Permission>>; | |
| 364 | +} | |
| 365 | +``` | |
| 366 | + | |
| 367 | +**Requete SQL (single JOIN, pas N+1)** : | |
| 368 | +```sql | |
| 369 | +SELECT MAX(permission_level) FROM resource_shares rs | |
| 370 | +LEFT JOIN team_members tm ON tm.team_id = rs.shared_with_id AND rs.shared_with_type = 'team' | |
| 371 | +WHERE rs.resource_type = $1 AND rs.resource_id = $2 | |
| 372 | + AND ( | |
| 373 | + (rs.shared_with_type = 'user' AND rs.shared_with_id = $3) | |
| 374 | + OR (rs.shared_with_type = 'team' AND tm.user_id = $3) | |
| 375 | + ); | |
| 376 | +``` | |
| 377 | + | |
| 378 | +**Migration resource_shares** : ajouter colonne `shared_with_type VARCHAR(8) NOT NULL DEFAULT 'user'` avec contrainte `CHECK (shared_with_type IN ('user','team'))`. | |
| 379 | + | |
| 380 | +### Etape 5.6 — Tests de charge | |
| 381 | + | |
| 382 | +Verifier : | |
| 383 | +- 1000 users × 10 teams × 100 repos : `effective_permission_for_user` reste < 10ms p99. | |
| 384 | +- Pas de N+1 (utiliser `sea-orm` query log + count). | |
| 385 | + | |
| 386 | +**Critere d'acceptation** : | |
| 387 | +- [ ] `TeamService` + API complete. | |
| 388 | +- [ ] `ResourceService::share_with(Principal::User|Team)` + `effective_permission_for_user`. | |
| 389 | +- [ ] Wrapper de compat : ancien `share(user_id, perm)` appelle `share_with(Principal::User(user_id), perm)`. | |
| 390 | +- [ ] Suppression team → cascade `team_members` + revoque shares `Team(id)`. | |
| 391 | +- [ ] Hooks `on_team_*` appeles. | |
| 392 | +- [ ] Tests : user dans 2 teams (read + write) sur meme repo → effective = write. User retire d'une team perd son acces. | |
| 393 | +- [ ] Perf : p99 < 10ms sur fixture 1000×10×100. | |
| 394 | + | |
| 395 | +--- | |
| 396 | + | |
| 397 | +## Phase 6 — Bus SSE `NotificationBus<T>` (5-7 jours, risque moyen) | |
| 398 | + | |
| 399 | +### Etape 6.1 — Trait + impl in-memory | |
| 400 | + | |
| 401 | +**Fichier** : nouveau `crates/rustwarden-core/src/notifications/mod.rs` (nouveau module `pub mod notifications` dans `lib.rs`). | |
| 402 | + | |
| 403 | +**Implementation** : | |
| 404 | +```rust | |
| 405 | +#[async_trait] | |
| 406 | +pub trait NotificationBus<T: Clone + Send + Sync + 'static>: Send + Sync { | |
| 407 | + async fn publish(&self, event: T); | |
| 408 | + fn subscribe(&self, filter: Arc<dyn Fn(&T) -> bool + Send + Sync>) | |
| 409 | + -> Pin<Box<dyn Stream<Item = T> + Send>>; | |
| 410 | +} | |
| 411 | + | |
| 412 | +pub struct InMemoryBus<T> { | |
| 413 | + tx: tokio::sync::broadcast::Sender<T>, | |
| 414 | +} | |
| 415 | + | |
| 416 | +impl<T: Clone + Send + Sync + 'static> NotificationBus<T> for InMemoryBus<T> { | |
| 417 | + // publish() envoie sur broadcast (drop si channel vide) | |
| 418 | + // subscribe() wrap BroadcastStream + filter_map, gere Lagged proprement | |
| 419 | +} | |
| 420 | +``` | |
| 421 | + | |
| 422 | +### Etape 6.2 — Helper `sse_response` | |
| 423 | + | |
| 424 | +**Fichier** : `crates/rustwarden-core/src/notifications/sse.rs` | |
| 425 | + | |
| 426 | +```rust | |
| 427 | +pub fn sse_response<T: Serialize + Send + 'static>( | |
| 428 | + stream: impl Stream<Item = T> + Send + 'static, | |
| 429 | +) -> Sse<impl Stream<Item = Result<Event, Infallible>>> { | |
| 430 | + Sse::new(stream.map(|ev| Ok(Event::default().json_data(ev).unwrap()))) | |
| 431 | + .keep_alive(KeepAlive::new().interval(Duration::from_secs(30))) | |
| 432 | +} | |
| 433 | +``` | |
| 434 | + | |
| 435 | +### Etape 6.3 — Feature `redis-bus` (optionnelle v2) | |
| 436 | + | |
| 437 | +Hors scope v1 : documenter le point d'extension dans la doc du trait, mais pas d'impl. | |
| 438 | + | |
| 439 | +**Critere d'acceptation** : | |
| 440 | +- [ ] Trait + impl memoire. | |
| 441 | +- [ ] Helper `sse_response` avec KeepAlive 30s. | |
| 442 | +- [ ] Client disconnect → drop du subscriber, pas de panique publisher. | |
| 443 | +- [ ] Gestion `Lagged` : skip events perdus, continue stream. | |
| 444 | +- [ ] Tests : publish + 3 subscribers, filter discriminant, slow consumer. | |
| 445 | +- [ ] Doc complete : worker publie, handler souscrit, client JS `EventSource`. | |
| 446 | + | |
| 447 | +--- | |
| 448 | + | |
| 449 | +## Recapitulatif et ordre d'execution | |
| 450 | + | |
| 451 | +| Phase | Etapes | Effort | Risque | Debloquera | | |
| 452 | +|---|---|---|---|---| | |
| 453 | +| 1 | 1.1-1.3 | 1-2 j | Faible | Reduit boilerplate immediat, DRY | | |
| 454 | +| 2 | 2.1-2.3 | 3-5 j | Moyen | Gitrust SSR CSRF + toutes apps rate-limit | | |
| 455 | +| 3 | 3.1-3.2 | 2-3 j | Faible | Audit centralise, prereq Phase 5 | | |
| 456 | +| 4 | 4.1-4.3 | 5-7 j | Moyen | Gitrust LOT 11 (Smart HTTP PAT) | | |
| 457 | +| 5 | 5.1-5.6 | 7-10 j | **Eleve** | Gitrust LOT 14 (Teams) + PasterMan | | |
| 458 | +| 6 | 6.1-6.3 | 5-7 j | Moyen | Gitrust imports async, CI logs, PasterMan walls | | |
| 459 | + | |
| 460 | +**Chemin critique** : 1 → 2 → 3 → 4 → 6 → 5. Justification : | |
| 461 | +- Phase 5 (Teams) en dernier car touche `ResourceService` qui est central. | |
| 462 | +- Phase 3 avant 5 car hooks `on_team_*` doivent preexister. | |
| 463 | +- Phase 6 (SSE) avant Phase 5 pour livrer de la valeur sans bloquer sur le refactor ResourceService. | |
| 464 | + | |
| 465 | +**Total** : ~25-35 jours dev (hors review et integration consommateurs). | |
| 466 | + | |
| 467 | +## Verification end-to-end par phase | |
| 468 | + | |
| 469 | +Chaque phase se conclut par : | |
| 470 | + | |
| 471 | +```bash | |
| 472 | +# Quality gates (impose par CLAUDE.md) | |
| 473 | +cargo fmt --check | |
| 474 | +cargo clippy -- -D warnings | |
| 475 | +cargo test --workspace | |
| 476 | +cargo doc --no-deps | |
| 477 | + | |
| 478 | +# App de reference tourne | |
| 479 | +cargo run -p rustwarden-app # demarre sur :3000 | |
| 480 | + | |
| 481 | +# Integration consommateur (gitrust) | |
| 482 | +cd ../gitrust && cargo update -p rustwarden-core && cargo test | |
| 483 | +``` | |
| 484 | + | |
| 485 | +Et mise a jour : | |
| 486 | +- [docs/UPSTREAM_RUSTWARDEN.md](../UPSTREAM_RUSTWARDEN.md) — supprimer la section du gap resolu. | |
| 487 | +- `CHANGELOG.md` — ajouter l'entree de version. | |
| 488 | +- Bump `Cargo.toml` (minor pour tous ces ajouts). | |
| 489 | + | |
| 490 | +## Hors scope de ce plan | |
| 491 | + | |
| 492 | +- Impl Redis du bus SSE (feature v2). | |
| 493 | +- Migration des apps consommatrices (Gitrust, PasterMan) : faite apres merge upstream, suit le "Protocole de remontee upstream" du doc source. | |
| 494 | +- CLI wizard update ([tools/rustwarden-cli/src/templates.rs](../../tools/rustwarden-cli/src/templates.rs)) : seulement si l'API du builder change (prevu uniquement Phase 2 pour `csrf_protection_with_config`). |
A
docs/plans/2026-04-23-feedback-app-cahier-des-charges.md
+498
-0
@@ -0,0 +1,498 @@
| 1 | +# Cahier des charges — Application web de gestion des demandes Rustwarden | |
| 2 | + | |
| 3 | +- **Date** : 2026-04-23 | |
| 4 | +- **Auteur** : EBO | |
| 5 | +- **Statut** : Draft (a valider) | |
| 6 | +- **Nom de travail** : `rustwarden-forge` (a arbitrer : alternatives `warden-hub`, `rw-backlog`, `feedback-forge`) | |
| 7 | +- **Type** : Application web externe au framework, dediee a la centralisation et au triage assiste par IA des demandes (bugs, ameliorations, RFC) provenant des apps consommatrices. | |
| 8 | + | |
| 9 | +--- | |
| 10 | + | |
| 11 | +## 1. Vision | |
| 12 | + | |
| 13 | +Construire une **application web autonome** qui sert de point d'entree unique pour toutes les demandes touchant le framework Rustwarden (bugs, ameliorations, RFC). L'application : | |
| 14 | + | |
| 15 | +- Recoit les demandes via UI web, API REST ou webhooks (GitHub, CLI). | |
| 16 | +- Utilise un **agent IA** (Claude API) pour pre-triager, classifier, scorer, detecter les doublons et generer des brouillons de RFC. | |
| 17 | +- Centralise le backlog upstream (P2). | |
| 18 | +- Pilote la boucle de feedback post-release (P6). | |
| 19 | +- Genere automatiquement une partie de la documentation transmise aux consommateurs (P3). | |
| 20 | + | |
| 21 | +**Principe meta** : l'application est elle-meme construite **sur** rustwarden-core (eat your own dogfood). Elle sert de vitrine, de bench reel et de premier consommateur "non-gitrust" pour stresser le framework. | |
| 22 | + | |
| 23 | +--- | |
| 24 | + | |
| 25 | +## 2. Objectifs mesurables | |
| 26 | + | |
| 27 | +| Objectif | Metrique cible | | |
| 28 | +|----------|---------------| | |
| 29 | +| Reduire le temps de triage d'une demande | < 2 min (vs ~15 min manuel aujourd'hui) | | |
| 30 | +| Detecter les doublons | > 90% precision sur jeu de test | | |
| 31 | +| Suggerer la categorie correcte (bug/improvement/RFC) | > 85% accord avec le mainteneur | | |
| 32 | +| Generer un brouillon de RFC accepte avec < 20% de modifications | > 50% des cas | | |
| 33 | +| Tracker l'adoption d'une release par consommateur | 100% visibilite (statut par app) | | |
| 34 | +| Synchro doc SERVICES_REFERENCE avec le code | 0 drift (regen sur tag) | | |
| 35 | + | |
| 36 | +--- | |
| 37 | + | |
| 38 | +## 3. Personas | |
| 39 | + | |
| 40 | +### 3.1 Mainteneur framework (Toi, EBO) | |
| 41 | +- Recoit toutes les demandes, arbitre, merge. | |
| 42 | +- Veut voir un **tableau de bord** avec le backlog priorise. | |
| 43 | +- Veut **valider/corriger** les suggestions de l'agent IA en 1 clic. | |
| 44 | +- Veut **generer un RFC** sans repartir d'une page blanche. | |
| 45 | + | |
| 46 | +### 3.2 Mainteneur d'app consommatrice (Toi en mode "gitrust", futur "PasterMan") | |
| 47 | +- Detecte un gap pendant l'integration. | |
| 48 | +- Veut **soumettre la demande sans quitter son terminal** (CLI) ou via UI rapide. | |
| 49 | +- Veut **suivre l'etat** de ses demandes deposees (acceptee, en cours, livree). | |
| 50 | +- Veut etre **notifie** quand une release ferme une de ses demandes. | |
| 51 | + | |
| 52 | +### 3.3 Agent IA (Claude) | |
| 53 | +- Lit la demande brute. | |
| 54 | +- Produit une fiche structuree (categorie, score, doublons, brouillon RFC si applicable). | |
| 55 | +- Apprend des corrections du mainteneur (RAG sur historique des decisions). | |
| 56 | + | |
| 57 | +### 3.4 Contributeur externe (futur) | |
| 58 | +- Decouvre le framework, soumet un bug. | |
| 59 | +- Doit pouvoir le faire sans compte (webhook depuis issue GitHub) ou avec compte minimal. | |
| 60 | + | |
| 61 | +--- | |
| 62 | + | |
| 63 | +## 4. Perimetre fonctionnel — par phase | |
| 64 | + | |
| 65 | +### Phase 1 : Triage 3 voies (P1) | |
| 66 | + | |
| 67 | +**User stories** : | |
| 68 | +- En tant que consommateur, je depose une demande via formulaire. | |
| 69 | +- L'agent IA classifie automatiquement : `bug` / `improvement` / `rfc`. | |
| 70 | +- Le mainteneur peut **overrider** la classification en 1 clic. | |
| 71 | +- Si `rfc` : un brouillon de RFC est genere a partir du template existant ([RUSTWARDEN_EVOLUTION_PROCESS.md section 3](../RUSTWARDEN_EVOLUTION_PROCESS.md)). | |
| 72 | + | |
| 73 | +**Regles de classification** (heuristiques + LLM) : | |
| 74 | +- Modifie une signature `pub` existante → `rfc` | |
| 75 | +- Touche au schema DB → `rfc` | |
| 76 | +- Change un comportement par defaut → `rfc` | |
| 77 | +- Ajoute une fonctionnalite < 50 LOC, additif, non-breaking → `improvement` | |
| 78 | +- Reproducer + comportement vs attendu → `bug` | |
| 79 | + | |
| 80 | +**Livrables** : | |
| 81 | +- Formulaire de soumission (3 champs minimum : titre, contexte, comportement attendu). | |
| 82 | +- Dashboard de triage avec colonnes Kanban (Inbox / Bug / Improvement / RFC / Rejected). | |
| 83 | +- Action "Override" + justification (alimente l'apprentissage IA). | |
| 84 | + | |
| 85 | +### Phase 2 : Backlog centralise (P2) | |
| 86 | + | |
| 87 | +**User stories** : | |
| 88 | +- Toutes les demandes vivent dans **une seule base** (vs `UPSTREAM_RUSTWARDEN.md` par app). | |
| 89 | +- Filtrage par : consommateur source, categorie, score, statut, tag. | |
| 90 | +- Vue "par consommateur" : ce que gitrust/PasterMan attendent. | |
| 91 | +- Vue "par release cible" : qu'est-ce qui rentre dans la 0.9.0 ? | |
| 92 | + | |
| 93 | +**Livrables** : | |
| 94 | +- Modele de donnees (cf section 6). | |
| 95 | +- Vues filtrables (URL parametree pour bookmark). | |
| 96 | +- Export markdown : regenere `UPSTREAM_<consommateur>.md` a la demande pour conserver la coherence avec l'ecosysteme actuel. | |
| 97 | + | |
| 98 | +### Phase 3 : Generation auto de SERVICES_REFERENCE (P3) | |
| 99 | + | |
| 100 | +**User stories** : | |
| 101 | +- Sur tag git de rustwarden-core, l'app declenche un build de `cargo doc --no-deps` + extraction des items `pub`. | |
| 102 | +- Genere une section "API auto-generee" dans `SERVICES_REFERENCE.md`. | |
| 103 | +- Garde la section "Patterns d'usage" ecrite a la main (intacte). | |
| 104 | +- Ouvre une PR sur le repo framework avec le diff. | |
| 105 | + | |
| 106 | +**Livrables** : | |
| 107 | +- Worker (background task) qui clone rustwarden, lance cargo doc, parse JSON output. | |
| 108 | +- Templates Tera/Askama pour rendre la section auto. | |
| 109 | +- Integration GitHub API pour creer la PR. | |
| 110 | + | |
| 111 | +### Phase 4 : Templates et channels (P4) | |
| 112 | + | |
| 113 | +**User stories** : | |
| 114 | +- Webhook GitHub : nouvelle issue avec label `rustwarden` → ingest auto dans l'app. | |
| 115 | +- Endpoint API REST : `POST /api/demands` (auth par token, depuis CLI consommateur). | |
| 116 | +- Webhook depuis `rustwarden-cli` : `rustwarden-cli report-bug "..."` envoie directement. | |
| 117 | + | |
| 118 | +**Livrables** : | |
| 119 | +- Templates GitHub `.github/ISSUE_TEMPLATE/` deployes sur les repos consommateurs. | |
| 120 | +- Endpoint `POST /api/demands` documente (OpenAPI). | |
| 121 | +- Commande `rustwarden-cli feedback` (sous-commande nouvelle). | |
| 122 | + | |
| 123 | +### Phase 5 : Gate CI consumer-compat (P5) | |
| 124 | + | |
| 125 | +**User stories** : | |
| 126 | +- Sur PR rustwarden-core touchant du `pub`, l'app declenche un build de chaque consommateur enregistre contre la branche RC. | |
| 127 | +- Reporte le statut sur la PR (check vert/rouge). | |
| 128 | +- Liste les API impactees (diff signatures pub). | |
| 129 | + | |
| 130 | +**Livrables** : | |
| 131 | +- GitHub Action `consumer-compat-check.yml` triggere par webhook. | |
| 132 | +- Dashboard "Compat status par PR". | |
| 133 | +- Notification Slack/email si rouge. | |
| 134 | + | |
| 135 | +### Phase 6 : Adoption tracking post-release (P6) | |
| 136 | + | |
| 137 | +**User stories** : | |
| 138 | +- A chaque release rustwarden-core (tag git), creation auto d'un objet "Release Tracker". | |
| 139 | +- Pour chaque consommateur enregistre : statut adoption (Pending / Adopted / Regression). | |
| 140 | +- Le consommateur met a jour son statut via API ou UI. | |
| 141 | +- Si Regression : creation auto d'un bug lie a la release. | |
| 142 | + | |
| 143 | +**Livrables** : | |
| 144 | +- Webhook tag GitHub → creation tracker. | |
| 145 | +- UI consommateur : "valider l'adoption de v0.9.0" en 1 clic. | |
| 146 | +- Email/notif pour rappel J+7 si Pending. | |
| 147 | + | |
| 148 | +--- | |
| 149 | + | |
| 150 | +## 5. Architecture technique | |
| 151 | + | |
| 152 | +### 5.1 Stack (alignee Rustwarden) | |
| 153 | + | |
| 154 | +| Couche | Choix | Justification | | |
| 155 | +|--------|-------|---------------| | |
| 156 | +| Backend | **Rust + Axum 0.8** | Coherence ecosysteme, perf, type safety | | |
| 157 | +| ORM | **SeaORM** | Idem Rustwarden, reuse competences | | |
| 158 | +| Auth | **rustwarden-core** (eat-our-own-dogfood) | Premier consommateur non-gitrust → bench framework | | |
| 159 | +| Templates | **Askama 0.15** | Idem Rustwarden | | |
| 160 | +| Frontend | **HTMX + Alpine.js** | Idem Rustwarden, SSR-first | | |
| 161 | +| CSS | **Tailwind 4 + DaisyUI 5** | Idem Rustwarden | | |
| 162 | +| DB | **PostgreSQL 18** | Idem Rustwarden | | |
| 163 | +| IA | **Claude API (Anthropic SDK Rust)** | Modeles a jour, prompt caching pour RAG historique | | |
| 164 | +| Background | **Tokio + SeaORM polling** | Pas de Redis, simple | | |
| 165 | +| GitHub integration | **octocrab** crate | Standard Rust | | |
| 166 | +| OpenAPI | **utoipa** crate | Doc auto pour `/api` | | |
| 167 | + | |
| 168 | +### 5.2 Modules principaux | |
| 169 | + | |
| 170 | +``` | |
| 171 | +rustwarden-forge/ | |
| 172 | +├── crates/ | |
| 173 | +│ ├── forge-core/ # Domain (demand, release, consumer, classification) | |
| 174 | +│ ├── forge-ai/ # Wrapper Claude API + prompts + RAG | |
| 175 | +│ ├── forge-github/ # Webhooks, PR creation, CI integration | |
| 176 | +│ ├── forge-web/ # Handlers Axum + templates Askama | |
| 177 | +│ └── forge-cli/ # CLI admin (regen doc, replay AI sur historique) | |
| 178 | +├── migrations/ | |
| 179 | +└── docker-compose.yml | |
| 180 | +``` | |
| 181 | + | |
| 182 | +### 5.3 Diagramme de flux (demande type) | |
| 183 | + | |
| 184 | +``` | |
| 185 | +[Consommateur] | |
| 186 | + ├─ via UI web ─→ POST /demands | |
| 187 | + ├─ via CLI ────→ POST /api/demands (token) | |
| 188 | + └─ via GitHub ─→ webhook → ingest | |
| 189 | + | |
| 190 | + ↓ | |
| 191 | +[Forge-web ingest] | |
| 192 | + ├─ persist Demand (status=inbox) | |
| 193 | + └─ enqueue AI job | |
| 194 | + | |
| 195 | + ↓ | |
| 196 | +[Forge-ai worker] | |
| 197 | + ├─ load context (similar past demands via embeddings) | |
| 198 | + ├─ call Claude API (classify + score + duplicates + draft RFC) | |
| 199 | + ├─ persist Classification + DraftRFC | |
| 200 | + └─ notify mainteneur (push/email) | |
| 201 | + | |
| 202 | + ↓ | |
| 203 | +[Mainteneur] | |
| 204 | + ├─ valide/corrige sur dashboard | |
| 205 | + ├─ assigne a une release cible | |
| 206 | + └─ (si RFC) genere fichier docs/rfcs/NNNN-*.md via PR auto | |
| 207 | + | |
| 208 | + ↓ | |
| 209 | +[Implementation manuelle dans rustwarden-core] | |
| 210 | + ↓ | |
| 211 | +[Tag release] | |
| 212 | + ├─ webhook → Forge cree Release Tracker | |
| 213 | + ├─ regen SERVICES_REFERENCE → PR auto | |
| 214 | + └─ notif consommateurs concernes | |
| 215 | +``` | |
| 216 | + | |
| 217 | +--- | |
| 218 | + | |
| 219 | +## 6. Modele de donnees (entites SeaORM) | |
| 220 | + | |
| 221 | +### `consumer` | |
| 222 | +Une app consommatrice du framework. | |
| 223 | +- `id` UUID | |
| 224 | +- `slug` text unique (`gitrust`, `pasterman`) | |
| 225 | +- `name` text | |
| 226 | +- `repo_url` text | |
| 227 | +- `api_token` text (pour POST /api/demands) | |
| 228 | +- `auto_compat_check` bool (active P5) | |
| 229 | +- `created_at` timestamp | |
| 230 | + | |
| 231 | +### `demand` | |
| 232 | +La demande brute deposee. | |
| 233 | +- `id` UUID | |
| 234 | +- `title` text | |
| 235 | +- `body` text (markdown) | |
| 236 | +- `source` enum (`web_ui`, `api_cli`, `github_webhook`) | |
| 237 | +- `consumer_id` FK consumer (nullable pour anonyme) | |
| 238 | +- `external_ref` text (issue GitHub URL si source=webhook) | |
| 239 | +- `status` enum (`inbox`, `triaged`, `accepted`, `rejected`, `in_progress`, `released`, `regression`) | |
| 240 | +- `created_at` / `updated_at` | |
| 241 | + | |
| 242 | +### `classification` | |
| 243 | +Sortie de l'agent IA pour une demande. | |
| 244 | +- `id` UUID | |
| 245 | +- `demand_id` FK | |
| 246 | +- `category` enum (`bug`, `improvement`, `rfc`) | |
| 247 | +- `score` int (0-35, grille existante) | |
| 248 | +- `score_breakdown` jsonb (7 sous-scores) | |
| 249 | +- `confidence` float (0-1) | |
| 250 | +- `duplicates` jsonb (liste demand_ids similaires + similarity) | |
| 251 | +- `reasoning` text (justification IA) | |
| 252 | +- `model_version` text (`claude-opus-4-7`) | |
| 253 | +- `prompt_version` text (versionning des prompts) | |
| 254 | +- `created_at` | |
| 255 | +- `overridden_by` UUID nullable (user_id si humain a corrige) | |
| 256 | +- `override_reason` text nullable | |
| 257 | + | |
| 258 | +### `draft_rfc` | |
| 259 | +Brouillon de RFC genere par l'IA. | |
| 260 | +- `id` UUID | |
| 261 | +- `demand_id` FK | |
| 262 | +- `content` text (markdown complet) | |
| 263 | +- `status` enum (`draft`, `validated`, `published`) | |
| 264 | +- `pr_url` text nullable (si publie via PR) | |
| 265 | + | |
| 266 | +### `release_tracker` | |
| 267 | +Suivi adoption d'une release. | |
| 268 | +- `id` UUID | |
| 269 | +- `version` text (`0.9.0`) | |
| 270 | +- `tag_url` text | |
| 271 | +- `released_at` timestamp | |
| 272 | +- `closed_at` timestamp nullable | |
| 273 | + | |
| 274 | +### `release_adoption` | |
| 275 | +Statut adoption par consommateur. | |
| 276 | +- `release_tracker_id` FK | |
| 277 | +- `consumer_id` FK | |
| 278 | +- `status` enum (`pending`, `adopted`, `regression`) | |
| 279 | +- `notes` text | |
| 280 | +- `updated_at` | |
| 281 | + | |
| 282 | +### `compat_check` | |
| 283 | +Resultat d'un check P5. | |
| 284 | +- `id` UUID | |
| 285 | +- `pr_url` text | |
| 286 | +- `consumer_id` FK | |
| 287 | +- `status` enum (`pending`, `pass`, `fail`) | |
| 288 | +- `log_url` text | |
| 289 | +- `pub_diff` jsonb (liste signatures impactees) | |
| 290 | + | |
| 291 | +### Relations | |
| 292 | +``` | |
| 293 | +consumer 1─N demand | |
| 294 | +demand 1─1 classification | |
| 295 | +demand 0─1 draft_rfc | |
| 296 | +release_tracker 1─N release_adoption | |
| 297 | +consumer 1─N release_adoption | |
| 298 | +consumer 1─N compat_check | |
| 299 | +``` | |
| 300 | + | |
| 301 | +--- | |
| 302 | + | |
| 303 | +## 7. Agent IA — design detaille | |
| 304 | + | |
| 305 | +### 7.1 Modeles | |
| 306 | +- **Classification + scoring** : `claude-haiku-4-5` (rapide, cout bas). | |
| 307 | +- **Generation RFC + reasoning complexe** : `claude-opus-4-7`. | |
| 308 | +- **Embeddings pour duplicates** : a determiner (Voyage AI ou OpenAI text-embedding-3 pendant qu'Anthropic n'expose pas encore d'API embeddings). | |
| 309 | + | |
| 310 | +### 7.2 Prompt structure (versionne) | |
| 311 | + | |
| 312 | +``` | |
| 313 | +SYSTEM: | |
| 314 | +Tu es l'agent de triage de Rustwarden Forge. | |
| 315 | +Connaissances : [INJECT process complet RUSTWARDEN_EVOLUTION_PROCESS.md] | |
| 316 | +Regles : [INJECT grille 7 criteres] | |
| 317 | +Historique : [INJECT 10 demandes similaires + leurs decisions finales] | |
| 318 | + | |
| 319 | +USER: | |
| 320 | +Nouvelle demande: | |
| 321 | +Titre: {{title}} | |
| 322 | +Body: {{body}} | |
| 323 | +Source: {{source}} | |
| 324 | +Consommateur: {{consumer_slug}} | |
| 325 | + | |
| 326 | +Output JSON strict: | |
| 327 | +{ | |
| 328 | + "category": "bug|improvement|rfc", | |
| 329 | + "confidence": 0.0-1.0, | |
| 330 | + "score": { "generalite": 1-5, ... 7 keys }, | |
| 331 | + "score_total": 0-35, | |
| 332 | + "duplicates": [ { "demand_id": "...", "similarity": 0.0-1.0 } ], | |
| 333 | + "reasoning": "explication courte", | |
| 334 | + "draft_rfc": null | "<markdown du brouillon>" | |
| 335 | +} | |
| 336 | +``` | |
| 337 | + | |
| 338 | +### 7.3 Prompt caching | |
| 339 | +- Cache le SYSTEM prompt (process + grille + 10 historiques) → economie >90% sur tokens repetes. | |
| 340 | +- Refresh cache toutes les 5 min ou sur changement du process. | |
| 341 | + | |
| 342 | +### 7.4 RAG sur historique | |
| 343 | +- Toutes les `classification` validees alimentent un index vectoriel. | |
| 344 | +- Pour une nouvelle demande : retrieve top-5 similaires + leur classification finale (avec override si applicable). | |
| 345 | +- Injection dans le prompt comme exemples few-shot. | |
| 346 | + | |
| 347 | +### 7.5 Apprentissage des overrides | |
| 348 | +- Chaque override mainteneur est un signal fort. | |
| 349 | +- Les overrides recents (30 derniers) sont prioritaires dans la retrieval. | |
| 350 | +- Metric tracker : taux d'accord IA/mainteneur par mois (cible : monter au-dessus de 85%). | |
| 351 | + | |
| 352 | +### 7.6 Cout estime | |
| 353 | +- Hypothese : 50 demandes/mois. | |
| 354 | +- Haiku classification : ~5K tokens entree (cached) + 1K sortie = ~$0.005/demande. | |
| 355 | +- Opus RFC draft : seulement pour ~30% des demandes = ~$0.05/demande. | |
| 356 | +- **Total mensuel : < $5**. Negligeable. | |
| 357 | + | |
| 358 | +--- | |
| 359 | + | |
| 360 | +## 8. Securite et acces | |
| 361 | + | |
| 362 | +### 8.1 Roles | |
| 363 | +- `admin` : mainteneur framework (toi). Tout droit. | |
| 364 | +- `consumer_owner` : un mainteneur d'app consommatrice. Lecture/ecriture sur ses demandes. | |
| 365 | +- `viewer` : lecture seule du backlog public. | |
| 366 | +- `bot` : token API pour CLI/webhooks (scoped par consumer). | |
| 367 | + | |
| 368 | +### 8.2 Auth | |
| 369 | +- Login admin/consumer_owner : **rustwarden-core auth** (eat-our-own-dogfood). | |
| 370 | +- Auth bot : **personal access token** (rustwarden-core `PersonalTokenService`, deja existant). | |
| 371 | +- Webhook GitHub : signature HMAC verifiee. | |
| 372 | + | |
| 373 | +### 8.3 Donnees sensibles | |
| 374 | +- Pas de secret framework/consommateur stocke. | |
| 375 | +- Token GitHub pour CI : encrypted en DB (rustwarden-core `EncryptionService`). | |
| 376 | +- Pas de log des bodies de demandes en clair > 90 jours. | |
| 377 | + | |
| 378 | +### 8.4 Rate limiting | |
| 379 | +- `RateLimitService` (rustwarden-core) sur `/api/demands` : 10/min/consumer. | |
| 380 | +- Sur l'IA : queue avec back-pressure (max 5 jobs concurrents). | |
| 381 | + | |
| 382 | +--- | |
| 383 | + | |
| 384 | +## 9. Integrations externes | |
| 385 | + | |
| 386 | +| Integration | Direction | Usage | | |
| 387 | +|-------------|-----------|-------| | |
| 388 | +| GitHub Webhooks | In | Issues labelled `rustwarden` → demand | | |
| 389 | +| GitHub API (octocrab) | Out | Creation PR (RFC, SERVICES_REFERENCE), commentaires sur PR | | |
| 390 | +| GitHub Actions | In/Out | Trigger compat-check, recoit resultats | | |
| 391 | +| Anthropic API | Out | Classification, scoring, draft RFC | | |
| 392 | +| Voyage/OpenAI Embeddings | Out | Detection doublons | | |
| 393 | +| Email SMTP | Out | Notifications mainteneur/consommateurs | | |
| 394 | +| `rustwarden-cli` | In | Sous-commande `feedback` POST /api | | |
| 395 | + | |
| 396 | +--- | |
| 397 | + | |
| 398 | +## 10. MVP vs version complete | |
| 399 | + | |
| 400 | +### MVP (4-6 semaines, en solo) | |
| 401 | +- Phase 1 : triage 3 voies + IA classification. | |
| 402 | +- Phase 2 : backlog centralise UI. | |
| 403 | +- Auth via rustwarden-core. | |
| 404 | +- 1 consommateur enregistre (gitrust). | |
| 405 | +- Pas de RFC drafting, pas de generation doc, pas de CI gate. | |
| 406 | + | |
| 407 | +### V1 complete (3-4 mois) | |
| 408 | +- Toutes les phases 1-6. | |
| 409 | +- 2+ consommateurs. | |
| 410 | +- Generation RFC IA. | |
| 411 | +- Generation SERVICES_REFERENCE auto. | |
| 412 | +- CI compat gate operationnel. | |
| 413 | + | |
| 414 | +### V2 (futur) | |
| 415 | +- Plugins pour autres frameworks Rust (devenir un produit standalone). | |
| 416 | +- Multi-tenant (plusieurs frameworks heberges). | |
| 417 | +- Analytics (qui contribue, quelle vitesse de cycle...). | |
| 418 | + | |
| 419 | +--- | |
| 420 | + | |
| 421 | +## 11. Roadmap par jalons | |
| 422 | + | |
| 423 | +| Jalon | Contenu | Critere de sortie | | |
| 424 | +|-------|---------|-------------------| | |
| 425 | +| **M0 — Bootstrap** (1 sem) | Init projet, builder rustwarden, schema DB, login fonctionnel | Login admin OK, page vide | | |
| 426 | +| **M1 — Phase 1 manuelle** (1 sem) | Formulaire soumission, listing, override manuel sans IA | Une demande peut etre saisie et categorisee a la main | | |
| 427 | +| **M2 — Phase 1 IA** (2 sem) | Integration Claude API, classification auto, scoring, prompt caching | 10 demandes test classifiees auto, accord >70% | | |
| 428 | +| **M3 — Phase 2** (1 sem) | Backlog filtres, vues par consommateur, export markdown | Regen `UPSTREAM_RUSTWARDEN.md` identique au manuel | | |
| 429 | +| **M4 — Phase 4 partielle** (1 sem) | API REST, webhook GitHub, sous-commande CLI | gitrust peut soumettre via 3 canaux | | |
| 430 | +| **M5 — RFC drafting IA** (2 sem) | Generation brouillon RFC, PR auto sur framework | 1 RFC generee acceptee avec < 30% modifs | | |
| 431 | +| **M6 — Phase 6 release tracking** (1 sem) | Webhook tag, tracker creation, UI adoption | 1 release tracking complete | | |
| 432 | +| **M7 — Phase 3 doc auto** (2 sem) | Worker cargo doc, regeneration SERVICES_REFERENCE | PR auto sur tag, diff valide | | |
| 433 | +| **M8 — Phase 5 CI gate** (2 sem) | GitHub Action, dashboard compat | 1 PR rustwarden testee contre gitrust en CI | | |
| 434 | + | |
| 435 | +**Total estime** : ~13 semaines de travail solo, etalable selon disponibilite. | |
| 436 | + | |
| 437 | +--- | |
| 438 | + | |
| 439 | +## 12. Criteres de reussite (KPI) | |
| 440 | + | |
| 441 | +| Indicateur | Mesure | Cible J+90 | | |
| 442 | +|-----------|--------|-----------| | |
| 443 | +| Taux d'accord IA/mainteneur sur categorie | sur derniers 30 jours | > 85% | | |
| 444 | +| Detection doublons (precision) | manuel sur 50 cas | > 90% | | |
| 445 | +| Temps median entre soumission et triage | ms | < 2 min | | |
| 446 | +| RFC drafts acceptes avec < 20% modifs | ratio | > 50% | | |
| 447 | +| Apps consommatrices enregistrees | count | >= 2 | | |
| 448 | +| Ratio demandes traitees / soumises | sur 30j glissants | > 90% | | |
| 449 | +| Cout mensuel IA | $ | < $20 | | |
| 450 | + | |
| 451 | +--- | |
| 452 | + | |
| 453 | +## 13. Risques et mitigations | |
| 454 | + | |
| 455 | +| Risque | Probabilite | Impact | Mitigation | | |
| 456 | +|--------|------------|--------|-----------| | |
| 457 | +| L'IA hallucine une RFC plausible mais fausse | Moyenne | Eleve | Mainteneur valide TOUJOURS avant PR, jamais d'auto-merge | | |
| 458 | +| Drift entre prompt versions et grille du process | Moyenne | Moyen | Prompt versionne, lien vers commit du process file | | |
| 459 | +| Couplage trop fort avec rustwarden-core (qui evolue) | Eleve | Moyen | Pin minor sur rustwarden-core, bench compat hebdomadaire | | |
| 460 | +| Solo dev → bus factor 1 | Eleve | Eleve | Rendre le code open-source rapidement, doc d'install detaillee | | |
| 461 | +| Cout API Anthropic explose | Faible | Faible | Quota mensuel + alerte + fallback Haiku-only | | |
| 462 | +| Webhook GitHub down → demandes perdues | Faible | Moyen | File d'attente DB pour replay, idempotence sur external_ref | | |
| 463 | + | |
| 464 | +--- | |
| 465 | + | |
| 466 | +## 14. Questions ouvertes a arbitrer | |
| 467 | + | |
| 468 | +- [ ] Hebergement : VPS perso, Fly.io, ou self-hosted maison ? | |
| 469 | +- [ ] Nom definitif (`rustwarden-forge` vs alternatives) ? | |
| 470 | +- [ ] Open-source des le debut ou prive jusqu'a V1 ? | |
| 471 | +- [ ] L'app gere-t-elle UNIQUEMENT rustwarden, ou est-elle generique pour N'IMPORTE quel framework Rust (positionnement produit) ? | |
| 472 | +- [ ] Embeddings : Voyage AI, OpenAI, ou self-hosted (sentence-transformers via candle) ? | |
| 473 | +- [ ] Authent : OAuth GitHub (login facile contributeurs externes) ou seulement local ? | |
| 474 | +- [ ] Frequence des syncs avec le repo framework : webhook seul ou polling de garantie ? | |
| 475 | +- [ ] Place de PasterMan : on attend qu'il soit pret ou on simule un 2eme consommateur pour developper la logique multi-app ? | |
| 476 | + | |
| 477 | +--- | |
| 478 | + | |
| 479 | +## 15. Premiere tache concrete (pour demarrer) | |
| 480 | + | |
| 481 | +Si validation du plan : | |
| 482 | + | |
| 483 | +1. **Reserver le nom** + creer le repo `rustwarden-forge` (ou nom retenu). | |
| 484 | +2. **Init workspace Cargo** avec les 5 crates listes section 5.2. | |
| 485 | +3. **Scaffolder** via `rustwarden-cli start-project` → premier test reel du wizard sur un projet non-trivial. | |
| 486 | +4. **Migration initiale** : tables `consumer`, `demand`, `classification`. | |
| 487 | +5. **Premier endpoint** : `POST /demands` (sans IA) + page de listing. | |
| 488 | +6. **Demo end-to-end** : depot manuel d'une demande, lecture dans la liste. | |
| 489 | + | |
| 490 | +A partir de la, iterer M2 → M8 selon roadmap. | |
| 491 | + | |
| 492 | +--- | |
| 493 | + | |
| 494 | +## 16. Notes finales | |
| 495 | + | |
| 496 | +Cette application est un **investissement strategique** : elle automatise un process documente mais aujourd'hui purement manuel, et elle sert simultanement de premier consommateur non-trivial du framework. Tout bug decouvert pendant son developpement est un bug evite chez les futurs consommateurs. | |
| 497 | + | |
| 498 | +Le risque principal reste la **discipline d'usage** : l'app n'apportera de la valeur que si toutes les demandes y transitent (pas de remontee orale ou par DM qui contournent le process). Cette discipline doit etre encodee dans le `RUSTWARDEN_EVOLUTION_PROCESS.md` une fois l'app en prod : "tout besoin se depose dans Forge, sinon il n'existe pas". |
A
docs/plans/2026-04-23-rustwarden-ui-evolution.md
+416
-0
@@ -0,0 +1,416 @@
| 1 | +# Plan d'evolution rustwarden-ui — exposer les nouvelles capacites backend | |
| 2 | + | |
| 3 | +> **Copie depot** : apres approbation, ce plan doit etre copie dans | |
| 4 | +> `docs/plans/2026-04-23-rustwarden-ui-evolution.md` (regle CLAUDE.md sur la | |
| 5 | +> co-localisation plan ↔ code). | |
| 6 | + | |
| 7 | +## Context | |
| 8 | + | |
| 9 | +`rustwarden-core` a recu 6 phases d'ameliorations (Phases 1-6 du plan upstream) : | |
| 10 | +PATs, Teams, `Principal` pour partage, rate limit 429, SSE bus, hooks audit, | |
| 11 | +CSRF form-body middleware, `LayoutContext::from_session`. Cote `rustwarden-ui`, | |
| 12 | +l'admin existant (users / roles / settings / sessions / preferences / TOTP) ne | |
| 13 | +couvre **aucune** de ces surfaces : pas de gestion PAT, pas de teams, le | |
| 14 | +partage de ressources ne sait cibler qu'un user, les 429 ne sont pas formates, | |
| 15 | +le boilerplate CSRF est toujours manuel dans 10 handlers. | |
| 16 | + | |
| 17 | +**But** : livrer les surfaces UI user-facing qui manquent (PATs, Teams, | |
| 18 | +Sharing avec Principal) sans bloquer sur un admin complet, puis une phase de | |
| 19 | +finition technique (LayoutContext + CSRF SSR + 429 UX). Choix valide : | |
| 20 | +- **Portee** : User-facing d'abord (PATs, Teams, Sharing) puis finition. | |
| 21 | +- **Scopes PAT** : trait `PatScopesProvider` implemente par l'app consommatrice | |
| 22 | + → UI genere des checkboxes dynamiquement. | |
| 23 | + | |
| 24 | +Total estime : ~10-15 jours dev + 2-3 jours finition technique. | |
| 25 | + | |
| 26 | +--- | |
| 27 | + | |
| 28 | +## Stack et conventions (contraintes CLAUDE.md) | |
| 29 | + | |
| 30 | +- **Templates** Askama dans `crates/rustwarden-ui/templates/`, struct Template | |
| 31 | + dans un handler, route dans `src/routes.rs`. | |
| 32 | +- **CSS** Tailwind 4 + DaisyUI 5 (variables `var(--color-primary)` etc.). | |
| 33 | +- **Interactions** : HTMX pour formulaire → HTML, Alpine.js pour JSON API | |
| 34 | + (clipboard, toggle UI), JS natif pour edge cases. | |
| 35 | +- **Contrat `base.html`** : chaque Template doit fournir `page_title`, | |
| 36 | + `app_name`, `app_version`, `csrf_token` + (authentifie) `username`, `email`, | |
| 37 | + `initials`, `avatar_color`, `roles_text`, `nav_items`, `app_domain`, | |
| 38 | + `app_description`. | |
| 39 | +- **DTOs** dans `src/dto/`, declares dans `dto/mod.rs`. | |
| 40 | +- **Zero stub**, `cargo clippy -- -D warnings`, `cargo fmt`. | |
| 41 | + | |
| 42 | +Patterns a **reutiliser** : | |
| 43 | +- [`LayoutContext::from_session`](../../crates/rustwarden-core/src/templates/mod.rs) pour | |
| 44 | + le boilerplate CSRF (livre en Phase 1 backend). | |
| 45 | +- [`NavigationDTO::for_user`](../../crates/rustwarden-ui/src/dto/navigation_dto.rs) pour | |
| 46 | + ajouter les entrees de menu sans dupliquer la logique admin/user. | |
| 47 | +- [`SidebarSection`](../../crates/rustwarden-ui/src/dto/sidebar_dto.rs) pour les menus | |
| 48 | + secondaires. | |
| 49 | +- Pattern handler existant dans | |
| 50 | + [admin_handler.rs](../../crates/rustwarden-ui/src/handlers/admin_handler.rs) : | |
| 51 | + `#[derive(Template)] + impl_template_response! + Extension<Claims> + | |
| 52 | + Services`. | |
| 53 | +- Pattern formulaire HTMX + CSRF hidden field visible dans | |
| 54 | + [users_list.html](../../crates/rustwarden-ui/templates/admin/users_list.html). | |
| 55 | + | |
| 56 | +--- | |
| 57 | + | |
| 58 | +## Phase UI-1 — Personal Access Tokens self-service (~3-4 jours) | |
| 59 | + | |
| 60 | +### Surface utilisateur | |
| 61 | + | |
| 62 | +Nouveau menu "Mes tokens API" dans `/settings/tokens`. L'utilisateur : | |
| 63 | +- voit la liste de ses PATs actifs (nom, scopes, `last_used_at`, `expires_at`, date de creation), | |
| 64 | +- cree un nouveau token (nom + scopes + expiration optionnelle), | |
| 65 | +- voit le plaintext **une seule fois** apres creation (clipboard + warning), | |
| 66 | +- revoque un token. | |
| 67 | + | |
| 68 | +### Nouveau trait framework | |
| 69 | + | |
| 70 | +**Fichier** : nouveau `crates/rustwarden-core/src/services/pat_scopes_provider.rs` | |
| 71 | + | |
| 72 | +```rust | |
| 73 | +/// Implemente par l'app consommatrice pour exposer ses scopes a l'UI. | |
| 74 | +/// | |
| 75 | +/// Le framework ne connait pas la semantique des scopes (RepoScope, | |
| 76 | +/// WallScope, ...) — ce trait sert de pont pour peupler la liste de | |
| 77 | +/// checkboxes a la creation d'un PAT. | |
| 78 | +pub trait PatScopesProvider: Send + Sync { | |
| 79 | + /// Retourne la liste complete des scopes exposables, tries par categorie. | |
| 80 | + fn available_scopes(&self) -> Vec<PatScopeDescriptor>; | |
| 81 | +} | |
| 82 | + | |
| 83 | +pub struct PatScopeDescriptor { | |
| 84 | + pub value: String, // "read:repo" | |
| 85 | + pub label: String, // "Lire les repos" | |
| 86 | + pub description: String, // "Clone, fetch, git ls-remote." | |
| 87 | + pub category: String, // "Repositories" | |
| 88 | + pub default: bool, // coche par defaut ? | |
| 89 | +} | |
| 90 | +``` | |
| 91 | + | |
| 92 | +L'app branche son provider via le builder : `RustwardenApp::builder().pat_scopes_provider(Arc::new(RepoScopesProvider))`. | |
| 93 | + | |
| 94 | +Si aucun provider : l'UI affiche un champ libre text (fallback minimal). | |
| 95 | + | |
| 96 | +### Fichiers a creer | |
| 97 | + | |
| 98 | +- `crates/rustwarden-ui/src/dto/token_dto.rs` : | |
| 99 | + - `CreateTokenRequest { name, scopes: Vec<String>, expires_in_days: Option<i64> }` | |
| 100 | + - `CreateTokenResponse { id, name, plaintext }` (plaintext retourne UNE fois) | |
| 101 | + - `TokenInfo { id, name, scopes, last_used_at, expires_at, created_at }` | |
| 102 | + - `ListTokensResponse { tokens: Vec<TokenInfo> }` | |
| 103 | +- `crates/rustwarden-ui/src/handlers/tokens_handler.rs` : | |
| 104 | + - `list_tokens` (GET `/settings/tokens`) → template `settings/tokens_list.html` | |
| 105 | + - `create_token_form` (GET `/settings/tokens/new`) → template `settings/tokens_new.html` | |
| 106 | + - `create_token` (POST `/settings/tokens`) → template `settings/tokens_created.html` (affiche plaintext + clipboard) | |
| 107 | + - `revoke_token` (DELETE `/settings/tokens/:id`) → HTMX `hx-swap="outerHTML"` | |
| 108 | +- `crates/rustwarden-ui/templates/settings/tokens_list.html`, | |
| 109 | + `tokens_new.html`, `tokens_created.html`. | |
| 110 | + | |
| 111 | +### Reutiliser | |
| 112 | + | |
| 113 | +- [`PersonalTokenService::<S>::create/verify/list_for_user/revoke`](../../crates/rustwarden-core/src/services/personal_token_service.rs) — cote core. | |
| 114 | +- Le service est generique sur `S: TokenScope`. L'UI framework **ne peut pas** | |
| 115 | + instancier `PersonalTokenService<ConcreteScope>`. Solution : un type-erased | |
| 116 | + wrapper `dyn PatScopesProvider` + storage des scopes en `Vec<String>` brut | |
| 117 | + dans les handlers UI. Le binding vers le type concret reste dans l'app. | |
| 118 | + | |
| 119 | +### Routes a ajouter (dans `routes.rs`) | |
| 120 | + | |
| 121 | +```rust | |
| 122 | +.route("/settings/tokens", get(tokens_handler::list_tokens)) | |
| 123 | +.route("/settings/tokens/new", get(tokens_handler::create_token_form)) | |
| 124 | +.route("/settings/tokens", post(tokens_handler::create_token)) | |
| 125 | +.route("/settings/tokens/:id", delete(tokens_handler::revoke_token)) | |
| 126 | +``` | |
| 127 | + | |
| 128 | +### Navigation | |
| 129 | + | |
| 130 | +Ajouter une entree "Tokens API" dans `NavigationDTO::for_user` sous la section | |
| 131 | +Settings. | |
| 132 | + | |
| 133 | +### Critere d'acceptation | |
| 134 | + | |
| 135 | +- [ ] Tests d'integration DB (create → list → revoke → verify echoue apres revoke). | |
| 136 | +- [ ] Clipboard copy du plaintext fonctionne (Alpine.js). | |
| 137 | +- [ ] Refresh de la page apres creation ne re-affiche PAS le plaintext. | |
| 138 | +- [ ] Scope checkboxes dynamiques si provider branche, text field sinon. | |
| 139 | +- [ ] 429 sur `create` affiche le retry hint (voir Phase Finition). | |
| 140 | + | |
| 141 | +--- | |
| 142 | + | |
| 143 | +## Phase UI-2 — Teams management (~4-5 jours) | |
| 144 | + | |
| 145 | +### Surface utilisateur | |
| 146 | + | |
| 147 | +**Two niveaux** : | |
| 148 | +1. `/settings/teams` — user : "Mes teams" (teams dont je suis membre, avec indication du role). | |
| 149 | +2. `/admin/teams` — admin : liste/CRUD de toutes les teams du tenant + vue membership. | |
| 150 | + | |
| 151 | +### Fichiers a creer | |
| 152 | + | |
| 153 | +**DTOs** (`src/dto/team_dto.rs`) : | |
| 154 | +- `TeamInfo { id, slug, name, owner_username, member_count, created_at, my_role: Option<String> }` | |
| 155 | +- `CreateTeamRequest { slug: String, name: String }` | |
| 156 | +- `AddMemberRequest { username: String, role: String }` (username pour UX, resolve to user_id cote handler) | |
| 157 | +- `TeamDetailResponse { team: TeamInfo, members: Vec<TeamMember> }` | |
| 158 | + | |
| 159 | +**Handlers** (`src/handlers/teams_handler.rs` — user-side, `src/handlers/admin_teams_handler.rs` — admin-side) : | |
| 160 | + | |
| 161 | +User-side : | |
| 162 | +- `GET /settings/teams` — liste des teams du user. | |
| 163 | +- `GET /settings/teams/:slug` — detail (membres) si member, sinon 403. | |
| 164 | + | |
| 165 | +Admin-side : | |
| 166 | +- `GET /admin/teams` — liste paginee + search par slug. | |
| 167 | +- `POST /admin/teams` — creer (form TeamSlug validated). | |
| 168 | +- `GET /admin/teams/:slug` — detail + form add/remove member. | |
| 169 | +- `POST /admin/teams/:slug/members` — ajouter un membre (HTMX). | |
| 170 | +- `DELETE /admin/teams/:slug/members/:user_id` — retirer (HTMX). | |
| 171 | +- `PUT /admin/teams/:slug/members/:user_id` — changer le role. | |
| 172 | +- `DELETE /admin/teams/:slug` — supprimer (confirmation HTMX). | |
| 173 | + | |
| 174 | +**Templates** : | |
| 175 | +- `templates/settings/teams_list.html` — cartes "Ma team Backend (Member)" avec lien vers detail. | |
| 176 | +- `templates/settings/team_detail.html` — liste membres + owner badge, pas d'actions si non-admin. | |
| 177 | +- `templates/admin/teams_list.html` — table avec actions. | |
| 178 | +- `templates/admin/team_form.html` — create/edit form. | |
| 179 | +- `templates/admin/team_detail.html` — membership management. | |
| 180 | + | |
| 181 | +### Reutiliser | |
| 182 | + | |
| 183 | +- [`TeamService::create/add_member/remove_member/list_members/teams_of_user/delete`](../../crates/rustwarden-core/src/services/team_service.rs) — core. | |
| 184 | +- [`TeamSlug::parse`](../../crates/rustwarden-core/src/types/team_slug.rs) cote handler pour valider avant DB. | |
| 185 | +- [`UserService::get_user_by_username`](../../crates/rustwarden-core/src/services/user_service.rs) pour resolve username → uuid dans `AddMemberRequest`. | |
| 186 | +- Pattern pagination/search de `admin/users_list.html`. | |
| 187 | + | |
| 188 | +### Routes | |
| 189 | + | |
| 190 | +```rust | |
| 191 | +// User self-service | |
| 192 | +.route("/settings/teams", get(teams_handler::list_my_teams)) | |
| 193 | +.route("/settings/teams/:slug", get(teams_handler::team_detail)) | |
| 194 | +// Admin | |
| 195 | +.route("/admin/teams", get(admin_teams_handler::list_teams).post(admin_teams_handler::create_team)) | |
| 196 | +.route("/admin/teams/:slug", get(admin_teams_handler::team_detail).delete(admin_teams_handler::delete_team)) | |
| 197 | +.route("/admin/teams/:slug/members", post(admin_teams_handler::add_member)) | |
| 198 | +.route("/admin/teams/:slug/members/:user_id", delete(admin_teams_handler::remove_member).put(admin_teams_handler::update_member_role)) | |
| 199 | +``` | |
| 200 | + | |
| 201 | +### Hooks admin | |
| 202 | + | |
| 203 | +Aucun nouveau hook — `TeamService::create/delete/add_member/remove_member` | |
| 204 | +cablent deja `on_team_*`. Verifier qu'on passe bien le `hooks: | |
| 205 | +Option<&Arc<dyn RustwardenHooks>>` depuis le handler (pattern identique a | |
| 206 | +`UserService::delete_user`). | |
| 207 | + | |
| 208 | +### Navigation | |
| 209 | + | |
| 210 | +- "Teams" dans menu user (sous Settings). | |
| 211 | +- "Teams" dans menu admin (sidebar). | |
| 212 | + | |
| 213 | +### Critere d'acceptation | |
| 214 | + | |
| 215 | +- [ ] Creation team avec slug invalide → 400 + message `TeamSlug::parse`. | |
| 216 | +- [ ] Owner ne peut etre retire des membres (backend deja protege). | |
| 217 | +- [ ] Suppression team cascade DB (teams + team_members + resource_shares). | |
| 218 | +- [ ] User non-membre sur `/settings/teams/:slug` → 403. | |
| 219 | +- [ ] i18n fr/en/de/es au minimum (reutiliser la structure existante). | |
| 220 | + | |
| 221 | +--- | |
| 222 | + | |
| 223 | +## Phase UI-3 — Resource sharing avec Principal (user OR team) (~3-4 jours) | |
| 224 | + | |
| 225 | +### Surface | |
| 226 | + | |
| 227 | +Partie **transverse** — le framework ne possede pas de pages de partage | |
| 228 | +specifiques (chaque app consommatrice expose ses ressources). La valeur | |
| 229 | +livree ici est un **composant UI reutilisable** que les apps consommatrices | |
| 230 | +inclueront dans leurs pages de partage. | |
| 231 | + | |
| 232 | +### Fichiers a creer | |
| 233 | + | |
| 234 | +**Composant template reutilisable** : | |
| 235 | +- `templates/components/share_principal_picker.html` — fragment Askama pour | |
| 236 | + picker user/team (autocomplete HTMX). | |
| 237 | + | |
| 238 | +**DTO** (`src/dto/share_dto.rs`) : | |
| 239 | +- `SharePrincipalRequest { principal_kind: "user"|"team", principal_id: String, permission_level: String }` | |
| 240 | +- `ShareEntryView { id, principal_kind, principal_label, permission_level, created_at }` | |
| 241 | + | |
| 242 | +**Handler generique** (`src/handlers/share_handler.rs`) : | |
| 243 | +- `GET /api/share/search-principals?q=alice&types=user,team` — retourne JSON | |
| 244 | + `[{kind, id, label}]` filtre par prefix. Alimente l'autocomplete. | |
| 245 | +- Helpers `list_shares_view(db, resource_type, resource_id)` qui | |
| 246 | + hydrate les shares avec les labels (`username` ou `team.slug`) en | |
| 247 | + batch — evite les N+1 cote templates. | |
| 248 | + | |
| 249 | +### Reutiliser | |
| 250 | + | |
| 251 | +- [`ResourceService::share_with(Principal)`](../../crates/rustwarden-core/src/services/resource_service.rs) — core. | |
| 252 | +- [`ResourceService::list_shares`](../../crates/rustwarden-core/src/services/resource_service.rs) — deja rendu hybride user+team par Phase 5. | |
| 253 | +- [`ResourceService::effective_permission_for_user`](../../crates/rustwarden-core/src/services/resource_service.rs) pour l'affichage "permission effective" sur la fiche ressource. | |
| 254 | + | |
| 255 | +### Routes | |
| 256 | + | |
| 257 | +```rust | |
| 258 | +.route("/api/share/search-principals", get(share_handler::search_principals)) | |
| 259 | +``` | |
| 260 | + | |
| 261 | +### Design de la requete de search | |
| 262 | + | |
| 263 | +``` | |
| 264 | +GET /api/share/search-principals?q=ab&types=user,team&limit=10 | |
| 265 | +→ 200 [ | |
| 266 | + {"kind":"user","id":"uuid...","label":"alice (alice@ex.com)"}, | |
| 267 | + {"kind":"team","id":"uuid...","label":"@backend (Backend Team)"} | |
| 268 | +] | |
| 269 | +``` | |
| 270 | + | |
| 271 | +Implementation : | |
| 272 | +- `UserService::search_by_prefix(db, q, limit)` — **a ajouter au core** si absent (likely missing). | |
| 273 | +- `TeamService::search_by_slug_prefix(db, q, limit)` — **a ajouter au core** si absent. | |
| 274 | +- Rate limit la recherche (`RateLimitService::check("share_search:{user_id}", 60)`). | |
| 275 | + | |
| 276 | +### Composant UI | |
| 277 | + | |
| 278 | +```html | |
| 279 | +<!-- share_principal_picker.html --> | |
| 280 | +<div x-data="{ q: '', results: [], selected: null }" hx-get="/api/share/search-principals" | |
| 281 | + hx-trigger="keyup changed delay:200ms from:#share-search" hx-target="#share-results"> | |
| 282 | + <input id="share-search" name="q" placeholder="Chercher un user ou @team"> | |
| 283 | + <div id="share-results"></div> | |
| 284 | + <select name="permission_level"> | |
| 285 | + <option>read</option><option>write</option><option>admin</option> | |
| 286 | + </select> | |
| 287 | + <button type="submit">Partager</button> | |
| 288 | +</div> | |
| 289 | +``` | |
| 290 | + | |
| 291 | +### Critere d'acceptation | |
| 292 | + | |
| 293 | +- [ ] Autocomplete renvoie user ET team prefixes. | |
| 294 | +- [ ] Rate limit actif. | |
| 295 | +- [ ] Selection d'un team partage a tous les membres (verifie via | |
| 296 | + `effective_permission_for_user` cote test). | |
| 297 | +- [ ] Composant documente dans `SERVICES_REFERENCE.md` + `DEVELOPER.md` comme | |
| 298 | + include Askama reutilisable. | |
| 299 | + | |
| 300 | +--- | |
| 301 | + | |
| 302 | +## Phase UI-4 — Finition technique (~2-3 jours) | |
| 303 | + | |
| 304 | +Cette phase nettoie 3 dettes techniques qui sont des quick wins maintenant | |
| 305 | +que l'infrastructure backend existe. | |
| 306 | + | |
| 307 | +### 4.1 — Adoption `LayoutContext::from_session` | |
| 308 | + | |
| 309 | +Le backend expose `LayoutContext::from_session(session, title)` depuis | |
| 310 | +Phase 1. Les 10 occurrences actuelles de `LayoutContext::new(title, "")` ou | |
| 311 | +`LayoutContext::new(title, &csrf_token)` dans : | |
| 312 | + | |
| 313 | +- [totp_handler.rs](../../crates/rustwarden-ui/src/handlers/totp_handler.rs) | |
| 314 | +- [settings_handler.rs](../../crates/rustwarden-ui/src/handlers/settings_handler.rs) | |
| 315 | +- [home_handler.rs](../../crates/rustwarden-ui/src/handlers/home_handler.rs) | |
| 316 | +- [login_handler.rs](../../crates/rustwarden-ui/src/handlers/login_handler.rs) | |
| 317 | + | |
| 318 | +migrent vers `LayoutContext::from_session(&session, title).await`. | |
| 319 | + | |
| 320 | +Retire ~15-20 lignes de boilerplate par handler. | |
| 321 | + | |
| 322 | +### 4.2 — Wire `csrf_protection_with_config` pour les formulaires SSR | |
| 323 | + | |
| 324 | +Les 10 templates (`settings/security.html`, `admin/settings.html`, etc.) | |
| 325 | +utilisent deja un `<input type="hidden" name="csrf_token" ...>`. Aujourd'hui | |
| 326 | +la protection CSRF n'applique pas sur ces forms si JS absent. Wire dans | |
| 327 | +`routes.rs` : | |
| 328 | + | |
| 329 | +```rust | |
| 330 | +use rustwarden_core::middleware::csrf::{csrf_protection_with_config, CsrfProtectionConfig}; | |
| 331 | + | |
| 332 | +let csrf_cfg = Arc::new(CsrfProtectionConfig { accept_form_body: true, ..Default::default() }); | |
| 333 | +router.layer(from_fn_with_state((db.clone(), csrf_cfg.clone()), csrf_protection_with_config)) | |
| 334 | +``` | |
| 335 | + | |
| 336 | +Les forms SSR fonctionnent apres ce wiring, sans changer une ligne de | |
| 337 | +template. | |
| 338 | + | |
| 339 | +### 4.3 — 429 UX | |
| 340 | + | |
| 341 | +Quand le backend renvoie `AppError::TooManyRequests { retry_after_secs }`, le | |
| 342 | +flash message actuel affiche "Erreur serveur". Ajouter : | |
| 343 | + | |
| 344 | +- Un template partiel `templates/fragments/rate_limit_notice.html`. | |
| 345 | +- Un error handler cote UI qui, pour 429, extrait le header `Retry-After` et | |
| 346 | + affiche "Trop de tentatives, reessayez dans N secondes". | |
| 347 | +- Pour les formulaires HTMX : handler `htmx:responseError` qui affiche un | |
| 348 | + toast avec le compte a rebours. | |
| 349 | + | |
| 350 | +### Critere d'acceptation phase 4 | |
| 351 | + | |
| 352 | +- [ ] `grep LayoutContext::new` dans `crates/rustwarden-ui/src/handlers/` → 0 | |
| 353 | + occurrences avec token argument vide ou explicite. | |
| 354 | +- [ ] Un formulaire SSR (ex. `settings/preferences.html`) soumis sans JS | |
| 355 | + passe la CSRF. | |
| 356 | +- [ ] Un 429 force affiche le toast avec le retry hint et non "Erreur serveur". | |
| 357 | + | |
| 358 | +--- | |
| 359 | + | |
| 360 | +## Cross-phase : hors scope volontairement | |
| 361 | + | |
| 362 | +- **Admin audit log browser** — renvoye a une phase ulterieure (les logs sont | |
| 363 | + deja en DB via Phase 3, pas bloquant). | |
| 364 | +- **SSE demo page** — le framework n'emet pas d'events aujourd'hui, `InMemoryBus` | |
| 365 | + attend d'etre cable par un consommateur. Wait for a real use case. | |
| 366 | +- **Admin PAT view** (voir/revoquer PATs de tous users) — pas demande | |
| 367 | + explicitement, risque de galere UX (liste sur un tenant de 10k users). | |
| 368 | + | |
| 369 | +## Fichiers critiques modifies (resume) | |
| 370 | + | |
| 371 | +**Core** (ajouts mineurs) : | |
| 372 | +- `crates/rustwarden-core/src/services/pat_scopes_provider.rs` — nouveau trait. | |
| 373 | +- `crates/rustwarden-core/src/app.rs` — builder `pat_scopes_provider`. | |
| 374 | +- Potentiellement `UserService::search_by_prefix` et `TeamService::search_by_slug_prefix`. | |
| 375 | + | |
| 376 | +**UI** : | |
| 377 | +- DTOs : `token_dto.rs`, `team_dto.rs`, `share_dto.rs` dans `src/dto/`. | |
| 378 | +- Handlers : `tokens_handler.rs`, `teams_handler.rs`, `admin_teams_handler.rs`, | |
| 379 | + `share_handler.rs` dans `src/handlers/`. | |
| 380 | +- Templates : `settings/tokens_*.html`, `settings/teams_*.html`, | |
| 381 | + `admin/teams_*.html`, `components/share_principal_picker.html`, | |
| 382 | + `fragments/rate_limit_notice.html`. | |
| 383 | +- Routes : edit `src/routes.rs`. | |
| 384 | +- Navigation : edit `src/dto/navigation_dto.rs`. | |
| 385 | + | |
| 386 | +**i18n** : | |
| 387 | +- `/locales/{fr,en,de,es,it,pt}.yml` — ajouter les cles `tokens.*`, `teams.*`, `sharing.*`. | |
| 388 | + | |
| 389 | +## Verification end-to-end | |
| 390 | + | |
| 391 | +Apres chaque phase : | |
| 392 | + | |
| 393 | +```bash | |
| 394 | +# Quality gates | |
| 395 | +cargo fmt --check | |
| 396 | +cargo clippy --workspace --lib --tests -- -D warnings | |
| 397 | +cargo test --workspace | |
| 398 | + | |
| 399 | +# Smoke test UI | |
| 400 | +cd database && docker compose up -d | |
| 401 | +cargo run -p rustwarden-app # port 3000 | |
| 402 | +# Ouvrir http://localhost:3000 et verifier : | |
| 403 | +# - Phase UI-1 : /settings/tokens affiche la liste, create + revoke fonctionnent | |
| 404 | +# - Phase UI-2 : /settings/teams liste membership, /admin/teams CRUD complet | |
| 405 | +# - Phase UI-3 : /api/share/search-principals retourne user+team, composant integrable | |
| 406 | +# - Phase UI-4 : form SSR sans JS passe, 429 affiche le retry hint | |
| 407 | +``` | |
| 408 | + | |
| 409 | +## Ordre d'execution | |
| 410 | + | |
| 411 | +1. **UI-1 Tokens** — valeur user immediate, isole des autres phases. | |
| 412 | +2. **UI-2 Teams** — preparatoire au partage Principal. | |
| 413 | +3. **UI-3 Sharing Principal** — depend de UI-2 pour tester. | |
| 414 | +4. **UI-4 Finition** — apres pour beneficier de tout le surfaces testables. | |
| 415 | + | |
| 416 | +Total : ~12-18 jours dev hors revue. |
A
docs/plans/2026-04-24-post-audit-addteams-debt.md
+250
-0
@@ -0,0 +1,250 @@
| 1 | +# Plan — Résolution dette technique post-audit AddTeams | |
| 2 | + | |
| 3 | +## Context | |
| 4 | + | |
| 5 | +L'audit anti-slop de la branche `AddTeams` (tests verts, clippy 0 warning, fmt OK, 164+21 tests passants) a identifié **quatre items de cohérence produit / contrat framework** non-bloquants techniquement mais à traiter pour que la branche soit vraiment prête à être consommée par des projets externes. Les orientations ci-dessous ont été validées par le Product Owner : | |
| 6 | + | |
| 7 | +1. **Teams reste une primitive framework** — pas d'UI par défaut dans `rustwarden-ui`, les apps consommatrices exposent leurs propres handlers/routes team. Documenter l'usage et les hooks suffit. | |
| 8 | +2. **CLI wizard doit exposer toutes les nouvelles APIs publiques** (Teams, PAT, RateLimit, SSE, hooks `on_team_*`) avec exemples runtime, sauf le scope `PersonalTokenService` qui reste un TODO commenté + lien doc. | |
| 9 | +3. **Découper `settings_handler.rs`** (proche de 2000 lignes, +691 sur cette branche) en sous-module `handlers/settings/{profile,security,preferences}.rs`. **Breaking change assumé**, documenté dans un CHANGELOG thématique. | |
| 10 | +4. **9 TODO i18n locale hardcodée `"fr"`** pré-existants — hors scope de ce plan, ticket séparé. | |
| 11 | + | |
| 12 | +Résultat visé : la branche `AddTeams` peut être mergée et les nouveaux projets scaffoldés via `rustwarden-cli start-project` découvrent naturellement les primitives Teams / PAT / SSE, avec un contrat framework stable et documenté. | |
| 13 | + | |
| 14 | +--- | |
| 15 | + | |
| 16 | +## Scope | |
| 17 | + | |
| 18 | +### In | |
| 19 | +- Phase A — Découpe settings en sous-module + CHANGELOG thématique | |
| 20 | +- Phase B — Extension DEVELOPER.md pour Teams, PAT cleanup, SSE, hooks `on_team_*` | |
| 21 | +- Phase C — MAJ CLI wizard (prelude re-exports + snippets exemples) | |
| 22 | +- Phase D — Création du ticket différé `docs/BACKLOG_I18N_LOCALE.md` | |
| 23 | + | |
| 24 | +### Out | |
| 25 | +- Ajout de handlers/routes/templates Team dans `rustwarden-ui` → framework-only, PO décision | |
| 26 | +- Migration inline → `RateLimitService` → **déjà complète** (vérifié : 3 services + middleware utilisent `RateLimitService`) | |
| 27 | +- Refactor des `#[allow(dead_code)]` sur templates Askama → légitime (contrat `base.html`) | |
| 28 | +- Traitement des 9 TODO i18n → traité par ticket backlog | |
| 29 | +- Modification du comportement métier ou de la DB → refactor pur | |
| 30 | + | |
| 31 | +--- | |
| 32 | + | |
| 33 | +## Phase A — Découpe `settings_handler.rs` | |
| 34 | + | |
| 35 | +### A.1 Nouvelle arborescence | |
| 36 | + | |
| 37 | +``` | |
| 38 | +crates/rustwarden-ui/src/handlers/ | |
| 39 | +├── settings/ | |
| 40 | +│ ├── mod.rs # re-exports + utils communs (LayoutCtx, traductions partagées) | |
| 41 | +│ ├── profile.rs # username, email, avatar, delete_account | |
| 42 | +│ ├── security.rs # password change, sessions list/revoke, 2FA entry | |
| 43 | +│ └── preferences.rs # locale, theme, notifications | |
| 44 | +``` | |
| 45 | + | |
| 46 | +Fichier supprimé : `crates/rustwarden-ui/src/handlers/settings_handler.rs` | |
| 47 | + | |
| 48 | +### A.2 Répartition des fonctions | |
| 49 | + | |
| 50 | +Lire `crates/rustwarden-ui/src/handlers/settings_handler.rs` (+691 l sur cette branche). Déplacer chaque handler selon son domaine : | |
| 51 | +- `profile_page`, `update_profile`, `delete_account`, `change_username_request` → `profile.rs` | |
| 52 | +- `change_password_page`, `post_change_password`, `sessions_page`, `revoke_session`, `security_page_entry` → `security.rs` | |
| 53 | +- `preferences_page`, `update_preferences`, `update_locale`, `update_theme` → `preferences.rs` | |
| 54 | + | |
| 55 | +Tests `#[cfg(test)] mod tests` accompagnent leur handler (3 modules tests, pas un seul global). | |
| 56 | + | |
| 57 | +Utilitaires partagés (structs `LayoutCtx`-like, helpers i18n répétés) → `settings/mod.rs`. | |
| 58 | + | |
| 59 | +### A.3 Ajustements amont | |
| 60 | + | |
| 61 | +- `crates/rustwarden-ui/src/handlers/mod.rs` : `pub mod settings_handler;` → `pub mod settings;` | |
| 62 | +- `crates/rustwarden-ui/src/routes.rs` : réécrire les `use` et les paths `settings_handler::X` → `settings::{profile,security,preferences}::X`. Aucun changement de route HTTP. | |
| 63 | + | |
| 64 | +### A.4 CHANGELOG thématique | |
| 65 | + | |
| 66 | +Créer `docs/CHANGELOG_SETTINGS_SPLIT.md` (convention existante : `CHANGELOG_FORCE_2FA.md`). Contenu : | |
| 67 | +- Ancien import : `rustwarden_ui::handlers::settings_handler::X` | |
| 68 | +- Nouveau : `rustwarden_ui::handlers::settings::{profile|security|preferences}::X` | |
| 69 | +- Tableau complet des symboles déplacés (liste exhaustive) | |
| 70 | +- Raison : taille excessive, cohérence avec `totp_handler.rs` | |
| 71 | +- Date de la release concernée | |
| 72 | + | |
| 73 | +### A.5 Vérification phase A | |
| 74 | +- `cargo build --all-targets` vert | |
| 75 | +- `cargo clippy --all-targets -- -D warnings` vert | |
| 76 | +- `cargo fmt --check` vert | |
| 77 | +- `cargo test -p rustwarden-ui` : tests préfs / profile / security **tous déplacés** passent | |
| 78 | +- Démarrer `cargo run -p rustwarden-app` et naviguer manuellement : `/settings/profile`, `/settings/security`, `/settings/preferences`, `/settings/security/2fa` → pages rendues, formulaires fonctionnels, sessions listées | |
| 79 | + | |
| 80 | +--- | |
| 81 | + | |
| 82 | +## Phase B — Extension DEVELOPER.md | |
| 83 | + | |
| 84 | +La doc API de référence ([docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md)) couvre déjà §9 Teams, §15 Rate limiting, §16 Notifications SSE, §17 Hooks, §7 PersonalTokenService. Le gap est dans [DEVELOPER.md](DEVELOPER.md) — guide d'extension. | |
| 85 | + | |
| 86 | +### B.1 Sections à enrichir (pas créer) | |
| 87 | + | |
| 88 | +- **§12 Hooks** : ajouter un bloc *"Hooks teams"* listant `on_team_created`, `on_team_deleted`, `on_team_member_added`, `on_team_member_removed` avec un exemple minimal d'implémentation (audit log). Cf. [crates/rustwarden-core/src/hooks.rs:205-244](crates/rustwarden-core/src/hooks.rs) pour les signatures exactes. | |
| 89 | +- **§14 Taches de fond** : ajouter un bloc *"Nettoyage PAT"* montrant comment une app consommatrice instancie `PersonalTokenCleanupProcessor<MyAppScope>::new(db).start()` après définition de son enum scope qui implémente `TokenScope`. Pointeur vers §7 de SERVICES_REFERENCE.md pour le trait. | |
| 90 | +- **§15 Middleware** (ou créer §15bis *"Rate limiting et notifications"*) : pointeur bref vers SERVICES_REFERENCE.md §15 et §16, plus exemple de consommation SSE côté app (3-5 lignes, type `AppEvent` + `sse_response(bus.subscribe(filter))`). | |
| 91 | + | |
| 92 | +### B.2 Cross-refs | |
| 93 | +Dans `docs/SERVICES_REFERENCE.md`, ajouter en bas des sections §9 / §7 / §16 / §17 une note *"Voir DEVELOPER.md §X pour un exemple d'intégration consommateur"*. | |
| 94 | + | |
| 95 | +### B.3 Vérification phase B | |
| 96 | +- `cargo doc --workspace --no-deps` sans warning | |
| 97 | +- Relecture par PO : les 3 scénarios (hook team, cleanup PAT, SSE) sont exécutables en copier-coller | |
| 98 | + | |
| 99 | +--- | |
| 100 | + | |
| 101 | +## Phase C — MAJ CLI wizard | |
| 102 | + | |
| 103 | +Cible : [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) et [tools/rustwarden-cli/src/start_project.rs](tools/rustwarden-cli/src/start_project.rs). | |
| 104 | + | |
| 105 | +### C.1 Prélude : vérifier les re-exports dans `rustwarden-core` | |
| 106 | + | |
| 107 | +Lire `crates/rustwarden-core/src/lib.rs` section `prelude` et s'assurer que sont exposés : | |
| 108 | +- `TeamSlug` (déjà présent selon grep) | |
| 109 | +- `TeamService` | |
| 110 | +- `RateLimitService` | |
| 111 | +- `PersonalTokenService`, `TokenScope`, `PersonalTokenCleanupProcessor` | |
| 112 | +- `notifications::{NotificationBus, InMemoryBus, EventFilter}` et `notifications::sse::sse_response` | |
| 113 | + | |
| 114 | +Ajouter ce qui manque (pure re-exports, 0 impact runtime). | |
| 115 | + | |
| 116 | +### C.2 Templates wizard — main.rs généré | |
| 117 | + | |
| 118 | +Ajouter dans le `main.rs` template une section commentée runtime-ready : | |
| 119 | + | |
| 120 | +```rust | |
| 121 | +// ---- Optionnel : tâches de fond framework ---- | |
| 122 | +// Personal Access Token cleanup (périodique). | |
| 123 | +// TODO: définir votre enum Scope (impl TokenScope) et décommenter : | |
| 124 | +// use rustwarden_core::prelude::PersonalTokenCleanupProcessor; | |
| 125 | +// PersonalTokenCleanupProcessor::<MyScope>::new(db_arc.clone()).start(); | |
| 126 | +// Voir docs/SERVICES_REFERENCE.md#7-tokens-et-sessions | |
| 127 | +``` | |
| 128 | + | |
| 129 | +### C.3 Templates wizard — hooks.rs généré (si applicable) | |
| 130 | + | |
| 131 | +Si le wizard génère un fichier `hooks.rs`, ajouter des stubs des nouveaux hooks teams dans l'impl `RustwardenHooks` générée (défaut : ne rien faire, mais présents pour découverte) : | |
| 132 | + | |
| 133 | +```rust | |
| 134 | +async fn on_team_created(&self, _db: &DatabaseConnection, _team_id: Uuid, _creator_id: Uuid) -> anyhow::Result<()> { | |
| 135 | + Ok(()) | |
| 136 | +} | |
| 137 | +// idem on_team_deleted, on_team_member_added, on_team_member_removed | |
| 138 | +``` | |
| 139 | + | |
| 140 | +Si les hooks ont un default impl, **ne pas surcharger** — juste un commentaire `// Voir RustwardenHooks pour les hooks on_team_*, on_totp_*, on_resource_*`. | |
| 141 | + | |
| 142 | +### C.4 README wizard | |
| 143 | + | |
| 144 | +Mettre à jour [tools/rustwarden-cli/README.md](tools/rustwarden-cli/README.md) pour mentionner les primitives disponibles out-of-the-box : Auth, RBAC, OAuth, Email, **Teams, PAT, SSE, Rate limiting** (3 dernières sont nouvelles à ajouter). | |
| 145 | + | |
| 146 | +### C.5 Vérification phase C | |
| 147 | +- `cargo run -p rustwarden-cli -- start-project` → scaffold un projet-test dans `/tmp/` avec prompts par défaut | |
| 148 | +- Le projet généré compile : `cd /tmp/<nom>/ && cargo build` | |
| 149 | +- Le `main.rs` généré contient le bloc commenté PAT cleanup avec lien vers docs | |
| 150 | +- `cargo doc -p <projet-généré>` sans warning | |
| 151 | + | |
| 152 | +--- | |
| 153 | + | |
| 154 | +## Phase D — Ticket différé i18n | |
| 155 | + | |
| 156 | +Créer `docs/BACKLOG_I18N_LOCALE.md` listant les 9 TODO actuels : | |
| 157 | + | |
| 158 | +- [crates/rustwarden-core/src/i18n/extractor.rs:57](crates/rustwarden-core/src/i18n/extractor.rs) — T009 extraction locale | |
| 159 | +- [crates/rustwarden-core/src/services/email_validation_service.rs:100,220](crates/rustwarden-core/src/services/email_validation_service.rs) — locale depuis user pref | |
| 160 | +- [crates/rustwarden-core/src/services/password_reset_service.rs:115](crates/rustwarden-core/src/services/password_reset_service.rs) | |
| 161 | +- [crates/rustwarden-core/src/tasks/password_change_email_retry.rs:197](crates/rustwarden-core/src/tasks/password_change_email_retry.rs) | |
| 162 | +- [crates/rustwarden-core/src/services/refresh_token_service.rs:548,549,590](crates/rustwarden-core/src/services/refresh_token_service.rs) — device_info, IP, JWT blacklist | |
| 163 | +- [crates/rustwarden-ui/src/handlers/email_handler.rs:102](crates/rustwarden-ui/src/handlers/email_handler.rs) — pagination count | |
| 164 | + | |
| 165 | +Format : problème, fichier, ligne, impact, ébauche de solution. Ce document sert de brief pour un plan ultérieur dédié. | |
| 166 | + | |
| 167 | +--- | |
| 168 | + | |
| 169 | +## Fichiers critiques | |
| 170 | + | |
| 171 | +| Fichier | Phase | Action | | |
| 172 | +|---|---|---| | |
| 173 | +| [crates/rustwarden-ui/src/handlers/settings_handler.rs](crates/rustwarden-ui/src/handlers/settings_handler.rs) | A | Supprimé, contenu réparti | | |
| 174 | +| `crates/rustwarden-ui/src/handlers/settings/mod.rs` | A | Créé | | |
| 175 | +| `crates/rustwarden-ui/src/handlers/settings/profile.rs` | A | Créé | | |
| 176 | +| `crates/rustwarden-ui/src/handlers/settings/security.rs` | A | Créé | | |
| 177 | +| `crates/rustwarden-ui/src/handlers/settings/preferences.rs` | A | Créé | | |
| 178 | +| [crates/rustwarden-ui/src/handlers/mod.rs](crates/rustwarden-ui/src/handlers/mod.rs) | A | `pub mod settings;` | | |
| 179 | +| [crates/rustwarden-ui/src/routes.rs](crates/rustwarden-ui/src/routes.rs) | A | Imports + paths | | |
| 180 | +| `docs/CHANGELOG_SETTINGS_SPLIT.md` | A | Créé | | |
| 181 | +| [DEVELOPER.md](DEVELOPER.md) | B | §12, §14, §15 enrichies | | |
| 182 | +| [docs/SERVICES_REFERENCE.md](docs/SERVICES_REFERENCE.md) | B | Cross-refs vers DEVELOPER.md | | |
| 183 | +| [crates/rustwarden-core/src/lib.rs](crates/rustwarden-core/src/lib.rs) | C | Prelude re-exports | | |
| 184 | +| [tools/rustwarden-cli/src/templates.rs](tools/rustwarden-cli/src/templates.rs) | C | Snippets PAT + hooks teams | | |
| 185 | +| [tools/rustwarden-cli/README.md](tools/rustwarden-cli/README.md) | C | Liste primitives | | |
| 186 | +| `docs/BACKLOG_I18N_LOCALE.md` | D | Créé | | |
| 187 | + | |
| 188 | +## Fichiers de référence existants (à réutiliser, NE PAS dupliquer) | |
| 189 | + | |
| 190 | +- [crates/rustwarden-core/src/hooks.rs:205-244](crates/rustwarden-core/src/hooks.rs) — signatures `on_team_*` canoniques | |
| 191 | +- [crates/rustwarden-core/src/tasks/personal_token_cleanup.rs](crates/rustwarden-core/src/tasks/personal_token_cleanup.rs) — exemple `start()` à référencer tel quel dans DEVELOPER.md | |
| 192 | +- [crates/rustwarden-core/src/notifications/mod.rs](crates/rustwarden-core/src/notifications/mod.rs) — doc-comment `//!` qui contient déjà un exemple usable à recopier | |
| 193 | +- [docs/CHANGELOG_FORCE_2FA.md](docs/CHANGELOG_FORCE_2FA.md) — modèle stylistique pour `CHANGELOG_SETTINGS_SPLIT.md` | |
| 194 | + | |
| 195 | +--- | |
| 196 | + | |
| 197 | +## Règle CLAUDE.md appliquée | |
| 198 | + | |
| 199 | +Selon [CLAUDE.md §"Plans et documents de conception"](CLAUDE.md), dès que ce plan est approuvé et **avant toute modification du code**, le copier dans le dépôt : | |
| 200 | + | |
| 201 | +``` | |
| 202 | +cp /home/monsieurb/.claude/plans/je-veut-resoudre-cette-smooth-harbor.md \ | |
| 203 | + docs/plans/2026-04-24-post-audit-addteams-debt.md | |
| 204 | +``` | |
| 205 | + | |
| 206 | +Puis `git add docs/plans/2026-04-24-post-audit-addteams-debt.md`. Les phases suivantes travaillent sur la copie in-repo. | |
| 207 | + | |
| 208 | +--- | |
| 209 | + | |
| 210 | +## Vérification end-to-end (tous phases) | |
| 211 | + | |
| 212 | +1. **Quality gates** : | |
| 213 | + - `cargo build --all-targets` → exit 0 | |
| 214 | + - `cargo clippy --all-targets -- -D warnings` → exit 0 | |
| 215 | + - `cargo fmt --all --check` → exit 0 | |
| 216 | + - `cargo test --workspace` → 185+ tests verts (régression-safe : aucun test ne doit disparaître ou être `#[ignore]`) | |
| 217 | + - `cargo doc --workspace --no-deps` → zéro warning | |
| 218 | + | |
| 219 | +2. **CLI wizard end-to-end** : | |
| 220 | + - `cargo run -p rustwarden-cli -- start-project` dans `/tmp/`, prompts par défaut | |
| 221 | + - Le projet généré `cargo build` avec succès | |
| 222 | + - Le `main.rs` généré contient le bloc PAT cleanup commenté | |
| 223 | + - Lien `docs/SERVICES_REFERENCE.md#7-tokens-et-sessions` résolu | |
| 224 | + | |
| 225 | +3. **App de référence** : | |
| 226 | + - `cd database && docker compose up -d` | |
| 227 | + - `cargo run -p rustwarden-app` | |
| 228 | + - Navigation manuelle sur `/settings/profile`, `/settings/security`, `/settings/security/2fa`, `/settings/preferences` — chaque page rendue, formulaires fonctionnels | |
| 229 | + - Login / logout / 2FA / change password cycle complet sans régression | |
| 230 | + | |
| 231 | +4. **Docs** : | |
| 232 | + - DEVELOPER.md §12 / §14 / §15 relus par PO avec les 3 scénarios copier-coller testés (hook team, PAT cleanup, SSE) | |
| 233 | + - `docs/CHANGELOG_SETTINGS_SPLIT.md` lu — tableau des imports complet | |
| 234 | + - `docs/BACKLOG_I18N_LOCALE.md` créé — 9 TODO listés avec fichier:ligne | |
| 235 | + | |
| 236 | +5. **Lien Docs ↔ Code** : | |
| 237 | + - Chaque exemple de DEVELOPER.md compile (sous `doc-tests` si possible, sinon revue manuelle) | |
| 238 | + - Aucun lien markdown cassé dans les 3 docs modifiés | |
| 239 | + | |
| 240 | +--- | |
| 241 | + | |
| 242 | +## Ordre d'exécution recommandé | |
| 243 | + | |
| 244 | +1. **Copier le plan dans `docs/plans/`** (règle CLAUDE.md) | |
| 245 | +2. **Phase D d'abord** (BACKLOG_I18N_LOCALE.md) — 10 min, pas de risque, retire le bruit TODO du radar | |
| 246 | +3. **Phase C** (wizard) avant Phase A — le wizard doit référencer des APIs stables, donc Phase C fige les exports avant le refactor | |
| 247 | +4. **Phase B** (docs DEVELOPER.md) — les exemples sont plus clairs avec le wizard mis à jour en référence | |
| 248 | +5. **Phase A en dernier** (split settings) — c'est le seul refactor avec risque fonctionnel, on le fait avec le reste stable et tests renforcés | |
| 249 | + | |
| 250 | +Durée estimée : Phase D 15 min, Phase C 1-2 h, Phase B 1-2 h, Phase A 2-3 h + vérif manuelle navigateur. Total ≈ 5-8 h. |
M
tools/rustwarden-cli/Cargo.toml
+2
-2
@@ -15,5 +15,5 @@ console = "0.15"
| 15 | 15 | rand = "0.9" |
| 16 | 16 | chrono = "0.4" |
| 17 | 17 | |
| 18 | -[lints.clippy] | |
| 19 | -empty_line_after_doc_comments = "allow" | |
| 18 | +[lints] | |
| 19 | +workspace = true |
M
tools/rustwarden-cli/README.md
+32
-0
@@ -36,6 +36,38 @@ Commandes disponibles :
| 36 | 36 | | `start-project` | Creer un nouveau projet applicatif Rustwarden | |
| 37 | 37 | | `update-project` | Mettre a jour les crates du framework dans un projet existant | |
| 38 | 38 | |
| 39 | +## Primitives fournies par le framework | |
| 40 | + | |
| 41 | +Chaque projet genere embarque l'ensemble des primitives du framework. Elles | |
| 42 | +sont toutes accessibles via `rustwarden_core::prelude::*` : | |
| 43 | + | |
| 44 | +| Domaine | Types cles | Reference | | |
| 45 | +|---------|-----------|-----------| | |
| 46 | +| Authentification | `AuthUser`, `Claims`, `JwtService` | SERVICES_REFERENCE.md §4 | | |
| 47 | +| RBAC | `RoleService`, `PermissionService` | SERVICES_REFERENCE.md §5 | | |
| 48 | +| Mots de passe | `PasswordService`, `PasswordChangeService`, `PasswordResetService` | SERVICES_REFERENCE.md §6 | | |
| 49 | +| Tokens de session | `RefreshTokenService`, `JwtBlacklistService` | SERVICES_REFERENCE.md §7 | | |
| 50 | +| Personal Access Tokens | `PersonalTokenService<S>`, `TokenScope`, `PersonalTokenCleanupProcessor<S>` | SERVICES_REFERENCE.md §7 | | |
| 51 | +| Teams | `TeamService`, `TeamSlug`, `TeamRole` | SERVICES_REFERENCE.md §9 | | |
| 52 | +| Ressources partagees | `ResourceService`, `Principal` | SERVICES_REFERENCE.md §8 | | |
| 53 | +| OAuth / SSO | `OAuthService`, providers Google/GitHub/Discord/Microsoft | SERVICES_REFERENCE.md §14 | | |
| 54 | +| Email | `EmailService`, `EmailValidationService`, templates Askama | SERVICES_REFERENCE.md §12 | | |
| 55 | +| Rate limiting | `RateLimitService` (sliding window generique) | SERVICES_REFERENCE.md §15 | | |
| 56 | +| Notifications temps-reel (SSE) | `NotificationBus`, `InMemoryBus`, `sse_response` | SERVICES_REFERENCE.md §16 | | |
| 57 | +| Hooks lifecycle | `RustwardenHooks` (on_user_*, on_team_*, on_totp_*, on_resource_*, on_audit_event) | SERVICES_REFERENCE.md §17, DEVELOPER.md §12 | | |
| 58 | + | |
| 59 | +Le `main.rs` genere contient un bloc commente listant les primitives | |
| 60 | +optionnelles a activer (PAT cleanup, hooks, SSE). Voir `DEVELOPER.md` du | |
| 61 | +projet genere pour les exemples concrets d'integration. | |
| 62 | + | |
| 63 | +cargo run --bin change_password -- admin "ChangeMe123!" 2>&1 | tail -5 | |
| 64 | +cargo run --bin migrate 2>&1 | tail -10 | |
| 65 | +- ralph | Password: happydays123 | |
| 66 | + - joanie | Password: happydays123 | |
| 67 | + | |
| 68 | +cargo run --bin change_password -- admin "ChangeMe123!" | |
| 69 | +cargo run --bin change_password -- joanie "happydays123" | |
| 70 | +cargo run --bin change_password -- richie "happydays123" | |
| 39 | 71 | ## Commande `start-project` |
| 40 | 72 | |
| 41 | 73 | Le wizard pose une serie de questions pour configurer le projet, puis genere un dossier complet pret a l'emploi. |
M
tools/rustwarden-cli/src/main.rs
+5
-3
@@ -1,3 +1,5 @@
| 1 | +//! Interactive CLI wizard for scaffolding Rustwarden projects. | |
| 2 | + | |
| 1 | 3 | mod start_project; |
| 2 | 4 | mod templates; |
| 3 | 5 | mod update_project; |
@@ -9,7 +11,7 @@ use clap::{Parser, Subcommand};
| 9 | 11 | #[command( |
| 10 | 12 | name = "rustwarden-cli", |
| 11 | 13 | version, |
| 12 | - about = "CLI wizard pour scaffolder des applications Rustwarden" | |
| 14 | + about = "CLI wizard for scaffolding Rustwarden applications" | |
| 13 | 15 | )] |
| 14 | 16 | struct Cli { |
| 15 | 17 | #[command(subcommand)] |
@@ -18,9 +20,9 @@ struct Cli {
| 18 | 20 | |
| 19 | 21 | #[derive(Subcommand)] |
| 20 | 22 | enum Commands { |
| 21 | - /// Creer un nouveau projet applicatif Rustwarden | |
| 23 | + /// Create a new Rustwarden application project | |
| 22 | 24 | StartProject, |
| 23 | - /// Mettre a jour les crates du framework dans un projet existant | |
| 25 | + /// Update the framework crates in an existing project | |
| 24 | 26 | UpdateProject, |
| 25 | 27 | } |
| 26 | 28 |
M
tools/rustwarden-cli/src/start_project.rs
+2
-8
@@ -533,14 +533,8 @@ fn copy_resources(
| 533 | 533 | } |
| 534 | 534 | |
| 535 | 535 | /// Copy framework documentation relevant to consumer projects. |
| 536 | -fn copy_docs( | |
| 537 | - rustwarden_root: &Path, | |
| 538 | - project_dir: &Path, | |
| 539 | -) -> Result<(), Box<dyn std::error::Error>> { | |
| 540 | - let docs_to_copy = [ | |
| 541 | - "docs/SERVICES_REFERENCE.md", | |
| 542 | - "docs/MIGRATION_GUIDE.md", | |
| 543 | - ]; | |
| 536 | +fn copy_docs(rustwarden_root: &Path, project_dir: &Path) -> Result<(), Box<dyn std::error::Error>> { | |
| 537 | + let docs_to_copy = ["docs/SERVICES_REFERENCE.md", "docs/MIGRATION_GUIDE.md"]; | |
| 544 | 538 | |
| 545 | 539 | let dst_dir = project_dir.join("docs"); |
| 546 | 540 | fs::create_dir_all(&dst_dir)?; |
M
tools/rustwarden-cli/src/templates.rs
+40
-0
@@ -147,6 +147,32 @@ fn app_routes() -> Router<DatabaseConnection> {{
| 147 | 147 | async fn hello() -> &'static str {{ |
| 148 | 148 | "Hello from {name}!" |
| 149 | 149 | }} |
| 150 | + | |
| 151 | +// ---------------------------------------------------------------------------- | |
| 152 | +// Primitives framework optionnelles (Teams, PAT, SSE, Hooks) | |
| 153 | +// | |
| 154 | +// Voir docs/SERVICES_REFERENCE.md pour la ref d'API complete et | |
| 155 | +// DEVELOPER.md (sections §12, §14, §15) pour les exemples d'integration. | |
| 156 | +// ---------------------------------------------------------------------------- | |
| 157 | +// | |
| 158 | +// Personal Access Token cleanup (tache de fond periodique). | |
| 159 | +// Le framework fournit un `PersonalTokenCleanupProcessor<S: TokenScope>`. Le | |
| 160 | +// scope `S` est un enum metier defini par votre app (ex. ApiRead, ApiWrite). | |
| 161 | +// TODO: definir votre enum Scope + impl TokenScope, puis decommenter dans main | |
| 162 | +// apres le `.build().await?` mais avant `.run()`. | |
| 163 | +// | |
| 164 | +// // use rustwarden_core::prelude::PersonalTokenCleanupProcessor; | |
| 165 | +// // PersonalTokenCleanupProcessor::<MyScope>::new(db_arc.clone()).start(); | |
| 166 | +// | |
| 167 | +// Voir: docs/SERVICES_REFERENCE.md#7-tokens-et-sessions | |
| 168 | +// | |
| 169 | +// Hooks lifecycle (on_user_registered, on_team_created, on_totp_enabled, ...). | |
| 170 | +// Implementer `RustwardenHooks` et passer l'instance via `.hooks(MyHooks)` au | |
| 171 | +// builder. Voir DEVELOPER.md §12. | |
| 172 | +// | |
| 173 | +// Notifications temps-reel (SSE): | |
| 174 | +// rustwarden_core::prelude::{{NotificationBus, InMemoryBus, sse_response}} | |
| 175 | +// Voir DEVELOPER.md §15 pour un exemple de handler SSE. | |
| 150 | 176 | "#, |
| 151 | 177 | ui_line = ui_line, |
| 152 | 178 | migrate_line = migrate_line, |
@@ -931,11 +957,25 @@ RustwardenApp::builder()
| 931 | 957 | | `on_user_registered` | Apres inscription d'un utilisateur | |
| 932 | 958 | | `on_user_login` | Apres connexion reussie | |
| 933 | 959 | | `on_user_logout` | Apres deconnexion | |
| 960 | +| `on_user_deleted` | Apres suppression d'un utilisateur | | |
| 934 | 961 | | `on_password_changed` | Apres changement de mot de passe | |
| 935 | 962 | | `on_email_verified` | Apres verification de l'email | |
| 936 | 963 | | `on_role_changed` | Apres modification des roles | |
| 964 | +| `on_oauth_login` | Apres connexion OAuth/SSO reussie | | |
| 965 | +| `on_totp_enabled` | Apres activation 2FA/TOTP | | |
| 966 | +| `on_totp_disabled` | Apres desactivation 2FA/TOTP | | |
| 967 | +| `on_resource_registered` | Apres enregistrement d'une ressource | | |
| 968 | +| `on_resource_unregistered` | Apres desenregistrement d'une ressource | | |
| 937 | 969 | | `on_resource_shared` | Apres partage d'une ressource | |
| 938 | 970 | | `on_resource_unshared` | Apres revocation d'un partage | |
| 971 | +| `on_team_created` | Apres creation d'une team | | |
| 972 | +| `on_team_deleted` | Apres suppression d'une team | | |
| 973 | +| `on_team_member_added` | Apres ajout d'un membre dans une team | | |
| 974 | +| `on_team_member_removed` | Apres retrait d'un membre d'une team | | |
| 975 | +| `on_audit_event` | Tout evenement audit (authentification, acces admin, etc.) | | |
| 976 | + | |
| 977 | +Tous les hooks ont une implementation par defaut vide dans le trait. Vous | |
| 978 | +n'implementez que ceux qui vous interessent. | |
| 939 | 979 | |
| 940 | 980 | --- |
| 941 | 981 |
GitRust