Skip to main content

karbon_framework/validation/constraints/security/
password.rs

1use crate::validation::constraints::{Constraint, ConstraintResult, ConstraintViolation};
2
3/// Password strength level
4#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
5pub enum PasswordStrength {
6    /// At least 6 chars
7    Weak,
8    /// At least 8 chars, mixed case + digits
9    Medium,
10    /// At least 10 chars, mixed case + digits + special
11    Strong,
12    /// At least 12 chars, mixed case + digits + special, no common patterns
13    VeryStrong,
14}
15
16/// Validates that a password meets security requirements.
17///
18/// Equivalent to Symfony's `PasswordStrength` constraint.
19pub struct Password {
20    pub min_strength: PasswordStrength,
21    pub message: String,
22    pub min_length: usize,
23}
24
25impl Default for Password {
26    fn default() -> Self {
27        Self {
28            min_strength: PasswordStrength::Medium,
29            message: "The password strength is too low. Please use a stronger password.".to_string(),
30            min_length: 8,
31        }
32    }
33}
34
35impl Password {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    pub fn strength(mut self, strength: PasswordStrength) -> Self {
41        self.min_strength = strength;
42        self.min_length = match strength {
43            PasswordStrength::Weak => 6,
44            PasswordStrength::Medium => 8,
45            PasswordStrength::Strong => 10,
46            PasswordStrength::VeryStrong => 12,
47        };
48        self
49    }
50
51    pub fn with_message(mut self, message: impl Into<String>) -> Self {
52        self.message = message.into();
53        self
54    }
55
56    fn evaluate_strength(value: &str) -> PasswordStrength {
57        let len = value.len();
58        let has_lower = value.chars().any(|c| c.is_ascii_lowercase());
59        let has_upper = value.chars().any(|c| c.is_ascii_uppercase());
60        let has_digit = value.chars().any(|c| c.is_ascii_digit());
61        let has_special = value.chars().any(|c| !c.is_alphanumeric());
62
63        let common_patterns = [
64            "password", "123456", "qwerty", "abc123", "letmein", "admin",
65            "welcome", "monkey", "dragon", "master", "azerty",
66        ];
67        let lower = value.to_lowercase();
68        let has_common = common_patterns.iter().any(|p| lower.contains(p));
69
70        if len >= 12 && has_lower && has_upper && has_digit && has_special && !has_common {
71            PasswordStrength::VeryStrong
72        } else if len >= 10 && has_lower && has_upper && has_digit && has_special {
73            PasswordStrength::Strong
74        } else if len >= 8 && has_lower && has_upper && has_digit {
75            PasswordStrength::Medium
76        } else {
77            PasswordStrength::Weak
78        }
79    }
80}
81
82impl Constraint for Password {
83    fn validate(&self, value: &str) -> ConstraintResult {
84        if value.len() < self.min_length {
85            return Err(ConstraintViolation::new(
86                self.name(),
87                format!(
88                    "The password must be at least {} characters long.",
89                    self.min_length
90                ),
91                "[REDACTED]",
92            ));
93        }
94
95        let actual_strength = Self::evaluate_strength(value);
96        if actual_strength < self.min_strength {
97            return Err(ConstraintViolation::new(
98                self.name(),
99                &self.message,
100                "[REDACTED]",
101            ));
102        }
103
104        Ok(())
105    }
106
107    fn name(&self) -> &'static str {
108        "Password"
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115
116    #[test]
117    fn test_weak_password() {
118        let constraint = Password::new().strength(PasswordStrength::Weak);
119        assert!(constraint.validate("abcdef").is_ok());
120        assert!(constraint.validate("12345").is_err()); // too short
121    }
122
123    #[test]
124    fn test_medium_password() {
125        let constraint = Password::new().strength(PasswordStrength::Medium);
126        assert!(constraint.validate("Abcdef1x").is_ok());
127        assert!(constraint.validate("abcdefgh").is_err()); // no upper/digit
128    }
129
130    #[test]
131    fn test_strong_password() {
132        let constraint = Password::new().strength(PasswordStrength::Strong);
133        assert!(constraint.validate("Abcdef1x!z").is_ok());
134        assert!(constraint.validate("Abcdef1x").is_err()); // no special, too short
135    }
136
137    #[test]
138    fn test_very_strong_password() {
139        let constraint = Password::new().strength(PasswordStrength::VeryStrong);
140        assert!(constraint.validate("MyStr0ng!Pass").is_ok());
141        assert!(constraint.validate("password123!A").is_err()); // common pattern
142    }
143
144    #[test]
145    fn test_too_short() {
146        let constraint = Password::new().strength(PasswordStrength::Medium);
147        let err = constraint.validate("Ab1!").unwrap_err();
148        assert!(err.message.contains("at least"));
149    }
150
151    #[test]
152    fn test_password_not_leaked_in_error() {
153        let constraint = Password::new();
154        let err = constraint.validate("weak").unwrap_err();
155        assert_eq!(err.invalid_value, "[REDACTED]");
156    }
157}