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()); } }
GitRust