Merge branch 'add_getters' into 'main' update await bcrypt password See merge request ebii/rustwarden!2

Eric BOUHANA <eric@ebii.fr> committé le 2026-03-26 15:14

07acc819ab190ec3b003fa3d1ac119b44ed167f8

2 parent(s)

16 fichiers modifiés +1068 -42
M crates/rustwarden-core/src/auth/handlers.rs
+1 -1
@@ -1229,7 +1229,7 @@ pub async fn register(
1229 1229 }
1230 1230
1231 1231 // Hash password
1232 - let password_hash = PasswordService::hash_password(&payload.password)?;
1232 + let password_hash = PasswordService::hash_password(&payload.password).await?;
1233 1233
1234 1234 // Create user (email_verified will be false by default)
1235 1235 let user = UserService::create_user(
M crates/rustwarden-core/src/bin/change_password.rs
+1 -1
@@ -115,7 +115,7 @@ async fn main() {
115 115
116 116 // Hash the new password
117 117 println!("🔒 Hachage du nouveau mot de passe...");
118 - let password_hash = match PasswordService::hash_password(new_password) {
118 + let password_hash = match PasswordService::hash_password(new_password).await {
119 119 Ok(hash) => {
120 120 println!("✅ Mot de passe haché avec succès");
121 121 hash
M crates/rustwarden-core/src/bin/seed.rs
+1 -1
@@ -143,7 +143,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
143 143 continue;
144 144 }
145 145
146 - let password_hash = PasswordService::hash_password(HAPPY_DAYS_PASSWORD)?;
146 + let password_hash = PasswordService::hash_password(HAPPY_DAYS_PASSWORD).await?;
147 147
148 148 let user = UserService::create_user(
149 149 &db,
M crates/rustwarden-core/src/services/auth_service.rs
+3 -4
@@ -7,7 +7,6 @@
7 7 use crate::error::AppError;
8 8 use crate::models::user;
9 9 use crate::services::password_service::PasswordService;
10 -use bcrypt::{hash, verify};
11 10 use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
12 11 use std::sync::LazyLock;
13 12
@@ -22,7 +21,7 @@ static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
22 21 .ok()
23 22 .and_then(|v| v.parse::<u32>().ok())
24 23 .unwrap_or(bcrypt::DEFAULT_COST);
25 - hash("__dummy_timing_pad__", cost.clamp(12, 20)).expect("bcrypt init failed")
24 + bcrypt::hash("__dummy_timing_pad__", cost.clamp(12, 20)).expect("bcrypt init failed")
26 25 });
27 26
28 27 fn mask_username(username: &str) -> String {
@@ -59,13 +58,13 @@ impl AuthService {
59 58 Some(u) => u,
60 59 None => {
61 60 // SEC-C1: always run bcrypt to prevent timing-based user enumeration
62 - let _ = verify("invalid", &DUMMY_HASH);
61 + let _ = PasswordService::verify_password("invalid", &DUMMY_HASH).await;
63 62 tracing::warn!("Login attempt for unknown user: {}", mask_username(username));
64 63 return Err(AppError::InvalidCredentials);
65 64 }
66 65 };
67 66
68 - let password_valid = PasswordService::verify_password(password, &user.password_hash)?;
67 + let password_valid = PasswordService::verify_password(password, &user.password_hash).await?;
69 68
70 69 if !password_valid {
71 70 tracing::warn!(
M crates/rustwarden-core/src/services/default_admin_service.rs
+1 -1
@@ -64,7 +64,7 @@ pub async fn ensure_default_admin(db: &DatabaseConnection) -> Result<(), AppErro
64 64 return Ok(());
65 65 }
66 66
67 - let password_hash = PasswordService::hash_password(password)?;
67 + let password_hash = PasswordService::hash_password(password).await?;
68 68 let user = UserService::create_user(
69 69 db,
70 70 config.username.clone(),
M crates/rustwarden-core/src/services/password_change_service.rs
+1 -1
@@ -85,7 +85,7 @@ impl PasswordChangeService {
85 85 self.cancel_pending_request(db, user_id).await?;
86 86
87 87 // Hash the new password (bcrypt)
88 - let new_password_hash = PasswordService::hash_password(new_password)?;
88 + let new_password_hash = PasswordService::hash_password(new_password).await?;
89 89
90 90 // Generate secure token (UUID v4)
91 91 let token = Uuid::new_v4().to_string();
M crates/rustwarden-core/src/services/password_reset_service.rs
+1 -1
@@ -227,7 +227,7 @@ impl PasswordResetService {
227 227 let user_id = self.validate_token(db, token).await?;
228 228
229 229 // Validate and hash new password
230 - let password_hash = PasswordService::hash_password(new_password)?;
230 + let password_hash = PasswordService::hash_password(new_password).await?;
231 231
232 232 // Update user password
233 233 let user = UserService::get_user_by_id(db, user_id)
M crates/rustwarden-core/src/services/password_service.rs
+42 -25
@@ -1,5 +1,5 @@
1 1 use crate::error::AppError;
2 -use bcrypt::{hash, verify, DEFAULT_COST};
2 +use bcrypt::{DEFAULT_COST};
3 3 use rand::seq::SliceRandom;
4 4 use rand::Rng;
5 5 use std::sync::LazyLock;
@@ -20,7 +20,10 @@ static BCRYPT_COST: LazyLock<u32> = LazyLock::new(|| {
20 20 pub struct PasswordService;
21 21
22 22 impl PasswordService {
23 - /// Hash a plaintext password using bcrypt
23 + /// Hash a plaintext password using bcrypt on a blocking thread.
24 + ///
25 + /// Bcrypt is CPU-intensive (~100-300ms at cost 12). This method offloads
26 + /// the work to `spawn_blocking` so it does not block the Tokio runtime.
24 27 ///
25 28 /// # Arguments
26 29 ///
@@ -32,15 +35,24 @@ impl PasswordService {
32 35 ///
33 36 /// # Errors
34 37 ///
35 - /// Returns `AppError::InternalServerError` if hashing fails
36 - pub fn hash_password(password: &str) -> Result<String, AppError> {
37 - hash(password, *BCRYPT_COST).map_err(|e| {
38 - tracing::error!("Failed to hash password: {}", e);
39 - AppError::Internal("Password hashing failed".to_string())
38 + /// Returns `AppError::Internal` if hashing or the blocking task fails
39 + pub async fn hash_password(password: &str) -> Result<String, AppError> {
40 + let password = password.to_string();
41 + let cost = *BCRYPT_COST;
42 + tokio::task::spawn_blocking(move || {
43 + bcrypt::hash(&password, cost).map_err(|e| {
44 + tracing::error!("Failed to hash password: {}", e);
45 + AppError::Internal("Password hashing failed".to_string())
46 + })
40 47 })
48 + .await
49 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))?
41 50 }
42 51
43 - /// Verify a plaintext password against a bcrypt hash
52 + /// Verify a plaintext password against a bcrypt hash on a blocking thread.
53 + ///
54 + /// Bcrypt verification is CPU-intensive. This method offloads the work
55 + /// to `spawn_blocking` so it does not block the Tokio runtime.
44 56 ///
45 57 /// # Arguments
46 58 ///
@@ -53,13 +65,18 @@ impl PasswordService {
53 65 ///
54 66 /// # Errors
55 67 ///
56 - /// Returns `AppError::InternalServerError` if verification fails
57 - #[allow(dead_code)] // Will be used in feature 003 (authentication)
58 - pub fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
59 - verify(password, hash).map_err(|e| {
60 - tracing::error!("Failed to verify password: {}", e);
61 - AppError::Internal("Password verification failed".to_string())
68 + /// Returns `AppError::Internal` if verification or the blocking task fails
69 + pub async fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> {
70 + let password = password.to_string();
71 + let hash = hash.to_string();
72 + tokio::task::spawn_blocking(move || {
73 + bcrypt::verify(&password, &hash).map_err(|e| {
74 + tracing::error!("Failed to verify password: {}", e);
75 + AppError::Internal("Password verification failed".to_string())
76 + })
62 77 })
78 + .await
79 + .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))?
63 80 }
64 81
65 82 /// Generate a secure default password for a user
@@ -187,16 +204,16 @@ impl PasswordService {
187 204 mod tests {
188 205 use super::*;
189 206
190 - #[test]
191 - fn test_hash_and_verify_password() {
207 + #[tokio::test]
208 + async fn test_hash_and_verify_password() {
192 209 let password = "my_secure_password_123";
193 - let hash = PasswordService::hash_password(password).unwrap();
210 + let hash = PasswordService::hash_password(password).await.unwrap();
194 211
195 212 // Verify correct password
196 - assert!(PasswordService::verify_password(password, &hash).unwrap());
213 + assert!(PasswordService::verify_password(password, &hash).await.unwrap());
197 214
198 215 // Verify incorrect password
199 - assert!(!PasswordService::verify_password("wrong_password", &hash).unwrap());
216 + assert!(!PasswordService::verify_password("wrong_password", &hash).await.unwrap());
200 217 }
201 218
202 219 #[test]
@@ -210,10 +227,10 @@ mod tests {
210 227 assert_ne!(password, password2);
211 228 }
212 229
213 - #[test]
214 - fn test_default_password_can_be_hashed() {
230 + #[tokio::test]
231 + async fn test_default_password_can_be_hashed() {
215 232 let password = PasswordService::generate_default_password();
216 - let hash = PasswordService::hash_password(&password);
233 + let hash = PasswordService::hash_password(&password).await;
217 234 assert!(hash.is_ok());
218 235 }
219 236
@@ -291,10 +308,10 @@ mod tests {
291 308 }
292 309 }
293 310
294 - #[test]
295 - fn test_generate_secure_password_can_be_hashed() {
311 + #[tokio::test]
312 + async fn test_generate_secure_password_can_be_hashed() {
296 313 let password = PasswordService::generate_secure_password();
297 - let hash = PasswordService::hash_password(&password);
314 + let hash = PasswordService::hash_password(&password).await;
298 315 assert!(hash.is_ok(), "Generated password should be hashable");
299 316 }
300 317 }
M crates/rustwarden-core/src/services/user_service.rs
+1 -1
@@ -232,7 +232,7 @@ impl UserService {
232 232 .await?
233 233 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
234 234
235 - if !PasswordService::verify_password(old_password, &user.password_hash)? {
235 + if !PasswordService::verify_password(old_password, &user.password_hash).await? {
236 236 return Err(AppError::Validation(
237 237 "Old password is incorrect".to_string(),
238 238 ));
M crates/rustwarden-ui/src/handlers/admin_handler.rs
+2 -2
@@ -819,7 +819,7 @@ pub async fn create_user(
819 819 return Ok(error_template.into_response());
820 820 }
821 821
822 - let password_hash = PasswordService::hash_password(&form_data.password)?;
822 + let password_hash = PasswordService::hash_password(&form_data.password).await?;
823 823 let user = UserService::create_user(&db, form_data.username, form_data.email, password_hash, false).await?;
824 824
825 825 for role_name in &form_data.roles {
@@ -1112,7 +1112,7 @@ pub async fn reset_user_password(
1112 1112 None => PasswordService::generate_default_password(),
1113 1113 };
1114 1114
1115 - let password_hash = PasswordService::hash_password(&new_password)?;
1115 + let password_hash = PasswordService::hash_password(&new_password).await?;
1116 1116
1117 1117 use rustwarden_core::models::user;
1118 1118 use sea_orm::{ActiveModelTrait, Set};
M crates/rustwarden-ui/src/handlers/settings_handler.rs
+2 -2
@@ -313,7 +313,7 @@ pub async fn change_password(
313 313 .await?
314 314 .ok_or_else(|| AppError::NotFound("User not found".to_string()))?;
315 315
316 - if !PasswordService::verify_password(&request.old_password, &user.password_hash)? {
316 + if !PasswordService::verify_password(&request.old_password, &user.password_hash).await? {
317 317 return Err(AppError::Validation(
318 318 "Old password is incorrect".to_string(),
319 319 ));
@@ -325,7 +325,7 @@ pub async fn change_password(
325 325 )
326 326 .await?;
327 327
328 - let new_hash = PasswordService::hash_password(&request.new_password)?;
328 + let new_hash = PasswordService::hash_password(&request.new_password).await?;
329 329 UserService::update_password_hash(&db, claims.sub, &new_hash).await?;
330 330 RefreshTokenService::revoke_all_tokens_for_user(&db, claims.sub).await?;
331 331
M crates/rustwarden-ui/src/handlers/user_handler.rs
+2 -2
@@ -90,7 +90,7 @@ pub async fn create_user(
90 90
91 91 let (password_hash, must_change_password) = match payload.password {
92 92 Some(pwd) => {
93 - let hash = PasswordService::hash_password(&pwd)?;
93 + let hash = PasswordService::hash_password(&pwd).await?;
94 94 (hash, false)
95 95 }
96 96 None => {
@@ -100,7 +100,7 @@ pub async fn create_user(
100 100 "Generated default password for user '{}' (must change on first login)",
101 101 payload.username
102 102 );
103 - let hash = PasswordService::hash_password(&default_pwd)?;
103 + let hash = PasswordService::hash_password(&default_pwd).await?;
104 104 (hash, true)
105 105 }
106 106 };
A docs/MIGRATION_GUIDE.md
+253 -0
@@ -0,0 +1,253 @@
1 +# Guide de migration — Rustwarden
2 +
3 +Ce document liste les changements qui impactent les projets consommateurs de `rustwarden-core`
4 +et `rustwarden-ui`, avec les instructions de migration pour chacun.
5 +
6 +Les entrees sont classees par date, de la plus recente a la plus ancienne.
7 +
8 +---
9 +
10 +## 2026-03-26 — `PasswordService` : methodes rendues async
11 +
12 +### Changement
13 +
14 +`PasswordService::hash_password` et `PasswordService::verify_password` sont desormais **async**.
15 +
16 +Bcrypt est CPU-intensif (~100-300ms a cost 12). Les methodes utilisent maintenant
17 +`tokio::task::spawn_blocking` en interne pour ne pas bloquer le runtime Tokio.
18 +
19 +### Avant
20 +
21 +```rust
22 +let hash = PasswordService::hash_password("motdepasse")?;
23 +let valid = PasswordService::verify_password("motdepasse", &hash)?;
24 +```
25 +
26 +### Apres
27 +
28 +```rust
29 +let hash = PasswordService::hash_password("motdepasse").await?;
30 +let valid = PasswordService::verify_password("motdepasse", &hash).await?;
31 +```
32 +
33 +### Migration
34 +
35 +La migration est mecanique : **ajouter `.await`** a chaque appel.
36 +
37 +Rechercher dans votre projet :
38 +
39 +```bash
40 +grep -rn "PasswordService::hash_password\|PasswordService::verify_password" src/
41 +```
42 +
43 +Pour chaque occurrence, ajouter `.await` avant le `?`. Les fonctions appelantes doivent
44 +etre `async` (ce qui est deja le cas dans les handlers Axum et les fonctions `#[tokio::main]`).
45 +
46 +### Ce qui ne change pas
47 +
48 +- `PasswordService::generate_secure_password()` reste synchrone (pas de travail CPU lourd)
49 +- `PasswordService::generate_default_password()` reste synchrone
50 +- `PasswordService::validate_password_strength()` reste synchrone
51 +- La signature de retour (`Result<String, AppError>` / `Result<bool, AppError>`) est inchangee
52 +- Le comportement est identique (meme algorithme, meme cout bcrypt)
53 +
54 +### Workaround obsolete
55 +
56 +Si votre projet wrappait manuellement les appels dans `spawn_blocking`, ce workaround
57 +est maintenant inutile et peut etre supprime :
58 +
59 +```rust
60 +// AVANT — workaround dans le projet consommateur (a supprimer)
61 +let password = password.to_string();
62 +let hash = tokio::task::spawn_blocking(move || {
63 + PasswordService::hash_password(&password)
64 +}).await??;
65 +
66 +// APRES — le spawn_blocking est integre dans le framework
67 +let hash = PasswordService::hash_password(&password).await?;
68 +```
69 +
70 +---
71 +
72 +## 2026-03-26 — Getters sur `RustwardenApp`
73 +
74 +### Changement
75 +
76 +`RustwardenApp` expose desormais 4 getters apres `build()` :
77 +
78 +| Getter | Type retourne | Description |
79 +|--------|--------------|-------------|
80 +| `database()` | `&DatabaseConnection` | Connexion base de donnees du framework |
81 +| `auth_config()` | `&AuthConfig` | Configuration d'authentification |
82 +| `i18n_service()` | `&Arc<I18nService>` | Service de traduction |
83 +| `address()` | `&str` | Adresse de bind du serveur |
84 +
85 +### Migration
86 +
87 +**Aucune migration requise.** Les getters sont purement additifs. Le code existant
88 +continue de fonctionner sans modification.
89 +
90 +### Nouveau pattern recommande
91 +
92 +Les projets consommateurs peuvent simplifier leur `main.rs` :
93 +
94 +```rust
95 +// AVANT — gestion manuelle de la connexion DB
96 +let db = rustwarden_core::config::database::create_connection().await?;
97 +my_app::migrations::run_migrations(&db).await?;
98 +
99 +let app = RustwardenApp::builder()
100 + .from_env()
101 + .database(db.clone()) // clone obligatoire
102 + .auto_migrate(false)
103 + .build().await?;
104 +
105 +app.run().await
106 +
107 +// APRES — le framework gere la DB
108 +let app = RustwardenApp::builder()
109 + .from_env()
110 + .auto_migrate(true) // migrations framework automatiques
111 + .build().await?;
112 +
113 +// Migrations metier sur la meme connexion
114 +my_app::migrations::run_migrations(app.database()).await?;
115 +
116 +app.run().await
117 +```
118 +
119 +L'ancien pattern (`create_connection()` + `db.clone()`) reste fonctionnel.
120 +
121 +---
122 +
123 +## 2026-03-26 — `rustwarden-cli start-project` : documentation copiee
124 +
125 +### Changement
126 +
127 +Le wizard `start-project` copie desormais les fichiers de documentation framework
128 +dans le dossier `docs/` du nouveau projet :
129 +
130 +- `docs/SERVICES_REFERENCE.md` — reference complete des services du framework
131 +- `docs/MIGRATION_GUIDE.md` — ce fichier
132 +
133 +### Migration
134 +
135 +**Aucune migration requise.** Les projets existants peuvent copier ces fichiers manuellement
136 +depuis le depot Rustwarden.
137 +
138 +---
139 +
140 +## 2026-03-23 — Remember Me : session longue duree
141 +
142 +### Changement
143 +
144 +Le flux d'authentification supporte un champ optionnel `remember_me` qui etend
145 +la duree du refresh token (30 jours au lieu de 7 par defaut).
146 +
147 +#### Cote serveur (rustwarden-core)
148 +
149 +**`LoginRequest`** — nouveau champ :
150 +
151 +```rust
152 +pub struct LoginRequest {
153 + pub username: String,
154 + pub password: String,
155 + #[serde(default)] // retrocompatible : false si absent
156 + pub remember_me: bool,
157 +}
158 +```
159 +
160 +**`AuthConfig`** — nouvelle variable d'environnement :
161 +
162 +| Variable | Defaut | Description |
163 +|----------|--------|-------------|
164 +| `REMEMBER_ME_EXPIRATION_DAYS` | 30 | Duree du refresh token avec remember me |
165 +
166 +**`LoginResponse` et `RefreshResponse`** — nouveau champ :
167 +
168 +```rust
169 +pub refresh_expires_in: i64, // duree du refresh token en secondes
170 +```
171 +
172 +**`refresh_token()` handler** — conserve la duree d'origine lors de la rotation :
173 +un token remember me de 30 jours genere un nouveau token de 30 jours (pas de
174 +raccourcissement a 7 jours). Le handler set aussi le cookie `jwt_token` dans la
175 +reponse (coherence avec le flux login).
176 +
177 +#### Cote client (rustwarden-ui)
178 +
179 +**`login.html`** — checkbox "Se souvenir de moi" ajoutee au formulaire. Au login reussi,
180 +le JS stocke dans `localStorage` :
181 +
182 +- `refresh_token` — le token de rafraichissement
183 +- `remember_me` — `"true"` si coche
184 +- `jwt_expires_in` — duree du JWT en secondes
185 +
186 +**`base.html`** — script d'auto-refresh ajoute. Si `remember_me === "true"` en
187 +localStorage, un `setInterval` appelle `POST /api/v1/auth/refresh` toutes les
188 +`(JWT_EXPIRATION - 2 min)` pour renouveler le JWT avant expiration.
189 +
190 +### Migration
191 +
192 +**Aucune migration requise.** Le champ `remember_me` est optionnel (`#[serde(default)]`).
193 +Les clients existants qui n'envoient pas ce champ obtiennent le comportement standard
194 +(refresh token de 7 jours).
195 +
196 +**Migration optionnelle** pour en beneficier :
197 +
198 +1. Ajouter `REMEMBER_ME_EXPIRATION_DAYS=30` au `.env` (ou garder le defaut)
199 +2. Si votre projet a un formulaire de login custom (qui n'utilise pas `login.html` du
200 + framework), ajouter une checkbox qui envoie `"remember_me": true` dans le body JSON
201 + du `POST /api/v1/auth/login`
202 +3. Si votre projet a un template de base custom (qui n'etend pas `base.html` du framework),
203 + ajouter le script d'auto-refresh JWT (voir `crates/rustwarden-ui/templates/base.html`
204 + lignes 174-221)
205 +
206 +### Aucune migration de base de donnees
207 +
208 +La table `refresh_token` stocke deja `expires_at` par token. Le remember me utilise
209 +cette colonne existante avec une duree plus longue.
210 +
211 +---
212 +
213 +## 2026-03-10 — OAuth / SSO (feature `oauth`)
214 +
215 +### Changement
216 +
217 +Support OpenID Connect / OAuth2 pour Google, GitHub, Discord et Microsoft.
218 +Protege derriere la feature `oauth` dans `Cargo.toml`.
219 +
220 +### Migration
221 +
222 +**Aucune migration requise si vous n'activez pas la feature.**
223 +
224 +Pour activer OAuth dans un projet existant :
225 +
226 +1. Ajouter la feature dans `Cargo.toml` :
227 + ```toml
228 + rustwarden-core = { path = "crates/rustwarden-core", features = ["oauth"] }
229 + ```
230 +
231 +2. Ajouter les variables d'environnement (voir `.env.example`) :
232 + ```
233 + OAUTH_ENABLED=true
234 + OAUTH_REDIRECT_BASE_URL=https://votre-domaine.com
235 + OAUTH_ENCRYPTION_KEY=<64 chars hex>
236 + OAUTH_GOOGLE_ENABLED=true
237 + OAUTH_GOOGLE_CLIENT_ID=...
238 + OAUTH_GOOGLE_CLIENT_SECRET=...
239 + ```
240 +
241 +3. La migration de base de donnees (`create_oauth_accounts_table`) est incluse dans
242 + `core_migrations()` et sera executee automatiquement au prochain demarrage si
243 + `auto_migrate(true)`.
244 +
245 +### Routes ajoutees
246 +
247 +| Route | Description |
248 +|-------|-------------|
249 +| `GET /api/v1/auth/oauth/{provider}/redirect` | Initie le flux OAuth |
250 +| `GET /api/v1/auth/oauth/{provider}/callback` | Callback apres autorisation |
251 +| `GET /api/v1/auth/oauth/providers` | Liste les providers actifs (JSON) |
252 +| `GET /api/v1/auth/oauth/accounts` | Comptes OAuth lies de l'utilisateur |
253 +| `DELETE /api/v1/auth/oauth/accounts/{id}` | Delier un compte OAuth |
A docs/SERVICES_REFERENCE.md
+664 -0
@@ -0,0 +1,664 @@
1 +# Rustwarden Services — Reference pour les projets consommateurs
2 +
3 +> **Audience** : developpeurs de projets qui consomment `rustwarden-core` (PasterMan, GitRust, etc.)
4 +>
5 +> Ce document decrit les services exposes par le framework, leurs methodes publiques,
6 +> les patterns d'utilisation et les invariants de securite qu'ils garantissent.
7 +
8 +---
9 +
10 +## Table des matieres
11 +
12 +1. [Architecture generale](#1-architecture-generale)
13 +2. [Acces aux services](#2-acces-aux-services)
14 +3. [Gestion des utilisateurs](#3-gestion-des-utilisateurs)
15 +4. [Authentification](#4-authentification)
16 +5. [RBAC — Roles et Permissions](#5-rbac--roles-et-permissions)
17 +6. [Mots de passe](#6-mots-de-passe)
18 +7. [Tokens et sessions](#7-tokens-et-sessions)
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)
27 +
28 +---
29 +
30 +## 1. Architecture generale
31 +
32 +Tous les services suivent un pattern uniforme :
33 +
34 +```
35 +Handler (axum) → Service (logique metier) → SeaORM (base de donnees)
36 +```
37 +
38 +**Conventions** :
39 +- Les services sont des **structs sans etat** avec des methodes `async fn` statiques, ou des structs legeres avec un etat minimal (rate limiter, config).
40 +- Le premier parametre est toujours `db: &DatabaseConnection`.
41 +- Le type de retour est toujours `Result<T, AppError>`.
42 +- Aucun `unwrap()` / `expect()` en production — toute erreur remonte via `?`.
43 +- Aucun SQL brut — tout passe par SeaORM.
44 +
45 +**Services exportes dans le prelude** (`use rustwarden_core::prelude::*`) :
46 +- `UserService`, `RoleService`, `PermissionService`, `ResourceService`
47 +- `AuthUser`, `Claims`, `JwtService`
48 +- `AppError`, `RustwardenHooks`, `RustwardenApp`
49 +
50 +Les autres services sont accessibles via leur chemin complet :
51 +```rust
52 +use rustwarden_core::services::refresh_token_service::RefreshTokenService;
53 +use rustwarden_core::services::app_settings_service::AppSettingsService;
54 +// etc.
55 +```
56 +
57 +---
58 +
59 +## 2. Acces aux services
60 +
61 +Depuis un projet consommateur, les services sont utilises de deux facons :
62 +
63 +### Dans les handlers Axum (via State)
64 +
65 +```rust
66 +use rustwarden_core::prelude::*;
67 +use axum::extract::State;
68 +use sea_orm::DatabaseConnection;
69 +
70 +async fn my_handler(
71 + State(db): State<DatabaseConnection>,
72 + user: AuthUser, // extrait automatiquement du JWT
73 +) -> Result<impl IntoResponse, AppError> {
74 + let roles = UserService::get_user_roles(&db, user.user_id).await?;
75 + // ...
76 +}
77 +```
78 +
79 +### En dehors des handlers (migrations, seeding, taches)
80 +
81 +```rust
82 +let app = RustwardenApp::builder().from_env().build().await?;
83 +let db = app.database();
84 +
85 +// Migrations metier
86 +my_app::migrations::run(&db).await?;
87 +
88 +// Seeding
89 +let roles = RoleService::list_all_roles(&db).await?;
90 +```
91 +
92 +---
93 +
94 +## 3. Gestion des utilisateurs
95 +
96 +### `UserService`
97 +
98 +**Module** : `rustwarden_core::services::user_service`
99 +**Export** : prelude
100 +
101 +Gestion CRUD des utilisateurs et de leurs roles.
102 +
103 +#### Methodes
104 +
105 +| Methode | Signature | Description |
106 +|---------|-----------|-------------|
107 +| `create_user` | `(db, username: String, email: String, password_hash: String, must_change_password: bool) → Result<user::Model>` | Cree un utilisateur. Le mot de passe doit etre **pre-hashe** via `PasswordService::hash_password`. |
108 +| `get_user_by_id` | `(db, user_id: Uuid) → Result<Option<user::Model>>` | Recherche par ID. |
109 +| `get_user_by_username` | `(db, username: &str) → Result<Option<user::Model>>` | Recherche par nom d'utilisateur. |
110 +| `get_user_by_email` | `(db, email: &str) → Result<Option<user::Model>>` | Recherche par email. |
111 +| `list_all_users` | `(db) → Result<Vec<user::Model>>` | Liste tous les utilisateurs. |
112 +| `get_all_admin_users` | `(db) → Result<Vec<user::Model>>` | Liste les utilisateurs avec le role `admin`. |
113 +| `get_user_roles` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne les noms des roles d'un utilisateur (ex: `["admin", "user"]`). |
114 +| `assign_role_to_user` | `(db, user_id: Uuid, role_id: Uuid) → Result<user_role::Model>` | Assigne un role a un utilisateur. |
115 +| `update_profile` | `(db, user_id: Uuid, username: Option<String>, email: Option<String>) → Result<user::Model>` | Met a jour le profil. Valide l'unicite du username et de l'email. |
116 +| `change_password` | `(db, user_id: Uuid, old_password: &str, new_password: &str, current_token_id: Option<Uuid>) → Result<(Uuid, String)>` | Initie un changement de mot de passe (cree une demande pendante). Retourne `(request_id, plain_token)` pour confirmation. |
117 +| `update_password_hash` | `(db, user_id: Uuid, password_hash: &str) → Result<()>` | Met a jour directement le hash — usage interne (PasswordChangeService). |
118 +
119 +#### Modele `user::Model`
120 +
121 +| Champ | Type | Description |
122 +|-------|------|-------------|
123 +| `id` | `Uuid` | Identifiant unique |
124 +| `username` | `String` | Nom d'utilisateur unique |
125 +| `email` | `String` | Email unique |
126 +| `password_hash` | `String` | Hash bcrypt |
127 +| `email_verified` | `bool` | Email verifie |
128 +| `must_change_password` | `bool` | Forcer le changement au prochain login |
129 +| `last_login_at` | `Option<DateTime>` | Derniere connexion |
130 +| `last_login_ip` | `Option<String>` | IP de derniere connexion |
131 +| `created_at` | `DateTime` | Date de creation |
132 +| `updated_at` | `DateTime` | Derniere mise a jour |
133 +
134 +---
135 +
136 +## 4. Authentification
137 +
138 +### `AuthService`
139 +
140 +**Module** : `rustwarden_core::services::auth_service`
141 +
142 +Verification des credentials avec protection contre le timing attack (SEC-C1).
143 +
144 +| Methode | Signature | Description |
145 +|---------|-----------|-------------|
146 +| `verify_credentials` | `(db, username: &str, password: &str) → Result<user::Model>` | Verifie les identifiants. Utilise un **dummy hash** si l'utilisateur n'existe pas pour eviter les timing attacks. |
147 +| `authenticate_user` | `(db, username: &str, password: &str) → Result<user::Model>` | Authentification de haut niveau. Appelle `verify_credentials` et logue si `must_change_password`. |
148 +
149 +**Securite** :
150 +- `DUMMY_HASH` : hash bcrypt pre-calcule utilise quand le username n'existe pas. Le temps de reponse est identique qu'il y ait ou non un utilisateur correspondant (SEC-C1).
151 +- `mask_username()` : masque les usernames dans les logs (affiche uniquement le premier tiers des caracteres).
152 +
153 +### `AuthUser` (extracteur)
154 +
155 +**Module** : `rustwarden_core::auth::extractors`
156 +**Export** : prelude
157 +
158 +Extracteur Axum qui valide le JWT depuis le cookie `jwt_token` ou le header `Authorization: Bearer`.
159 +
160 +```rust
161 +async fn handler(user: AuthUser) -> impl IntoResponse {
162 + // user.user_id, user.username, user.roles, user.permissions
163 +}
164 +```
165 +
166 +| Methode | Description |
167 +|---------|-------------|
168 +| `has_role(role: &str) → bool` | Verifie si l'utilisateur a un role. |
169 +| `has_any_role(roles: &[&str]) → bool` | Verifie si l'utilisateur a l'un des roles. |
170 +| `has_all_roles(roles: &[&str]) → bool` | Verifie si l'utilisateur a tous les roles. |
171 +| `has_permission(permission: &str) → bool` | Verifie une permission. Les `admin` ont implicitement toutes les permissions. |
172 +
173 +**Rejection** : retourne `401 Unauthorized` si le JWT est absent, expire, invalide, ou blackliste.
174 +
175 +### `JwtService`
176 +
177 +**Module** : `rustwarden_core::auth::jwt`
178 +**Export** : prelude
179 +
180 +| Methode | Signature | Description |
181 +|---------|-----------|-------------|
182 +| `generate_token` | `(config: &AuthConfig, user_id: Uuid, username: &str, roles: Vec<String>, permissions: Vec<String>) → Result<String>` | Genere un JWT signe. |
183 +| `validate_token` | `(config: &AuthConfig, token: &str) → Result<Claims>` | Valide et decode un JWT. |
184 +| `extract_token_from_header` | `(header: &str) → Result<&str>` | Extrait le token d'un header `Authorization: Bearer <token>`. |
185 +
186 +### `Claims` (contenu du JWT)
187 +
188 +| Champ | Type | Description |
189 +|-------|------|-------------|
190 +| `sub` | `Uuid` | User ID |
191 +| `username` | `String` | Nom d'utilisateur |
192 +| `roles` | `Vec<String>` | Roles de l'utilisateur |
193 +| `permissions` | `Vec<String>` | Permissions resolues |
194 +| `exp` | `i64` | Timestamp d'expiration |
195 +| `iat` | `i64` | Timestamp d'emission |
196 +| `iss` | `String` | Issuer |
197 +
198 +---
199 +
200 +## 5. RBAC — Roles et Permissions
201 +
202 +### `RoleService`
203 +
204 +**Module** : `rustwarden_core::services::role_service`
205 +**Export** : prelude
206 +
207 +| Methode | Signature | Description |
208 +|---------|-----------|-------------|
209 +| `create_role` | `(db, name: String, description: Option<String>) → Result<role::Model>` | Cree un role. |
210 +| `get_role_by_name` | `(db, name: &str) → Result<Option<role::Model>>` | Recherche par nom. |
211 +| `get_role_by_id` | `(db, id: Uuid) → Result<Option<role::Model>>` | Recherche par ID. |
212 +| `list_all_roles` | `(db) → Result<Vec<role::Model>>` | Liste tous les roles. |
213 +| `update_role` | `(db, id: Uuid, name: Option<String>, description: Option<String>) → Result<role::Model>` | Met a jour un role. |
214 +| `delete_role` | `(db, id: Uuid) → Result<()>` | Supprime un role. **Roles proteges** : `admin` et `user` ne peuvent pas etre supprimes. |
215 +| `count_users_for_role` | `(db, role_id: Uuid) → Result<u64>` | Compte les utilisateurs ayant ce role. |
216 +| `initialize_default_roles` | `(db) → Result<(role::Model, role::Model, role::Model)>` | Cree les roles par defaut (`admin`, `user`, `app_manager`) si absents. Idempotent. |
217 +
218 +**Roles par defaut** :
219 +| Role | Description |
220 +|------|-------------|
221 +| `admin` | Administrateur — acces complet, toutes les permissions implicites |
222 +| `user` | Utilisateur standard |
223 +| `app_manager` | Gestionnaire d'application |
224 +
225 +### `PermissionService`
226 +
227 +**Module** : `rustwarden_core::services::permission_service`
228 +**Export** : prelude
229 +
230 +| Methode | Signature | Description |
231 +|---------|-----------|-------------|
232 +| `list_all` | `(db) → Result<Vec<permission::Model>>` | Liste toutes les permissions. |
233 +| `get_by_id` | `(db, id: Uuid) → Result<Option<permission::Model>>` | Recherche par ID. |
234 +| `get_by_name` | `(db, name: &str) → Result<Option<permission::Model>>` | Recherche par nom. |
235 +| `create` | `(db, name: String, description: Option<String>, category: String) → Result<permission::Model>` | Cree une permission. |
236 +| `delete` | `(db, id: Uuid) → Result<()>` | Supprime une permission. |
237 +| `get_permissions_for_user` | `(db, user_id: Uuid) → Result<Vec<String>>` | Retourne l'union des permissions de tous les roles de l'utilisateur. |
238 +| `get_permissions_for_role` | `(db, role_id: Uuid) → Result<Vec<permission::Model>>` | Permissions assignees a un role. |
239 +| `set_role_permissions` | `(db, role_id: Uuid, permission_ids: Vec<Uuid>) → Result<()>` | Remplace atomiquement les permissions d'un role. |
240 +| `initialize_default_permissions` | `(db) → Result<()>` | Seed les 12 permissions par defaut. Idempotent. |
241 +
242 +**Permissions par defaut** (4 categories × 3 niveaux) :
243 +
244 +| Categorie | Permissions |
245 +|-----------|------------|
246 +| `users` | `users.view`, `users.manage`, `users.delete` |
247 +| `roles` | `roles.view`, `roles.manage`, `roles.delete` |
248 +| `settings` | `settings.view`, `settings.manage`, `settings.delete` |
249 +| `audit` | `audit.view`, `audit.manage`, `audit.delete` |
250 +
251 +**Logique d'acces** : le role `admin` bypass le systeme de permissions — `AuthUser::has_permission()` retourne toujours `true` pour un admin.
252 +
253 +---
254 +
255 +## 6. Mots de passe
256 +
257 +### `PasswordService`
258 +
259 +**Module** : `rustwarden_core::services::password_service`
260 +
261 +Operations de hashing et validation. Les methodes `hash_password` et `verify_password` sont
262 +**async** et offloadent le travail CPU-intensif de bcrypt sur `tokio::task::spawn_blocking`,
263 +ce qui evite de bloquer le runtime Tokio.
264 +
265 +| Methode | Signature | Description |
266 +|---------|-----------|-------------|
267 +| `hash_password` | `async (password: &str) → Result<String>` | Hash bcrypt via `spawn_blocking`. Cout configurable via `BCRYPT_COST` (defaut: 12). |
268 +| `verify_password` | `async (password: &str, hash: &str) → Result<bool>` | Verification bcrypt via `spawn_blocking`. |
269 +| `generate_secure_password` | `() → String` | Genere un mot de passe de 20 caracteres avec mix garanti (majuscules, minuscules, chiffres, speciaux). |
270 +| `generate_default_password` | `() → String` | Alias de `generate_secure_password`. |
271 +
272 +### `PasswordChangeService`
273 +
274 +**Module** : `rustwarden_core::services::password_change_service`
275 +
276 +Flux de changement de mot de passe en deux etapes (demande + confirmation par email).
277 +
278 +| Methode | Signature | Description |
279 +|---------|-----------|-------------|
280 +| `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)`. |
281 +| `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). |
282 +| `find_by_token` | `(&self, db, token: &str) → Result<password_change_request::Model>` | Trouve une demande par token (verifie expiration et statut). |
283 +| `find_pending_by_user_id` | `(&self, db, user_id: Uuid) → Result<Option<Model>>` | Trouve la demande pendante d'un utilisateur. |
284 +| `cancel_pending_request` | `(&self, db, user_id: Uuid) → Result<()>` | Annule la demande pendante. |
285 +| `invalidate_on_email_change` | `(&self, db, user_id: Uuid) → Result<u64>` | Invalide les demandes si l'email change (le token a ete envoye a l'ancien email). |
286 +| `cleanup_expired_requests` | `(&self, db) → Result<u64>` | Nettoie les demandes expirees ou confirmees. |
287 +| `cleanup_failed_requests` | `(&self, db) → Result<u64>` | Supprime les demandes avec 3+ tentatives echouees. |
288 +
289 +### `PasswordResetService`
290 +
291 +**Module** : `rustwarden_core::services::password_reset_service`
292 +
293 +Flux "Mot de passe oublie" (reset par email).
294 +
295 +| Methode | Signature | Description |
296 +|---------|-----------|-------------|
297 +| `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). |
298 +| `validate_token` | `(&self, db, token: &str) → Result<Uuid>` | Valide un token de reset. Retourne le `user_id`. |
299 +| `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). |
300 +
301 +---
302 +
303 +## 7. Tokens et sessions
304 +
305 +### `RefreshTokenService`
306 +
307 +**Module** : `rustwarden_core::services::refresh_token_service`
308 +
309 +Gestion des refresh tokens avec rotation et detection de reutilisation.
310 +
311 +**Constante** : `MAX_ACTIVE_TOKENS_PER_USER = 10` (SEC-M4)
312 +
313 +| Methode | Signature | Description |
314 +|---------|-----------|-------------|
315 +| `generate_token` | `() → String` | Genere un token aleatoire de 64 caracteres alphanumeriques. |
316 +| `create_refresh_token` | `(db, user_id: Uuid, expiration_days: i64) → Result<CreatedRefreshToken>` | Cree un refresh token. Revoque les plus anciens si > 10 actifs (SEC-M4). |
317 +| `validate_refresh_token` | `(db, token: &str) → Result<refresh_token::Model>` | Valide un token (hash SHA-256, existence, non revoque, non expire, non reutilise). |
318 +| `mark_as_replaced` | `(db, old_token_id: Uuid, new_token_id: Uuid) → Result<Model>` | Marque un token comme remplace (rotation). |
319 +| `revoke_all_tokens_for_user` | `(db, user_id: Uuid) → Result<u64>` | Revoque tous les tokens actifs (logout, changement de mot de passe). |
320 +| `get_active_tokens_for_user` | `(db, user_id: Uuid) → Result<Vec<Model>>` | Liste les tokens non revoques et non expires. |
321 +| `cleanup_expired_tokens` | `(db) → Result<u64>` | Supprime les tokens expires (tache de maintenance). |
322 +| `count_active_sessions` | `(db) → Result<u64>` | Compte les sessions actives tous utilisateurs confondus. |
323 +| `list_user_sessions` | `(db, user_id: Uuid) → Result<Vec<ActiveSession>>` | Liste les sessions actives d'un utilisateur. |
324 +| `revoke_session` | `(db, token_id: Uuid) → Result<()>` | Revoque une session specifique. |
325 +| `delete_session` | `(db, token_id: Uuid) → Result<()>` | Supprime definitivement un refresh token. |
326 +
327 +**Type `CreatedRefreshToken`** :
328 +```rust
329 +pub struct CreatedRefreshToken {
330 + pub model: refresh_token::Model, // Enregistrement en base (hash stocke)
331 + pub plain_token: String, // Token en clair pour le client
332 +}
333 +```
334 +
335 +**Type `ActiveSession`** :
336 +```rust
337 +pub struct ActiveSession {
338 + pub id: Uuid,
339 + pub device_info: Option<String>,
340 + pub ip_address: Option<String>,
341 + pub last_activity: DateTime<Utc>,
342 + pub created_at: DateTime<Utc>,
343 + pub is_current: bool,
344 +}
345 +```
346 +
347 +**Securite** :
348 +- Les tokens sont stockes **hashes en SHA-256** en base. Le token en clair n'est retourne qu'a la creation.
349 +- La detection de reutilisation (`has_been_used()`) declenche la revocation de **tous** les tokens de l'utilisateur (defense contre le vol de token).
350 +- La rotation est obligatoire : chaque usage genere un nouveau token.
351 +
352 +### `JwtBlacklistService`
353 +
354 +**Module** : `rustwarden_core::services::jwt_blacklist_service`
355 +
356 +Blacklist des JWT revoques (logout).
357 +
358 +| Methode | Signature | Description |
359 +|---------|-----------|-------------|
360 +| `add_to_blacklist` | `(db, jwt_token: &str, user_id: Uuid) → Result<()>` | Ajoute un JWT a la blacklist (au logout). Extrait automatiquement la date d'expiration. |
361 +| `is_blacklisted` | `(db, jwt_token: &str) → Result<bool>` | Verifie si un JWT est blackliste. Appele par le middleware auth a chaque requete. |
362 +| `cleanup_expired_tokens` | `(db) → Result<u64>` | Supprime les entrees expirees (tache de fond automatique). |
363 +| `hash_jwt_token` | `(token: &str) → String` | Hash SHA-256 (utilitaire public). |
364 +
365 +---
366 +
367 +## 8. Ressources et partage
368 +
369 +### `ResourceService`
370 +
371 +**Module** : `rustwarden_core::services::resource_service`
372 +**Export** : prelude
373 +
374 +Systeme generique de registre de ressources et de partage avec controle d'acces.
375 +
376 +**Hierarchie des permissions** : `read` < `write` < `admin`
377 +
378 +#### Registre
379 +
380 +| Methode | Signature | Description |
381 +|---------|-----------|-------------|
382 +| `register` | `(db, resource_type: &str, resource_id: Uuid, owner_id: Uuid) → Result<resource::Model>` | Enregistre une ressource dans le registre generique. |
383 +| `find_by_type_and_id` | `(db, resource_type: &str, resource_id: Uuid) → Result<Option<Model>>` | Recherche dans le registre. |
384 +| `find_by_id` | `(db, id: Uuid) → Result<Option<Model>>` | Recherche par ID interne du registre. |
385 +| `owned_by_user` | `(db, owner_id: Uuid, resource_type: &str) → Result<Vec<Model>>` | Ressources d'un type possedees par un utilisateur. |
386 +| `set_public` | `(db, resource_type: &str, resource_id: Uuid, is_public: bool) → Result<Model>` | Bascule la visibilite publique. |
387 +| `unregister` | `(db, resource_type: &str, resource_id: Uuid) → Result<()>` | Supprime du registre (cascade sur les partages). |
388 +
389 +#### Partage
390 +
391 +| Methode | Signature | Description |
392 +|---------|-----------|-------------|
393 +| `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. |
394 +| `revoke_share` | `(db, share_id: Uuid) → Result<()>` | Revoque un partage par ID. |
395 +| `revoke_user_access` | `(db, resource_type, resource_id, user_id) → Result<()>` | Revoque tous les partages pour un couple (ressource, utilisateur). |
396 +| `list_shares` | `(db, resource_type, resource_id) → Result<Vec<resource_share::Model>>` | Liste les partages d'une ressource. |
397 +
398 +#### Controle d'acces
399 +
400 +| Methode | Signature | Description |
401 +|---------|-----------|-------------|
402 +| `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. |
403 +| `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`. |
404 +| `shared_with_user` | `(db, user_id, resource_type) → Result<Vec<(resource::Model, resource_share::Model)>>` | Ressources partagees avec l'utilisateur. |
405 +| `accessible_by_user` | `(db, user_id, resource_type) → Result<Vec<resource::Model>>` | Toutes les ressources accessibles (possedees + partagees + publiques). |
406 +
407 +**Exemple d'utilisation dans un projet consommateur** :
408 +
409 +```rust
410 +use rustwarden_core::prelude::*;
411 +
412 +// Enregistrer un "wall" PasterMan comme ressource
413 +ResourceService::register(&db, "wall", wall.id, user.user_id).await?;
414 +
415 +// Partager en lecture
416 +ResourceService::share(&db, "wall", wall.id, other_user_id, "read", user.user_id).await?;
417 +
418 +// Verifier l'acces
419 +if ResourceService::user_can_access(&db, visitor_id, "wall", wall.id, "read").await? {
420 + // Afficher le wall
421 +}
422 +
423 +// Permission effective
424 +match ResourceService::effective_permission(&db, visitor_id, "wall", wall.id).await? {
425 + Some(ref p) if p == "owner" => /* acces total */,
426 + Some(ref p) if p == "write" => /* edition permise */,
427 + Some(ref p) if p == "read" => /* lecture seule */,
428 + _ => /* acces refuse */,
429 +}
430 +```
431 +
432 +---
433 +
434 +## 9. Configuration applicative
435 +
436 +### `AppSettingsService`
437 +
438 +**Module** : `rustwarden_core::services::app_settings_service`
439 +
440 +Stockage cle-valeur en base pour la configuration dynamique.
441 +
442 +| Methode | Signature | Description |
443 +|---------|-----------|-------------|
444 +| `get_setting` | `(db, key: &str) → Result<Option<app_settings::Model>>` | Lire un parametre. |
445 +| `set_setting` | `(db, key: String, value: String, updated_by: Option<Uuid>) → Result<Model>` | Creer ou mettre a jour un parametre. |
446 +| `get_all_settings` | `(db) → Result<Vec<Model>>` | Lire tous les parametres. |
447 +| `get_bool` | `(db, key: &str, default: bool) → Result<bool>` | Lire un booleen avec valeur par defaut. |
448 +| `get_bool_with_fallback` | `(db, keys: &[&str], default: bool) → Result<bool>` | Lire un booleen avec cles de fallback. |
449 +| `get_server_config` | `(db, config_name: &str) → Result<Option<String>>` | Lire une config serveur (`*:server_config:{config_name}`). |
450 +| `set_server_config` | `(db, app_id: &str, config_name: &str, value: String) → Result<Model>` | Ecrire une config serveur (`{app_id}:server_config:{config_name}`). |
451 +| `initialize_default_settings` | `(db) → Result<()>` | Seed les ~30 parametres par defaut. Idempotent. Appele automatiquement par `build()`. |
452 +
453 +**Parametres par defaut** (extrait) :
454 +
455 +| Cle | Defaut | Description |
456 +|-----|--------|-------------|
457 +| `app_name` | `"Rustwarden"` | Nom de l'application |
458 +| `app_domain` | `""` | Domaine public |
459 +| `registration_enabled` | `"true"` | Inscription ouverte |
460 +| `email_verification_required` | `"true"` | Verification email obligatoire |
461 +| `password_min_length` | `"12"` | Longueur minimale du mot de passe |
462 +| `password_require_uppercase` | `"true"` | Exiger une majuscule |
463 +| `oauth_enabled` | `"false"` | OAuth active |
464 +
465 +---
466 +
467 +## 10. Preferences utilisateur
468 +
469 +### `UserPreferencesService`
470 +
471 +**Module** : `rustwarden_core::services::user_preferences_service`
472 +
473 +| Methode | Signature | Description |
474 +|---------|-----------|-------------|
475 +| `get_preferences` | `(db, user_id: Uuid) → Result<Option<Model>>` | Lire les preferences. |
476 +| `create_preferences` | `(db, user_id: Uuid, theme: String, language: String) → Result<Model>` | Creer les preferences. |
477 +| `update_preferences` | `(db, user_id: Uuid, theme: String, language: String) → Result<Model>` | Mettre a jour les preferences. |
478 +| `create_default_preferences` | `(db, user_id: Uuid) → Result<Model>` | Creer les preferences par defaut (`theme: "light"`, `language: "fr"`). |
479 +
480 +**Valeurs acceptees** :
481 +- `theme` : `"light"`, `"dark"`, `"auto"`
482 +- `language` : `"fr"`, `"en"`, `"de"`, `"es"`
483 +
484 +---
485 +
486 +## 11. Email
487 +
488 +### `EmailService`
489 +
490 +**Module** : `rustwarden_core::services::email_service`
491 +
492 +Transport SMTP de bas niveau.
493 +
494 +| Methode | Signature | Description |
495 +|---------|-----------|-------------|
496 +| `new` | `(config: EmailConfig) → Result<Self>` | Cree le service avec connexion SMTP. |
497 +| `send_email` | `(&self, to_email, to_name: Option<&str>, subject, body_html, body_text) → Result<(Option<String>, Option<String>)>` | Envoie un email. Retourne `(smtp_code, smtp_response)`. |
498 +| `validate_config` | `(config: &EmailConfig) → Result<()>` | Valide la configuration SMTP. |
499 +
500 +### `EmailQueueService`
501 +
502 +**Module** : `rustwarden_core::services::email_queue_service`
503 +
504 +File d'attente d'emails persistante en base (envoi asynchrone par tache de fond).
505 +
506 +| Methode | Signature | Description |
507 +|---------|-----------|-------------|
508 +| `enqueue` | `(db, to_email, to_name, from_email, from_name, subject, body_html, body_text, email_type) → Result<Model>` | Ajoute un email a la file. |
509 +| `get_pending` | `(db, limit: usize) → Result<Vec<Model>>` | Recupere les emails en attente (plus ancien en premier). |
510 +| `mark_sent` | `(db, id: Uuid, smtp_code, smtp_response) → Result<()>` | Marque comme envoye. |
511 +| `mark_failed` | `(db, id: Uuid, error_message, smtp_code, smtp_response) → Result<()>` | Marque comme echoue. |
512 +| `increment_attempts` | `(db, id: Uuid) → Result<()>` | Incremente le compteur de tentatives. |
513 +| `get_all` | `(db, status_filter, email_type_filter, limit, offset) → Result<Vec<Model>>` | Liste avec filtres et pagination. |
514 +| `record_status` | `(db, email, state, smtp_code, smtp_response, ...) → Result<()>` | Enregistre le statut de livraison dans `email_delivery_status`. |
515 +| `list_delivery_statuses` | `(db, recipient, email_type, state, since, limit) → Result<Vec<Model>>` | Liste les statuts de livraison avec filtres. |
516 +
517 +### `EmailValidationService`
518 +
519 +**Module** : `rustwarden_core::services::email_validation_service`
520 +
521 +Verification d'email par token.
522 +
523 +| Methode | Signature | Description |
524 +|---------|-----------|-------------|
525 +| `new` | `(config: EmailConfig) → Self` | Constructeur. |
526 +| `send_validation_email` | `(&self, db, user_id: Uuid) → Result<()>` | Envoie l'email de verification. Invalide les tokens precedents. |
527 +| `verify_email` | `(&self, db, token: &str) → Result<Uuid>` | Verifie l'email via le token. Passe `email_verified = true`. Envoie un email de bienvenue. |
528 +| `resend_verification` | `(&self, db, email: &str) → Result<bool>` | Renvoie l'email de verification. Rate limited (3/heure). |
529 +
530 +### `ImapBounceService`
531 +
532 +**Module** : `rustwarden_core::services::imap_bounce_service`
533 +
534 +Detection des bounces email via IMAP (tache de fond).
535 +
536 +| Methode | Signature | Description |
537 +|---------|-----------|-------------|
538 +| `new` | `(config: EmailConfig, db: Arc<DatabaseConnection>) → Self` | Constructeur. |
539 +| `is_enabled` | `(&self) → bool` | Verifie si le traitement IMAP est configure. |
540 +| `start` | `(self)` | Demarre la tache de fond (polling IMAP a intervalle configurable). |
541 +
542 +Le service parse les messages DSN (RFC 3464) et met a jour la table `email_delivery_status`.
543 +
544 +---
545 +
546 +## 12. Chiffrement
547 +
548 +### `CryptoService`
549 +
550 +**Module** : `rustwarden_core::services::crypto_service`
551 +
552 +Chiffrement AES-256-GCM pour les secrets en base (secrets OAuth, etc.).
553 +
554 +| Methode | Signature | Description |
555 +|---------|-----------|-------------|
556 +| `encrypt` | `(plaintext: &str) → Result<String>` | Chiffre. Retourne `enc:<base64(nonce \|\| ciphertext)>`. |
557 +| `decrypt` | `(value: &str) → Result<String>` | Dechiffre. Si la valeur n'a pas le prefixe `enc:`, la retourne telle quelle (retrocompat). |
558 +| `is_available` | `() → bool` | Verifie si `OAUTH_ENCRYPTION_KEY` est configure (64 chars hex = 32 bytes). |
559 +| `is_encrypted` | `(value: &str) → bool` | Verifie le prefixe `enc:`. |
560 +
561 +---
562 +
563 +## 13. OAuth (feature `oauth`)
564 +
565 +> Disponible uniquement si la feature `oauth` est activee dans `Cargo.toml`.
566 +
567 +### `OAuthService`
568 +
569 +**Module** : `rustwarden_core::services::oauth::service`
570 +
571 +| Methode | Signature | Description |
572 +|---------|-----------|-------------|
573 +| `find_oauth_account` | `(db, provider: &str, provider_user_id: &str) → Result<Option<Model>>` | Trouve un compte OAuth lie. |
574 +| `find_user_oauth_accounts` | `(db, user_id: Uuid) → Result<Vec<Model>>` | Liste les comptes OAuth d'un utilisateur. |
575 +| `link_account` | `(db, user_id: Uuid, provider: &str, info: &ProviderUserInfo) → Result<Model>` | Lie un compte OAuth a un utilisateur. |
576 +| `unlink_account` | `(db, user_id: Uuid, provider: &str) → Result<()>` | Delie un compte OAuth. **Refuse si c'est la seule methode d'authentification** (pas de mot de passe et un seul lien OAuth). |
577 +| `unlink_account_by_id` | `(db, user_id: Uuid, account_id: Uuid) → Result<()>` | Delie par ID (meme protection). |
578 +| `find_or_create_user` | `(db, provider: &str, info: &ProviderUserInfo) → Result<(user::Model, bool)>` | Trouve ou cree un utilisateur depuis les infos OAuth. Retourne `(user, is_new)`. Exige un email verifie du provider. |
579 +
580 +### Trait `OAuthProvider`
581 +
582 +**Module** : `rustwarden_core::services::oauth::provider_trait`
583 +
584 +Interface que chaque provider implemente (Google, GitHub, Discord, Microsoft).
585 +
586 +| Methode | Signature | Description |
587 +|---------|-----------|-------------|
588 +| `name` | `(&self) → &str` | Identifiant (`"google"`, `"github"`, `"discord"`, `"microsoft"`). |
589 +| `display_name` | `(&self) → &str` | Nom affichable (`"Google"`, `"GitHub"`, etc.). |
590 +| `authorization_url` | `(&self, redirect_uri: &str) → Result<AuthorizationUrl>` | Genere l'URL d'autorisation avec state/nonce/PKCE. |
591 +| `exchange_code` | `(&self, code, redirect_uri, pkce_verifier, nonce) → Result<ProviderUserInfo>` | Echange le code d'autorisation contre les infos utilisateur. |
592 +
593 +**Providers implementes** : Google (OIDC complet), GitHub (OAuth2 + REST API), Discord (OAuth2 + REST API), Microsoft (Azure AD avec support tenant).
594 +
595 +---
596 +
597 +## 14. Utilitaires
598 +
599 +### `AuthLoggingService`
600 +
601 +**Module** : `rustwarden_core::services::auth_logging`
602 +
603 +| Methode | Signature | Description |
604 +|---------|-----------|-------------|
605 +| `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). |
606 +
607 +### `email_sanitization`
608 +
609 +**Module** : `rustwarden_core::services::email_sanitization`
610 +
611 +| Fonction | Signature | Description |
612 +|----------|-----------|-------------|
613 +| `sanitize_email_content` | `(content: &str) → String` | Echappe les caracteres HTML (`&`, `<`, `>`, `"`, `'`). |
614 +| `is_valid_email_format` | `(email: &str) → bool` | Validation RFC d'email via le crate `validator`. |
615 +
616 +### `default_admin_service`
617 +
618 +**Module** : `rustwarden_core::services::default_admin_service`
619 +
620 +| Fonction | Signature | Description |
621 +|----------|-----------|-------------|
622 +| `ensure_default_admin` | `(db) → Result<()>` | Cree l'administrateur par defaut au demarrage si aucun admin n'existe. Lit `ADMIN_USERNAME`, `ADMIN_EMAIL`, `ADMIN_PASSWORD`. Appele automatiquement par `build()`. |
623 +
624 +---
625 +
626 +## 15. Patterns communs
627 +
628 +### Gestion d'erreurs
629 +
630 +Tous les services retournent `Result<T, AppError>`. Les variantes principales :
631 +
632 +| Variante | HTTP | Usage |
633 +|----------|------|-------|
634 +| `AppError::NotFound(msg)` | 404 | Ressource introuvable |
635 +| `AppError::Validation(msg)` | 400 | Donnees invalides |
636 +| `AppError::Forbidden` | 403 | Acces refuse (RBAC) |
637 +| `AppError::Unauthorized` | 401 | Non authentifie |
638 +| `AppError::TokenExpired` | 401 | JWT expire |
639 +| `AppError::TokenReused` | 401 | Refresh token reutilise (attaque detectee) |
640 +| `AppError::Conflict(msg)` | 409 | Unicite violee (username, email) |
641 +| `AppError::Internal(msg)` | 500 | Erreur interne |
642 +| `AppError::Config(msg)` | 500 | Configuration manquante |
643 +
644 +### Rate limiting
645 +
646 +Les services sensibles implementent un rate limiter en memoire :
647 +
648 +| Service | Limite | Cle |
649 +|---------|--------|-----|
650 +| `PasswordChangeService` | 3/heure | `user_id` |
651 +| `PasswordResetService` | 3/heure | `email` |
652 +| `EmailValidationService` | 3/heure | `email` |
653 +
654 +### Tokens hashes en base
655 +
656 +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.
657 +
658 +### Revocation en cascade (SEC-M7)
659 +
660 +Les operations critiques revoquent automatiquement tous les refresh tokens de l'utilisateur :
661 +- `PasswordChangeService::confirm_request()`
662 +- `PasswordResetService::reset_password()`
663 +
664 +Cela force la reconnexion sur tous les appareils apres un changement de mot de passe.
A docs/UPSTREAM_RUSTWARDEN.md
+62 -0
@@ -0,0 +1,62 @@
1 +# Notes d'amelioration pour RustWarden-core
2 +
3 +## 1. `PasswordService` : operations bcrypt bloquantes dans un contexte async
4 +
5 +**Statut : IMPLEMENTE** (approche 2 — methodes rendues directement async)
6 +
7 +**Probleme :** `PasswordService::hash_password` et `PasswordService::verify_password` etaient des fonctions synchrones executant bcrypt (CPU-intensif, ~100-300ms selon le cost factor). Elles bloquaient le thread du runtime Tokio.
8 +
9 +**Solution implementee :** Les methodes ont ete rendues `async` avec `tokio::task::spawn_blocking` interne. Aucun suffixe `_async`, aucune duplication d'API. Tous les call sites (deja en contexte async) ont ete migres mecaniquement avec `.await`.
10 +
11 +**Approches evaluees et rejetees :**
12 +- **Suffixe `_async`** (ex: `hash_password_async`) — rejete car duplication d'API et anti-pattern en Rust
13 +- **Trait polymorphe** (`PasswordHasher`) — rejete car over-engineering (un seul algorithme utilise, pas de besoin de substitution)
14 +
15 +**Fichiers modifies :**
16 +
17 +| Fichier | Modification |
18 +|---------|-------------|
19 +| `services/password_service.rs` | `hash_password` et `verify_password` rendues `async` avec `spawn_blocking` |
20 +| `services/auth_service.rs` | `.await` sur `verify_password` + dummy hash migre vers `PasswordService::verify_password` |
21 +| `services/user_service.rs` | `.await` sur `verify_password` |
22 +| `services/password_change_service.rs` | `.await` sur `hash_password` |
23 +| `services/password_reset_service.rs` | `.await` sur `hash_password` |
24 +| `services/default_admin_service.rs` | `.await` sur `hash_password` |
25 +| `auth/handlers.rs` | `.await` sur `hash_password` (handler register) |
26 +| `bin/seed.rs` | `.await` sur `hash_password` (deja `#[tokio::main]`) |
27 +| `bin/change_password.rs` | `.await` sur `hash_password` (deja `#[tokio::main]`) |
28 +| `rustwarden-ui: admin_handler.rs` | `.await` sur `hash_password` (2 call sites) |
29 +| `rustwarden-ui: settings_handler.rs` | `.await` sur `verify_password` et `hash_password` |
30 +| `rustwarden-ui: user_handler.rs` | `.await` sur `hash_password` (2 call sites) |
31 +
32 +**Note :** `bin/hash_password.rs` n'est pas impacte — il appelle `bcrypt::hash` directement (CLI sync sans runtime Tokio).
33 +
34 +**Impact sur les consommateurs :** Breaking change mineur — tout appel a `PasswordService::hash_password()` ou `verify_password()` doit ajouter `.await`. Comme tous les contextes d'appel sont deja async, la migration est mecanique.
35 +
36 +---
37 +
38 +## 2. `UserService::create_user` : demande un hash pre-calcule
39 +
40 +**Statut : A FAIRE** (depend de §1, qui est maintenant implemente)
41 +
42 +**Observation :** `create_user(db, username, email, password_hash, must_change_password)` attend un hash deja calcule. L'appelant doit connaitre l'implementation du hashing (bcrypt) et appeler `hash_password().await` lui-meme.
43 +
44 +**Suggestion :** Ajouter une methode de convenance :
45 +
46 +```rust
47 +impl UserService {
48 + /// Create a user from a plaintext password (hashes on a blocking thread).
49 + pub async fn create_user_with_password(
50 + db: &DatabaseConnection,
51 + username: String,
52 + email: String,
53 + password: &str,
54 + must_change_password: bool,
55 + ) -> Result<user::Model, AppError> {
56 + let hash = PasswordService::hash_password(password).await?;
57 + Self::create_user(db, username, email, hash, must_change_password).await
58 + }
59 +}
60 +```
61 +
62 +La methode existante reste disponible pour les cas ou le hash est deja calcule (import, migration).
M tools/rustwarden-cli/src/start_project.rs
+31 -0
@@ -75,6 +75,7 @@ pub fn run() -> Result<(), Box<dyn std::error::Error>> {
75 75 }
76 76
77 77 copy_resources(&rustwarden_root, &project_dir, with_ui)?;
78 + copy_docs(&rustwarden_root, &project_dir)?;
78 79 generate_files(&project_dir, &cfg)?;
79 80 rebrand_templates(&project_dir, &app_display_name)?;
80 81
@@ -531,6 +532,36 @@ fn copy_resources(
531 532 Ok(())
532 533 }
533 534
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 + ];
544 +
545 + let dst_dir = project_dir.join("docs");
546 + fs::create_dir_all(&dst_dir)?;
547 +
548 + for doc in &docs_to_copy {
549 + let src = rustwarden_root.join(doc);
550 + if src.exists() {
551 + let filename = Path::new(doc).file_name().unwrap();
552 + let dst = dst_dir.join(filename);
553 + println!(
554 + " {} {}",
555 + style("Copie").dim(),
556 + style(format!("docs/{}", filename.to_string_lossy())).white()
557 + );
558 + fs::copy(&src, &dst)?;
559 + }
560 + }
561 +
562 + Ok(())
563 +}
564 +
534 565 fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), Box<dyn std::error::Error>> {
535 566 fs::create_dir_all(dst)?;
536 567