password_complexity.rs 209 lignes · 6981 octets
/// Password Complexity Validation Middleware
///
/// Middleware to validate password complexity according to configurable rules
/// stored in the `app_settings` table. Rules include minimum length and
/// required character types.
use axum::{
    extract::{Request, State},
    middleware::Next,
    response::Response,
};
use sea_orm::DatabaseConnection;

use crate::error::AppError;
use crate::services::app_settings_service::AppSettingsService;

/// Password validation rules (loaded from DB or defaults)
#[derive(Debug, Clone, serde::Serialize)]
pub struct PasswordRules {
    /// Minimum number of characters required in a password.
    pub min_length: usize,
    /// SEC-M3: bcrypt silently truncates at 72 bytes; cap passwords to prevent DoS
    pub max_length: usize,
    /// Whether at least one uppercase letter is required.
    pub require_uppercase: bool,
    /// Whether at least one lowercase letter is required.
    pub require_lowercase: bool,
    /// Whether at least one digit is required.
    pub require_digits: bool,
    /// Whether at least one special character is required.
    pub require_special: bool,
}

impl Default for PasswordRules {
    fn default() -> Self {
        Self {
            min_length: 8,
            max_length: 72,
            // SEC-L4: Sensible defaults — require at least mixed case and digits
            require_uppercase: true,
            require_lowercase: true,
            require_digits: true,
            require_special: false,
        }
    }
}

impl PasswordRules {
    /// Load password rules from the `app_settings` table.
    /// Falls back to defaults for any missing key.
    pub async fn from_db(db: &DatabaseConnection) -> Self {
        let get_bool = |key: &'static str, default: bool| async move {
            AppSettingsService::get_bool(db, key, default)
                .await
                .unwrap_or(default)
        };

        let min_length = match AppSettingsService::get_setting(db, "password_min_length").await {
            Ok(Some(s)) => s.value.parse::<usize>().unwrap_or(8),
            _ => 8,
        };

        let max_length = match AppSettingsService::get_setting(db, "password_max_length").await {
            Ok(Some(s)) => s.value.parse::<usize>().unwrap_or(72),
            _ => 72,
        };

        Self {
            min_length,
            max_length,
            require_uppercase: get_bool("password_require_uppercase", true).await,
            require_lowercase: get_bool("password_require_lowercase", true).await,
            require_digits: get_bool("password_require_digits", true).await,
            require_special: get_bool("password_require_special", false).await,
        }
    }

    /// Validate a password against these rules.
    pub fn validate(&self, password: &str) -> Result<(), Vec<String>> {
        let mut errors = Vec::new();

        if password.len() < self.min_length {
            errors.push(format!(
                "Password must be at least {} characters long",
                self.min_length
            ));
        }

        // SEC-M3: Reject overly long passwords (bcrypt truncates at 72 bytes, DoS risk)
        if password.len() > self.max_length {
            errors.push(format!(
                "Password must be at most {} characters long",
                self.max_length
            ));
        }

        if self.require_uppercase && !password.chars().any(|c| c.is_uppercase()) {
            errors.push("Password must contain at least one uppercase letter".to_string());
        }

        if self.require_lowercase && !password.chars().any(|c| c.is_lowercase()) {
            errors.push("Password must contain at least one lowercase letter".to_string());
        }

        if self.require_digits && !password.chars().any(|c| c.is_ascii_digit()) {
            errors.push("Password must contain at least one digit".to_string());
        }

        if self.require_special
            && !password
                .chars()
                .any(|c| "!@#$%^&*()_+-=[]{}|;:,.<>?".contains(c))
        {
            errors.push(
                "Password must contain at least one special character (!@#$%^&*()_+-=[]{}|;:,.<>?)"
                    .to_string(),
            );
        }

        if errors.is_empty() {
            Ok(())
        } else {
            Err(errors)
        }
    }
}

/// Middleware that validates password complexity for password-related operations.
///
/// Note: Password validation is currently done directly in handlers via
/// `validate_password_complexity()`. This middleware is kept as a hook point for
/// future use (e.g., global enforcement) but passes through by design.
pub async fn password_complexity_validation(
    State(_db): State<DatabaseConnection>,
    request: Request,
    next: Next,
) -> Result<Response, AppError> {
    Ok(next.run(request).await)
}

/// Validate password against rules loaded from the database.
pub async fn validate_password_complexity(
    db: &DatabaseConnection,
    password: &str,
) -> Result<(), AppError> {
    let rules = PasswordRules::from_db(db).await;
    match rules.validate(password) {
        Ok(()) => Ok(()),
        Err(errors) => Err(AppError::Validation(errors.join("; "))),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_password_rules_validation() {
        let rules = PasswordRules {
            min_length: 8,
            max_length: 72,
            require_uppercase: true,
            require_lowercase: true,
            require_digits: true,
            require_special: false,
        };

        assert!(rules.validate("Password123").is_ok());
        assert!(rules.validate("Pass1").is_err());
        assert!(rules.validate("password123").is_err());
        assert!(rules.validate("PASSWORD123").is_err());
        assert!(rules.validate("Password").is_err());
        assert!(rules.validate("a").is_err());
        assert!(rules.validate("password").is_err());
    }

    #[test]
    fn default_rules_reject_single_letter_and_password_literal() {
        let rules = PasswordRules::default();
        assert!(rules.validate("a").is_err());
        assert!(rules.validate("password").is_err());
    }

    #[test]
    fn default_rules_reject_password_literal() {
        let rules = PasswordRules::default();
        assert!(rules.validate("password").is_err());
    }

    #[test]
    fn default_rules_accept_mixed() {
        let rules = PasswordRules::default();
        assert!(rules.validate("CorrectHorse1").is_ok());
    }

    #[test]
    fn test_max_length_validation() {
        let rules = PasswordRules {
            min_length: 8,
            max_length: 20,
            require_uppercase: false,
            require_lowercase: false,
            require_digits: false,
            require_special: false,
        };
        let long_pw = "a".repeat(21);
        assert!(rules.validate(&long_pw).is_err());
        assert!(rules.validate("short_enough_pw1").is_ok());
    }
}