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");
    }
}