password_service.rs
325 lignes · 11430 octets
use crate::error::AppError; use bcrypt::DEFAULT_COST; use rand::Rng; use rand::seq::SliceRandom; use std::sync::LazyLock; /// SEC-M5: Bcrypt cost factor, configurable via BCRYPT_COST env var (default: 12, min: 10, max: 20). static BCRYPT_COST: LazyLock<u32> = LazyLock::new(|| { let cost = std::env::var("BCRYPT_COST") .ok() .and_then(|v| v.parse::<u32>().ok()) .unwrap_or(DEFAULT_COST); cost.clamp(12, 20) }); /// Service for password operations (hashing, verification, generation) /// /// This service handles all password-related security operations, /// including bcrypt hashing and default password generation. pub struct PasswordService; impl PasswordService { /// Hash a plaintext password using bcrypt on a blocking thread. /// /// Bcrypt is CPU-intensive (~100-300ms at cost 12). This method offloads /// the work to `spawn_blocking` so it does not block the Tokio runtime. /// /// # Arguments /// /// * `password` - The plaintext password to hash /// /// # Returns /// /// The bcrypt hash of the password /// /// # Errors /// /// Returns `AppError::Internal` if hashing or the blocking task fails pub async fn hash_password(password: &str) -> Result<String, AppError> { let password = password.to_string(); let cost = *BCRYPT_COST; tokio::task::spawn_blocking(move || { bcrypt::hash(&password, cost).map_err(|e| { tracing::error!("Failed to hash password: {}", e); AppError::Internal("Password hashing failed".to_string()) }) }) .await .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))? } /// Verify a plaintext password against a bcrypt hash on a blocking thread. /// /// Bcrypt verification is CPU-intensive. This method offloads the work /// to `spawn_blocking` so it does not block the Tokio runtime. /// /// # Arguments /// /// * `password` - The plaintext password to verify /// * `hash` - The bcrypt hash to verify against /// /// # Returns /// /// `true` if the password matches the hash, `false` otherwise /// /// # Errors /// /// Returns `AppError::Internal` if verification or the blocking task fails pub async fn verify_password(password: &str, hash: &str) -> Result<bool, AppError> { let password = password.to_string(); let hash = hash.to_string(); tokio::task::spawn_blocking(move || { bcrypt::verify(&password, &hash).map_err(|e| { tracing::error!("Failed to verify password: {}", e); AppError::Internal("Password verification failed".to_string()) }) }) .await .map_err(|e| AppError::Internal(format!("Blocking task failed: {e}")))? } /// Generate a secure default password for a user /// /// The default password format is: `RustWarden-{first8chars_of_uuid}` /// Example: `RustWarden-a1b2c3d4` /// /// This password is: /// - Unique (based on UUID) /// - Complies with typical password policies (mixed case, length) /// - Easy to communicate to the user /// - Cannot be easily guessed /// /// **Security note**: Users MUST change this password on first login. /// The `must_change_password` flag should be set to `true` when using this. /// /// # Returns /// /// A secure default password string /// SEC-L3: Generate a default password with sufficient entropy (128+ bits). /// Uses the same secure generator as `generate_secure_password`. pub fn generate_default_password() -> String { Self::generate_secure_password() } /// Generate a cryptographically secure random password /// /// This function generates a password with the following characteristics: /// - Length: 20 characters exactly /// - Charset: A-Z, a-z, 0-9, and special characters (!@#$%^&*()_+-=[]{}|;:,.<>?) /// - Distribution: Guaranteed to contain at least one uppercase, one lowercase, /// one digit, and one special character /// - Cryptographically secure: Uses `rand::thread_rng()` for randomness /// /// This is suitable for automatically generated admin passwords that will be /// logged in debug mode only. /// /// # Returns /// /// A secure 20-character password string /// /// # Examples /// /// ```rust,no_run /// use rustwarden_core::services::password_service::PasswordService; /// /// let password = PasswordService::generate_secure_password(); /// assert_eq!(password.len(), 20); /// ``` pub fn generate_secure_password() -> String { const CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ abcdefghijklmnopqrstuvwxyz\ 0123456789\ !@#$%^&*()_+-=[]{}|;:,.<>?"; let mut rng = rand::rng(); let mut password = Vec::with_capacity(20); // Ensure at least one of each required type let uppercase = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ"; let lowercase = b"abcdefghijklmnopqrstuvwxyz"; let digits = b"0123456789"; let special = b"!@#$%^&*()_+-=[]{}|;:,.<>?"; // Add one of each required type password.push(uppercase[rng.random_range(0..uppercase.len())] as char); password.push(lowercase[rng.random_range(0..lowercase.len())] as char); password.push(digits[rng.random_range(0..digits.len())] as char); password.push(special[rng.random_range(0..special.len())] as char); // Fill the rest randomly for _ in 4..20 { let idx = rng.random_range(0..CHARSET.len()); password.push(CHARSET[idx] as char); } // Shuffle to avoid predictable pattern password.as_mut_slice().shuffle(&mut rng); password.into_iter().collect() } /// Validate password strength (legacy hardcoded version) /// /// **Deprecated**: Use `crate::middleware::password_complexity::validate_password_complexity` /// which reads rules from `app_settings` in the database. #[allow(dead_code)] pub fn validate_password_strength(password: &str) -> Result<(), AppError> { if password.len() < 8 { return Err(AppError::Validation( "Password must be at least 8 characters long".to_string(), )); } if !password.chars().any(|c| c.is_uppercase()) { return Err(AppError::Validation( "Password must contain at least one uppercase letter".to_string(), )); } if !password.chars().any(|c| c.is_lowercase()) { return Err(AppError::Validation( "Password must contain at least one lowercase letter".to_string(), )); } if !password.chars().any(|c| c.is_ascii_digit()) { return Err(AppError::Validation( "Password must contain at least one digit".to_string(), )); } let special_chars = "!@#$%^&*()_+-=[]{}|;:,.<>?"; if !password.chars().any(|c| special_chars.contains(c)) { return Err(AppError::Validation( "Password must contain at least one special character".to_string(), )); } Ok(()) } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_hash_and_verify_password() { let password = "my_secure_password_123"; let hash = PasswordService::hash_password(password).await.unwrap(); // Verify correct password assert!( PasswordService::verify_password(password, &hash) .await .unwrap() ); // Verify incorrect password assert!( !PasswordService::verify_password("wrong_password", &hash) .await .unwrap() ); } #[test] fn test_generate_default_password_format() { let password = PasswordService::generate_default_password(); // SEC-L3: Default password now uses generate_secure_password (20 chars) assert_eq!(password.len(), 20); let password2 = PasswordService::generate_default_password(); assert_ne!(password, password2); } #[tokio::test] async fn test_default_password_can_be_hashed() { let password = PasswordService::generate_default_password(); let hash = PasswordService::hash_password(&password).await; assert!(hash.is_ok()); } #[test] fn test_generate_secure_password_length() { let password = PasswordService::generate_secure_password(); assert_eq!( password.len(), 20, "Password should be exactly 20 characters" ); } #[test] fn test_generate_secure_password_distribution() { let password = PasswordService::generate_secure_password(); // Check that password contains at least one of each required type let has_uppercase = password.chars().any(|c| c.is_uppercase()); let has_lowercase = password.chars().any(|c| c.is_lowercase()); let has_digit = password.chars().any(|c| c.is_ascii_digit()); let has_special = password .chars() .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c)); assert!( has_uppercase, "Password should contain at least one uppercase letter" ); assert!( has_lowercase, "Password should contain at least one lowercase letter" ); assert!(has_digit, "Password should contain at least one digit"); assert!( has_special, "Password should contain at least one special character" ); } #[test] fn test_generate_secure_password_uniqueness() { // Generate 100 passwords and verify they're all different let passwords: Vec<String> = (0..100) .map(|_| PasswordService::generate_secure_password()) .collect(); // Check uniqueness for i in 0..passwords.len() { for j in (i + 1)..passwords.len() { assert_ne!( passwords[i], passwords[j], "Generated passwords should be unique" ); } } } #[test] fn test_generate_secure_password_valid_chars() { const VALID_CHARS: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ\ abcdefghijklmnopqrstuvwxyz\ 0123456789\ !@#$%^&*()_+-=[]{}|;:,.<>?"; let password = PasswordService::generate_secure_password(); // Check that all characters are in the valid charset for ch in password.chars() { assert!( VALID_CHARS.contains(ch), "Password contains invalid character: {}", ch ); } } #[tokio::test] async fn test_generate_secure_password_can_be_hashed() { let password = PasswordService::generate_secure_password(); let hash = PasswordService::hash_password(&password).await; assert!(hash.is_ok(), "Generated password should be hashable"); } }
GitRust