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