Skip to main content

auth_framework/utils/
validation.rs

1//! Validation utilities for the AuthFramework.
2//!
3//! This module provides comprehensive input validation functions for
4//! authentication-related data including passwords, usernames, emails, and more.
5
6use crate::errors::{AuthError, Result};
7use regex::Regex;
8use std::collections::HashSet;
9use std::sync::OnceLock;
10
11// Compiled once at first use; regex compilation is non-trivial and running it
12// on every request wastes CPU and could be exploited for minor Denial-of-Service.
13static USERNAME_RE: OnceLock<Regex> = OnceLock::new();
14static EMAIL_RE: OnceLock<Regex> = OnceLock::new();
15static API_KEY_RE: OnceLock<Regex> = OnceLock::new();
16
17/// Enhanced password validation configuration
18#[derive(Debug, Clone)]
19pub struct PasswordPolicy {
20    /// Minimum password length
21    pub min_length: usize,
22    /// Maximum password length  
23    pub max_length: usize,
24    /// Require at least one uppercase letter
25    pub require_uppercase: bool,
26    /// Require at least one lowercase letter
27    pub require_lowercase: bool,
28    /// Require at least one digit
29    pub require_digit: bool,
30    /// Require at least one special character
31    pub require_special: bool,
32    /// List of banned common passwords
33    pub banned_passwords: HashSet<String>,
34    /// Minimum entropy requirement
35    pub min_entropy: f64,
36}
37
38impl Default for PasswordPolicy {
39    fn default() -> Self {
40        let mut banned_passwords = HashSet::new();
41        // Add common weak passwords
42        for password in [
43            "password",
44            "123456",
45            "password123",
46            "admin",
47            "qwerty",
48            "letmein",
49            "welcome",
50            "monkey",
51            "dragon",
52            "password1",
53            "123456789",
54            "1234567890",
55            "abc123",
56            "iloveyou",
57        ] {
58            banned_passwords.insert(password.to_string());
59        }
60
61        Self {
62            min_length: 8,
63            max_length: 128,
64            require_uppercase: true,
65            require_lowercase: true,
66            require_digit: true,
67            require_special: true,
68            banned_passwords,
69            min_entropy: 3.0,
70        }
71    }
72}
73
74impl PasswordPolicy {
75    /// NIST SP 800-63B compliant policy.
76    ///
77    /// Follows modern NIST guidance: no arbitrary composition rules,
78    /// focus on length and entropy. Banned password list still applies.
79    ///
80    /// ```rust
81    /// use auth_framework::utils::validation::PasswordPolicy;
82    /// let policy = PasswordPolicy::nist_800_63b();
83    /// assert_eq!(policy.min_length, 8);
84    /// assert!(!policy.require_special);
85    /// ```
86    pub fn nist_800_63b() -> Self {
87        Self {
88            require_uppercase: false,
89            require_lowercase: false,
90            require_digit: false,
91            require_special: false,
92            ..Default::default()
93        }
94    }
95
96    /// High-security policy with strict composition requirements.
97    ///
98    /// Suitable for admin accounts and sensitive systems.
99    ///
100    /// ```rust
101    /// use auth_framework::utils::validation::PasswordPolicy;
102    /// let policy = PasswordPolicy::high_security();
103    /// assert_eq!(policy.min_length, 12);
104    /// assert!(policy.require_special);
105    /// ```
106    pub fn high_security() -> Self {
107        Self {
108            min_length: 12,
109            min_entropy: 4.0,
110            ..Default::default()
111        }
112    }
113
114    /// Add custom banned passwords on top of the existing list.
115    ///
116    /// Words are lowercased before insertion.
117    pub fn with_banned_words(mut self, words: &[&str]) -> Self {
118        for word in words {
119            self.banned_passwords.insert(word.to_lowercase());
120        }
121        self
122    }
123
124    /// Create a builder starting from the default policy.
125    pub fn builder() -> PasswordPolicyBuilder {
126        PasswordPolicyBuilder {
127            policy: PasswordPolicy::default(),
128        }
129    }
130}
131
132/// Fluent builder for [`PasswordPolicy`].
133///
134/// # Example
135///
136/// ```rust
137/// use auth_framework::utils::validation::PasswordPolicy;
138///
139/// let policy = PasswordPolicy::builder()
140///     .min_length(10)
141///     .require_special(false)
142///     .min_entropy(3.5)
143///     .build();
144///
145/// assert_eq!(policy.min_length, 10);
146/// assert!(!policy.require_special);
147/// ```
148#[derive(Debug, Clone)]
149pub struct PasswordPolicyBuilder {
150    policy: PasswordPolicy,
151}
152
153impl PasswordPolicyBuilder {
154    /// Set minimum password length.
155    pub fn min_length(mut self, len: usize) -> Self {
156        self.policy.min_length = len;
157        self
158    }
159
160    /// Set maximum password length.
161    pub fn max_length(mut self, len: usize) -> Self {
162        self.policy.max_length = len;
163        self
164    }
165
166    /// Whether to require at least one uppercase letter.
167    pub fn require_uppercase(mut self, require: bool) -> Self {
168        self.policy.require_uppercase = require;
169        self
170    }
171
172    /// Whether to require at least one lowercase letter.
173    pub fn require_lowercase(mut self, require: bool) -> Self {
174        self.policy.require_lowercase = require;
175        self
176    }
177
178    /// Whether to require at least one digit.
179    pub fn require_digit(mut self, require: bool) -> Self {
180        self.policy.require_digit = require;
181        self
182    }
183
184    /// Whether to require at least one special character.
185    pub fn require_special(mut self, require: bool) -> Self {
186        self.policy.require_special = require;
187        self
188    }
189
190    /// Set the minimum entropy threshold.
191    pub fn min_entropy(mut self, entropy: f64) -> Self {
192        self.policy.min_entropy = entropy;
193        self
194    }
195
196    /// Consume the builder and produce the policy.
197    pub fn build(self) -> PasswordPolicy {
198        self.policy
199    }
200}
201
202/// Enhanced password validation with configurable policy
203pub fn validate_password_enhanced(password: &str, policy: &PasswordPolicy) -> Result<()> {
204    // Check length requirements
205    if password.len() < policy.min_length {
206        return Err(AuthError::validation(format!(
207            "Password must be at least {} characters long",
208            policy.min_length
209        )));
210    }
211
212    if password.len() > policy.max_length {
213        return Err(AuthError::validation(format!(
214            "Password must be no more than {} characters long",
215            policy.max_length
216        )));
217    }
218
219    // Check character requirements
220    if policy.require_uppercase && !password.chars().any(|c| c.is_uppercase()) {
221        return Err(AuthError::validation(
222            "Password must contain at least one uppercase letter".to_string(),
223        ));
224    }
225
226    if policy.require_lowercase && !password.chars().any(|c| c.is_lowercase()) {
227        return Err(AuthError::validation(
228            "Password must contain at least one lowercase letter".to_string(),
229        ));
230    }
231
232    if policy.require_digit && !password.chars().any(|c| c.is_numeric()) {
233        return Err(AuthError::validation(
234            "Password must contain at least one digit".to_string(),
235        ));
236    }
237
238    if policy.require_special && !password.chars().any(|c| !c.is_alphanumeric()) {
239        return Err(AuthError::validation(
240            "Password must contain at least one special character".to_string(),
241        ));
242    }
243
244    // Check against banned passwords
245    if policy.banned_passwords.contains(&password.to_lowercase()) {
246        return Err(AuthError::validation(
247            "Password is too common and not allowed".to_string(),
248        ));
249    }
250
251    // Calculate entropy and check minimum requirement
252    let entropy = calculate_password_entropy(password);
253    if entropy < policy.min_entropy {
254        return Err(AuthError::validation(format!(
255            "Password entropy ({:.2}) is below minimum requirement ({:.2})",
256            entropy, policy.min_entropy
257        )));
258    }
259
260    // Detect sequential character patterns (e.g. "abc", "123", "cba", "321")
261    // and keyboard walks (e.g. "qwerty", "asdf") that Shannon entropy misses
262    // because each character is unique.
263    if has_sequential_patterns(password) {
264        return Err(AuthError::validation(
265            "Password contains sequential or keyboard-pattern characters that are easily guessed"
266                .to_string(),
267        ));
268    }
269
270    Ok(())
271}
272
273/// Simple password validation with default policy
274pub fn validate_password(password: &str) -> Result<()> {
275    validate_password_enhanced(password, &PasswordPolicy::default())
276}
277
278/// Calculate password entropy using Shannon entropy formula
279fn calculate_password_entropy(password: &str) -> f64 {
280    let mut char_counts = std::collections::HashMap::new();
281
282    for c in password.chars() {
283        *char_counts.entry(c).or_insert(0) += 1;
284    }
285
286    let length = password.len() as f64;
287    let mut entropy = 0.0;
288
289    for &count in char_counts.values() {
290        let probability = count as f64 / length;
291        entropy -= probability * probability.log2();
292    }
293
294    entropy
295}
296
297/// Detect passwords dominated by sequential runs, keyboard walks, or repeated characters.
298///
299/// Returns `true` when more than half of the password's characters participate
300/// in a sequential run of 3+ or match a common keyboard walk pattern.
301fn has_sequential_patterns(password: &str) -> bool {
302    if password.len() < 6 {
303        return false; // too short for pattern detection to be meaningful
304    }
305
306    // Common keyboard walk sequences (lowercase)
307    const KEYBOARD_ROWS: &[&str] = &["qwertyuiop", "asdfghjkl", "zxcvbnm", "1234567890"];
308
309    let lower = password.to_lowercase();
310
311    // Count characters that are part of a 3+ ascending/descending codepoint run
312    let chars: Vec<char> = lower.chars().collect();
313    let mut sequential_count: usize = 0;
314    let mut run = 1usize;
315    for i in 1..chars.len() {
316        let diff = chars[i] as i32 - chars[i - 1] as i32;
317        if diff == 1 || diff == -1 {
318            run += 1;
319        } else {
320            if run >= 3 {
321                sequential_count += run;
322            }
323            run = 1;
324        }
325    }
326    if run >= 3 {
327        sequential_count += run;
328    }
329
330    // Also count characters that belong to a keyboard-walk substring of length >= 4
331    let mut walk_count: usize = 0;
332    for row in KEYBOARD_ROWS {
333        let rev: String = row.chars().rev().collect();
334        for window_len in (4..=lower.len()).rev() {
335            for start in 0..=lower.len().saturating_sub(window_len) {
336                let slice = &lower[start..start + window_len];
337                if row.contains(slice) || rev.contains(slice) {
338                    walk_count = walk_count.max(window_len);
339                }
340            }
341        }
342    }
343
344    let dominated = sequential_count.max(walk_count);
345    // If more than half the password is sequential/walk characters, reject
346    dominated * 2 > password.len()
347}
348
349/// Validate username format
350pub fn validate_username(username: &str) -> Result<()> {
351    if username.is_empty() {
352        return Err(AuthError::validation(
353            "Username cannot be empty".to_string(),
354        ));
355    }
356
357    if username.len() < 3 {
358        return Err(AuthError::validation(
359            "Username must be at least 3 characters long".to_string(),
360        ));
361    }
362
363    if username.len() > 50 {
364        return Err(AuthError::validation(
365            "Username must be no more than 50 characters long".to_string(),
366        ));
367    }
368
369    // Username must start with a letter and may then contain letters, digits,
370    // underscores, and hyphens (3–50 chars total).
371    let username_regex =
372        USERNAME_RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9_-]+$").expect("valid username regex"));
373    if !username_regex.is_match(username) {
374        return Err(AuthError::validation(
375            "Username can only contain letters, numbers, underscores, and hyphens".to_string(),
376        ));
377    }
378
379    // Must start with a letter
380    if !username.chars().next().is_some_and(|c| c.is_alphabetic()) {
381        return Err(AuthError::validation(
382            "Username must start with a letter".to_string(),
383        ));
384    }
385
386    Ok(())
387}
388
389/// Validate email format
390pub fn validate_email(email: &str) -> Result<()> {
391    if email.is_empty() {
392        return Err(AuthError::validation("Email cannot be empty".to_string()));
393    }
394
395    // Check length before running the regex to avoid matching against overlong strings.
396    if email.len() > 254 {
397        return Err(AuthError::validation(
398            "Email address is too long".to_string(),
399        ));
400    }
401
402    // Basic email validation regex (compiled once for performance).
403    let email_regex = EMAIL_RE.get_or_init(|| {
404        Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").expect("valid email regex")
405    });
406    if !email_regex.is_match(email) {
407        return Err(AuthError::validation("Invalid email format".to_string()));
408    }
409
410    Ok(())
411}
412
413/// Validate API key format
414pub fn validate_api_key(api_key: &str) -> Result<()> {
415    if api_key.is_empty() {
416        return Err(AuthError::validation("API key cannot be empty".to_string()));
417    }
418
419    if api_key.len() < 32 {
420        return Err(AuthError::validation(
421            "API key must be at least 32 characters long".to_string(),
422        ));
423    }
424
425    if api_key.len() > 128 {
426        return Err(AuthError::validation(
427            "API key must be no more than 128 characters long".to_string(),
428        ));
429    }
430
431    // API key should be alphanumeric
432    let api_key_regex =
433        API_KEY_RE.get_or_init(|| Regex::new(r"^[a-zA-Z0-9]+$").expect("valid api key regex"));
434    if !api_key_regex.is_match(api_key) {
435        return Err(AuthError::validation(
436            "API key can only contain letters and numbers".to_string(),
437        ));
438    }
439
440    Ok(())
441}
442
443/// Validate user-supplied input against common injection patterns.
444///
445/// Returns `true` when the input is safe to process. Rejects HTML/XML angle brackets,
446/// URL-encoded angle brackets, null bytes, dangerous URI schemes (`javascript:`, `data:`,
447/// `file:`, `jndi:`), template injection markers, path traversal sequences, and trivial
448/// SQL injection patterns.
449pub fn validate_user_input(input: &str) -> bool {
450    if input.is_empty() || input.len() > 1000 {
451        return false;
452    }
453    if !input.chars().all(|c| {
454        if c.is_control() {
455            matches!(c, ' ' | '\t' | '\n' | '\r')
456        } else {
457            !matches!(c, '<' | '>')
458        }
459    }) {
460        return false;
461    }
462    let lower = input.to_ascii_lowercase();
463    if lower.contains("%3c") || lower.contains("%3e") || lower.contains("%00") {
464        return false;
465    }
466    if lower.contains("javascript:")
467        || lower.contains("data:")
468        || lower.contains("file:")
469        || lower.contains("jndi:")
470    {
471        return false;
472    }
473    if input.contains("${") || input.contains("{{") {
474        return false;
475    }
476    if input.contains("../") || input.contains("..\\") {
477        return false;
478    }
479    if input.contains('\0') {
480        return false;
481    }
482    if lower.contains("; drop")
483        || lower.contains(";drop")
484        || lower.contains("' drop")
485        || lower.contains("'; drop")
486        || lower.contains("--")
487    {
488        return false;
489    }
490    true
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_password_validation() {
499        let policy = PasswordPolicy::default();
500
501        // Valid password
502        assert!(validate_password_enhanced("StrongP@ssw0rd!", &policy).is_ok());
503
504        // Too short
505        assert!(validate_password_enhanced("Short1!", &policy).is_err());
506
507        // No uppercase
508        assert!(validate_password_enhanced("lowercase123!", &policy).is_err());
509
510        // No lowercase
511        assert!(validate_password_enhanced("UPPERCASE123!", &policy).is_err());
512
513        // No digit
514        assert!(validate_password_enhanced("NoDigitPass!", &policy).is_err());
515
516        // No special character
517        assert!(validate_password_enhanced("NoSpecialChar123", &policy).is_err());
518
519        // Banned password
520        assert!(validate_password_enhanced("password", &policy).is_err());
521    }
522
523    #[test]
524    fn test_username_validation() {
525        // Valid usernames
526        assert!(validate_username("validuser").is_ok());
527        assert!(validate_username("user_123").is_ok());
528        assert!(validate_username("test-user").is_ok());
529
530        // Invalid usernames
531        assert!(validate_username("").is_err()); // Empty
532        assert!(validate_username("ab").is_err()); // Too short
533        assert!(validate_username("123user").is_err()); // Starts with number
534        assert!(validate_username("user@test").is_err()); // Invalid character
535    }
536
537    #[test]
538    fn test_email_validation() {
539        // Valid emails
540        assert!(validate_email("test@example.com").is_ok());
541        assert!(validate_email("user.name+tag@domain.co.uk").is_ok());
542
543        // Invalid emails
544        assert!(validate_email("").is_err()); // Empty
545        assert!(validate_email("invalid.email").is_err()); // No @
546        assert!(validate_email("@domain.com").is_err()); // No local part
547        assert!(validate_email("test@").is_err()); // No domain
548    }
549
550    #[test]
551    fn test_password_policy_nist_preset() {
552        let policy = PasswordPolicy::nist_800_63b();
553        assert_eq!(policy.min_length, 8);
554        assert!(!policy.require_uppercase);
555        assert!(!policy.require_lowercase);
556        assert!(!policy.require_digit);
557        assert!(!policy.require_special);
558        // NIST doesn't require composition rules, so a long lowercase-only password should pass
559        assert!(validate_password_enhanced("alongpasswordthatisonly lowercase", &policy).is_ok());
560    }
561
562    #[test]
563    fn test_password_policy_high_security_preset() {
564        let policy = PasswordPolicy::high_security();
565        assert_eq!(policy.min_length, 12);
566        assert!(policy.require_uppercase);
567        assert!(policy.require_special);
568        assert!(policy.min_entropy > 3.0);
569    }
570
571    #[test]
572    fn test_password_policy_builder() {
573        let policy = PasswordPolicy::builder()
574            .min_length(10)
575            .require_special(false)
576            .min_entropy(3.5)
577            .build();
578        assert_eq!(policy.min_length, 10);
579        assert!(!policy.require_special);
580        assert_eq!(policy.min_entropy, 3.5);
581        // Other defaults should remain
582        assert!(policy.require_uppercase);
583        assert!(policy.require_digit);
584    }
585
586    #[test]
587    fn test_password_policy_with_banned_words() {
588        let policy = PasswordPolicy::default().with_banned_words(&["CompanyName", "SecretWord"]);
589        assert!(policy.banned_passwords.contains("companyname"));
590        assert!(policy.banned_passwords.contains("secretword"));
591        // Original banned passwords should still be present
592        assert!(policy.banned_passwords.contains("password"));
593    }
594}