oxify_authn/
password.rs

1//! Password hashing and validation with configurable policy
2//!
3//! Ported from `OxiRS` (<https://github.com/cool-japan/oxirs>)
4//! Original implementation: Copyright (c) `OxiRS` Contributors
5//! Adapted for `OxiFY`
6//! License: MIT OR Apache-2.0 (compatible with `OxiRS`)
7//!
8//! # Features
9//! - Argon2 password hashing (memory-hard, GPU-resistant)
10//! - Configurable password policy enforcement
11//! - Password strength analysis
12//! - Common password checking
13
14use crate::types::{AuthError, Result};
15use argon2::{
16    password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
17    Argon2,
18};
19use serde::{Deserialize, Serialize};
20
21/// Password policy configuration
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct PasswordPolicy {
24    /// Minimum password length
25    pub min_length: usize,
26    /// Maximum password length (0 = unlimited)
27    pub max_length: usize,
28    /// Require at least one uppercase letter
29    pub require_uppercase: bool,
30    /// Require at least one lowercase letter
31    pub require_lowercase: bool,
32    /// Require at least one digit
33    pub require_digit: bool,
34    /// Require at least one special character
35    pub require_special: bool,
36    /// Minimum number of character classes required (1-4)
37    pub min_character_classes: usize,
38    /// Disallow common passwords
39    pub disallow_common: bool,
40    /// Disallow passwords containing username
41    pub disallow_username: bool,
42    /// Minimum strength level required
43    pub min_strength: PasswordStrength,
44}
45
46impl Default for PasswordPolicy {
47    fn default() -> Self {
48        Self {
49            min_length: 8,
50            max_length: 128,
51            require_uppercase: false,
52            require_lowercase: false,
53            require_digit: false,
54            require_special: false,
55            min_character_classes: 3,
56            disallow_common: true,
57            disallow_username: true,
58            min_strength: PasswordStrength::Medium,
59        }
60    }
61}
62
63impl PasswordPolicy {
64    /// Create a strict password policy
65    #[must_use]
66    pub fn strict() -> Self {
67        Self {
68            min_length: 12,
69            max_length: 128,
70            require_uppercase: true,
71            require_lowercase: true,
72            require_digit: true,
73            require_special: true,
74            min_character_classes: 4,
75            disallow_common: true,
76            disallow_username: true,
77            min_strength: PasswordStrength::Strong,
78        }
79    }
80
81    /// Create a relaxed password policy
82    #[must_use]
83    pub fn relaxed() -> Self {
84        Self {
85            min_length: 6,
86            max_length: 128,
87            require_uppercase: false,
88            require_lowercase: false,
89            require_digit: false,
90            require_special: false,
91            min_character_classes: 1,
92            disallow_common: false,
93            disallow_username: false,
94            min_strength: PasswordStrength::VeryWeak,
95        }
96    }
97
98    /// Create a NIST-compliant password policy
99    /// Based on NIST SP 800-63B guidelines
100    #[must_use]
101    pub fn nist_compliant() -> Self {
102        Self {
103            min_length: 8,
104            max_length: 64,
105            require_uppercase: false,
106            require_lowercase: false,
107            require_digit: false,
108            require_special: false,
109            min_character_classes: 1,
110            disallow_common: true,
111            disallow_username: true,
112            min_strength: PasswordStrength::Weak,
113        }
114    }
115}
116
117/// Result of password policy validation
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct PolicyValidationResult {
120    /// Whether the password passes the policy
121    pub is_valid: bool,
122    /// List of policy violations
123    pub violations: Vec<PolicyViolation>,
124    /// Password strength
125    pub strength: PasswordStrength,
126    /// Suggestions for improvement
127    pub suggestions: Vec<String>,
128}
129
130/// Password policy violation types
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
132pub enum PolicyViolation {
133    /// Password is too short
134    TooShort { min: usize, actual: usize },
135    /// Password is too long
136    TooLong { max: usize, actual: usize },
137    /// Missing uppercase letter
138    MissingUppercase,
139    /// Missing lowercase letter
140    MissingLowercase,
141    /// Missing digit
142    MissingDigit,
143    /// Missing special character
144    MissingSpecial,
145    /// Not enough character classes
146    InsufficientCharacterClasses { required: usize, actual: usize },
147    /// Password is too common
148    CommonPassword,
149    /// Password contains username
150    ContainsUsername,
151    /// Password is too weak
152    TooWeak {
153        required: PasswordStrength,
154        actual: PasswordStrength,
155    },
156}
157
158/// Password manager for hashing and verifying passwords
159pub struct PasswordManager {
160    argon2: Argon2<'static>,
161    policy: PasswordPolicy,
162}
163
164impl PasswordManager {
165    /// Create a new password manager with default policy
166    #[must_use]
167    pub fn new() -> Self {
168        Self {
169            argon2: Argon2::default(),
170            policy: PasswordPolicy::default(),
171        }
172    }
173
174    /// Create a password manager with custom policy
175    #[must_use]
176    pub fn with_policy(policy: PasswordPolicy) -> Self {
177        Self {
178            argon2: Argon2::default(),
179            policy,
180        }
181    }
182
183    /// Get the current policy
184    #[must_use]
185    pub fn policy(&self) -> &PasswordPolicy {
186        &self.policy
187    }
188
189    /// Hash a password using Argon2
190    pub fn hash_password(&self, password: &str) -> Result<String> {
191        let salt = SaltString::generate(&mut OsRng);
192        let password_hash = self
193            .argon2
194            .hash_password(password.as_bytes(), &salt)
195            .map_err(|e| AuthError::InternalError(format!("Failed to hash password: {e}")))?;
196
197        Ok(password_hash.to_string())
198    }
199
200    /// Verify a password against a hash
201    pub fn verify_password(&self, password: &str, hash: &str) -> Result<bool> {
202        let parsed_hash = PasswordHash::new(hash)
203            .map_err(|e| AuthError::InternalError(format!("Invalid password hash: {e}")))?;
204
205        match self
206            .argon2
207            .verify_password(password.as_bytes(), &parsed_hash)
208        {
209            Ok(()) => Ok(true),
210            Err(_) => Ok(false),
211        }
212    }
213
214    /// Validate password against policy
215    pub fn validate_password(
216        &self,
217        password: &str,
218        username: Option<&str>,
219    ) -> PolicyValidationResult {
220        let mut violations = Vec::new();
221        let mut suggestions = Vec::new();
222
223        let length = password.len();
224        let has_uppercase = password.chars().any(char::is_uppercase);
225        let has_lowercase = password.chars().any(char::is_lowercase);
226        let has_digit = password.chars().any(char::is_numeric);
227        let has_special = password.chars().any(|c| !c.is_alphanumeric());
228
229        let character_classes = usize::from(has_uppercase)
230            + usize::from(has_lowercase)
231            + usize::from(has_digit)
232            + usize::from(has_special);
233
234        // Check minimum length
235        if length < self.policy.min_length {
236            violations.push(PolicyViolation::TooShort {
237                min: self.policy.min_length,
238                actual: length,
239            });
240            suggestions.push(format!(
241                "Password must be at least {} characters long",
242                self.policy.min_length
243            ));
244        }
245
246        // Check maximum length
247        if self.policy.max_length > 0 && length > self.policy.max_length {
248            violations.push(PolicyViolation::TooLong {
249                max: self.policy.max_length,
250                actual: length,
251            });
252            suggestions.push(format!(
253                "Password must be at most {} characters long",
254                self.policy.max_length
255            ));
256        }
257
258        // Check required character types
259        if self.policy.require_uppercase && !has_uppercase {
260            violations.push(PolicyViolation::MissingUppercase);
261            suggestions.push("Add at least one uppercase letter".to_string());
262        }
263
264        if self.policy.require_lowercase && !has_lowercase {
265            violations.push(PolicyViolation::MissingLowercase);
266            suggestions.push("Add at least one lowercase letter".to_string());
267        }
268
269        if self.policy.require_digit && !has_digit {
270            violations.push(PolicyViolation::MissingDigit);
271            suggestions.push("Add at least one digit".to_string());
272        }
273
274        if self.policy.require_special && !has_special {
275            violations.push(PolicyViolation::MissingSpecial);
276            suggestions.push("Add at least one special character (!@#$%^&* etc.)".to_string());
277        }
278
279        // Check minimum character classes
280        if character_classes < self.policy.min_character_classes {
281            violations.push(PolicyViolation::InsufficientCharacterClasses {
282                required: self.policy.min_character_classes,
283                actual: character_classes,
284            });
285            suggestions.push(format!(
286                "Use at least {} of: uppercase, lowercase, digits, special characters",
287                self.policy.min_character_classes
288            ));
289        }
290
291        // Check common passwords
292        if self.policy.disallow_common && Self::is_common_password(password) {
293            violations.push(PolicyViolation::CommonPassword);
294            suggestions.push("Choose a less common password".to_string());
295        }
296
297        // Check username in password
298        if self.policy.disallow_username {
299            if let Some(user) = username {
300                if !user.is_empty() && password.to_lowercase().contains(&user.to_lowercase()) {
301                    violations.push(PolicyViolation::ContainsUsername);
302                    suggestions.push("Password should not contain your username".to_string());
303                }
304            }
305        }
306
307        // Check strength
308        let strength = self.check_password_strength(password);
309        if strength < self.policy.min_strength {
310            violations.push(PolicyViolation::TooWeak {
311                required: self.policy.min_strength,
312                actual: strength,
313            });
314            suggestions.push(format!(
315                "Password strength must be at least {:?}",
316                self.policy.min_strength
317            ));
318        }
319
320        PolicyValidationResult {
321            is_valid: violations.is_empty(),
322            violations,
323            strength,
324            suggestions,
325        }
326    }
327
328    /// Check password strength
329    pub fn check_password_strength(&self, password: &str) -> PasswordStrength {
330        let length = password.len();
331        let has_uppercase = password.chars().any(char::is_uppercase);
332        let has_lowercase = password.chars().any(char::is_lowercase);
333        let has_digit = password.chars().any(char::is_numeric);
334        let has_special = password.chars().any(|c| !c.is_alphanumeric());
335
336        let score = u8::from(length >= 8)
337            + u8::from(length >= 12)
338            + u8::from(length >= 16)
339            + u8::from(has_uppercase)
340            + u8::from(has_lowercase)
341            + u8::from(has_digit)
342            + u8::from(has_special);
343
344        match score {
345            0..=2 => PasswordStrength::VeryWeak,
346            3..=4 => PasswordStrength::Weak,
347            5 => PasswordStrength::Medium,
348            6 => PasswordStrength::Strong,
349            _ => PasswordStrength::VeryStrong,
350        }
351    }
352
353    /// Check if password is in common passwords list
354    fn is_common_password(password: &str) -> bool {
355        const COMMON_PASSWORDS: &[&str] = &[
356            "password",
357            "123456",
358            "12345678",
359            "qwerty",
360            "abc123",
361            "monkey",
362            "1234567",
363            "letmein",
364            "trustno1",
365            "dragon",
366            "baseball",
367            "iloveyou",
368            "master",
369            "sunshine",
370            "ashley",
371            "bailey",
372            "shadow",
373            "123123",
374            "654321",
375            "superman",
376            "qazwsx",
377            "michael",
378            "football",
379            "password1",
380            "password123",
381            "welcome",
382            "jesus",
383            "ninja",
384            "mustang",
385            "password!",
386            "admin",
387            "root",
388            "toor",
389            "pass",
390            "test",
391            "guest",
392            "master",
393            "changeme",
394            "default",
395            "hello",
396        ];
397
398        let lower = password.to_lowercase();
399        COMMON_PASSWORDS.contains(&lower.as_str())
400    }
401
402    /// Generate a random password meeting policy requirements
403    #[must_use]
404    pub fn generate_password(&self) -> String {
405        use rand::Rng;
406        let mut rng = rand::rng();
407
408        let length = std::cmp::max(self.policy.min_length, 16);
409
410        let uppercase: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ";
411        let lowercase: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
412        let digits: &[u8] = b"0123456789";
413        let special: &[u8] = b"!@#$%^&*()_+-=[]{}|;:,.<>?";
414
415        let mut password = Vec::with_capacity(length);
416
417        // Ensure we have at least one of each required type
418        if self.policy.require_uppercase || self.policy.min_character_classes >= 1 {
419            password.push(uppercase[rng.random_range(0..uppercase.len())]);
420        }
421        if self.policy.require_lowercase || self.policy.min_character_classes >= 2 {
422            password.push(lowercase[rng.random_range(0..lowercase.len())]);
423        }
424        if self.policy.require_digit || self.policy.min_character_classes >= 3 {
425            password.push(digits[rng.random_range(0..digits.len())]);
426        }
427        if self.policy.require_special || self.policy.min_character_classes >= 4 {
428            password.push(special[rng.random_range(0..special.len())]);
429        }
430
431        // Fill the rest with random characters from all sets
432        let all_chars: Vec<u8> = [uppercase, lowercase, digits, special].concat();
433        while password.len() < length {
434            password.push(all_chars[rng.random_range(0..all_chars.len())]);
435        }
436
437        // Shuffle the password
438        for i in (1..password.len()).rev() {
439            let j = rng.random_range(0..=i);
440            password.swap(i, j);
441        }
442
443        String::from_utf8(password).unwrap_or_else(|_| self.generate_password())
444    }
445}
446
447impl Default for PasswordManager {
448    fn default() -> Self {
449        Self::new()
450    }
451}
452
453/// Password strength levels
454#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
455pub enum PasswordStrength {
456    VeryWeak,
457    Weak,
458    Medium,
459    Strong,
460    VeryStrong,
461}
462
463#[cfg(test)]
464mod tests {
465    use super::*;
466
467    #[test]
468    fn test_password_hashing() {
469        let manager = PasswordManager::new();
470        let password = "my-secure-password-123";
471
472        let hash = manager.hash_password(password).unwrap();
473        assert!(!hash.is_empty());
474
475        // Verify correct password
476        let is_valid = manager.verify_password(password, &hash).unwrap();
477        assert!(is_valid);
478
479        // Verify incorrect password
480        let is_valid = manager.verify_password("wrong-password", &hash).unwrap();
481        assert!(!is_valid);
482    }
483
484    #[test]
485    fn test_password_strength() {
486        let manager = PasswordManager::new();
487
488        assert_eq!(
489            manager.check_password_strength("123"),
490            PasswordStrength::VeryWeak
491        );
492        assert_eq!(
493            manager.check_password_strength("password"),
494            PasswordStrength::VeryWeak
495        ); // Only lowercase, length 8
496        assert_eq!(
497            manager.check_password_strength("Password123"),
498            PasswordStrength::Weak // Length 11, uppercase, lowercase, digit = score 4
499        );
500        assert_eq!(
501            manager.check_password_strength("Password123!"),
502            PasswordStrength::Strong // Length 12, uppercase, lowercase, digit, special = score 6
503        );
504        assert_eq!(
505            manager.check_password_strength("MyVerySecureP@ssw0rd2024!"),
506            PasswordStrength::VeryStrong // Length 24, all checks = score 7+
507        );
508    }
509
510    #[test]
511    fn test_different_hashes() {
512        let manager = PasswordManager::new();
513        let password = "same-password";
514
515        let hash1 = manager.hash_password(password).unwrap();
516        let hash2 = manager.hash_password(password).unwrap();
517
518        // Hashes should be different due to random salt
519        assert_ne!(hash1, hash2);
520
521        // But both should verify correctly
522        assert!(manager.verify_password(password, &hash1).unwrap());
523        assert!(manager.verify_password(password, &hash2).unwrap());
524    }
525
526    #[test]
527    fn test_password_policy_default() {
528        let manager = PasswordManager::new();
529
530        // Too short
531        let result = manager.validate_password("Short1!", None);
532        assert!(!result.is_valid);
533        assert!(result
534            .violations
535            .iter()
536            .any(|v| matches!(v, PolicyViolation::TooShort { .. })));
537
538        // Good password
539        let result = manager.validate_password("MySecureP@ss123", None);
540        assert!(result.is_valid);
541    }
542
543    #[test]
544    fn test_strict_policy() {
545        let manager = PasswordManager::with_policy(PasswordPolicy::strict());
546
547        // Missing requirements
548        let result = manager.validate_password("password", None);
549        assert!(!result.is_valid);
550
551        // Meets all requirements
552        let result = manager.validate_password("MySecureP@ssw0rd!", None);
553        assert!(result.is_valid);
554    }
555
556    #[test]
557    fn test_common_password_detection() {
558        let manager = PasswordManager::new();
559
560        let result = manager.validate_password("password123", None);
561        // Common passwords are blocked
562        assert!(result
563            .violations
564            .iter()
565            .any(|v| matches!(v, PolicyViolation::CommonPassword)));
566    }
567
568    #[test]
569    fn test_username_in_password() {
570        let manager = PasswordManager::new();
571
572        let result = manager.validate_password("MyUsernameIsHere123!", Some("username"));
573        assert!(result
574            .violations
575            .iter()
576            .any(|v| matches!(v, PolicyViolation::ContainsUsername)));
577    }
578
579    #[test]
580    fn test_password_generation() {
581        let manager = PasswordManager::with_policy(PasswordPolicy::strict());
582
583        let password = manager.generate_password();
584
585        // Should meet policy requirements
586        let result = manager.validate_password(&password, None);
587        assert!(
588            result.is_valid,
589            "Generated password should meet policy: {:?}",
590            result.violations
591        );
592    }
593
594    #[test]
595    fn test_policy_presets() {
596        let strict = PasswordPolicy::strict();
597        assert_eq!(strict.min_length, 12);
598        assert!(strict.require_uppercase);
599
600        let relaxed = PasswordPolicy::relaxed();
601        assert_eq!(relaxed.min_length, 6);
602        assert!(!relaxed.require_uppercase);
603
604        let nist = PasswordPolicy::nist_compliant();
605        assert_eq!(nist.min_length, 8);
606        assert!(nist.disallow_common);
607    }
608
609    #[test]
610    fn test_strength_comparison() {
611        assert!(PasswordStrength::Strong > PasswordStrength::Medium);
612        assert!(PasswordStrength::VeryStrong > PasswordStrength::Strong);
613        assert!(PasswordStrength::Weak < PasswordStrength::Medium);
614    }
615}