Skip to main content

umbral_auth/
password_validation.rs

1//! Password-strength validation.
2//!
3//! A small set of validators that every password is checked against before
4//! it's hashed: minimum length, a common-password denylist, an all-numeric
5//! reject, and a similarity-to-user-attributes guard. umbral provides this
6//! surface here, with one deliberate property: **it's on by default with no
7//! opt-in.** A fresh `AuthPlugin::default()` enforces the full set, so
8//! `create_user("a", ...)` is rejected out of the box. An app that genuinely
9//! wants no policy must say so explicitly via
10//! [`crate::AuthPlugin::disable_password_validation`].
11//!
12//! ## The contract
13//!
14//! A [`PasswordValidator`] is `fn validate(&self, password, ctx) -> Result<(), String>`,
15//! where the `String` is a human-readable reason the password was rejected
16//! (the message is returned directly). [`PasswordContext`] carries the username / email so the
17//! similarity validator can compare against them — both are `None` when
18//! there's no user context (e.g. a standalone strength check).
19//!
20//! A [`PasswordPolicy`] is an ordered `Vec<Box<dyn PasswordValidator>>`.
21//! [`validate_password`] runs the ambiently-installed policy (falling back
22//! to [`PasswordPolicy::default`] when none is installed, so the free-function
23//! helpers stay secure even before `on_ready` runs) and **collects every
24//! failure**, not just the first — a caller showing a form gets the full list.
25//!
26//! ## Ambient install
27//!
28//! The active policy lives in a process-wide `OnceLock`, installed once in
29//! [`crate::AuthPlugin::on_ready`]. This mirrors the sessions plugin's
30//! `SLIDING_EXPIRY_ENABLED` flag: free functions (`create_user`,
31//! `set_password`) have no handle to the `AuthPlugin`, so they read the
32//! policy ambiently.
33
34use std::sync::OnceLock;
35
36/// The user context a validator can compare a candidate password against.
37///
38/// Both fields are `Option` because not every call site has a user: a bare
39/// strength check (e.g. a password-meter endpoint) passes `PasswordContext::empty()`,
40/// while `create_user` / `set_password` fill in what they know. The
41/// similarity validator simply skips any field that's `None`.
42#[derive(Debug, Clone, Copy, Default)]
43pub struct PasswordContext<'a> {
44    /// The account's login handle, when known.
45    pub username: Option<&'a str>,
46    /// The account's email address, when known. The similarity check uses
47    /// the local-part (before the `@`) as well as the whole string.
48    pub email: Option<&'a str>,
49}
50
51impl<'a> PasswordContext<'a> {
52    /// A context with no user attributes — the similarity validator is a
53    /// no-op against it. Use for standalone strength checks.
54    pub fn empty() -> Self {
55        Self::default()
56    }
57
58    /// A context carrying just a username.
59    pub fn for_username(username: &'a str) -> Self {
60        Self {
61            username: Some(username),
62            email: None,
63        }
64    }
65
66    /// A context carrying a username and an email.
67    pub fn new(username: Option<&'a str>, email: Option<&'a str>) -> Self {
68        Self { username, email }
69    }
70}
71
72/// One password-strength rule. Implementors return `Ok(())` when the
73/// password passes and `Err(reason)` with a human-readable message when it
74/// fails. Stateless rules (most of them) are zero-cost; rules that carry
75/// config (e.g. [`MinLengthValidator`]'s threshold) own their data.
76pub trait PasswordValidator: Send + Sync + std::fmt::Debug {
77    /// Check `password` against this rule. `ctx` carries the user
78    /// attributes a rule may compare against (only the similarity rule
79    /// uses it today). Return `Err(reason)` to reject.
80    fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String>;
81}
82
83// =========================================================================
84// Default validators
85// =========================================================================
86
87/// Reject passwords shorter than `min` characters. The default is 8.
88///
89/// Length is counted in Unicode scalar values (`chars().count()`), not
90/// bytes, so a password of 8 emoji counts as 8.
91#[derive(Debug, Clone, Copy)]
92pub struct MinLengthValidator(pub usize);
93
94impl Default for MinLengthValidator {
95    fn default() -> Self {
96        Self(8)
97    }
98}
99
100impl PasswordValidator for MinLengthValidator {
101    fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
102        let len = password.chars().count();
103        if len < self.0 {
104            Err(format!(
105                "This password is too short. It must contain at least {} characters.",
106                self.0
107            ))
108        } else {
109            Ok(())
110        }
111    }
112}
113
114/// The embedded common-password denylist. One password per line, lowercase.
115/// Curated from the well-known "most common passwords" lists. Case-insensitive
116/// matching happens at validation time.
117const COMMON_PASSWORDS: &str = include_str!("common_passwords.txt");
118
119/// Reject passwords that appear in an embedded denylist of common
120/// passwords. Matching is case-insensitive (`PASSWORD` and `password` are
121/// both rejected). We embed a curated few hundred entries (the ones that
122/// matter most) to keep the binary small while still catching the obvious
123/// choices.
124#[derive(Debug, Clone, Copy, Default)]
125pub struct CommonPasswordValidator;
126
127impl PasswordValidator for CommonPasswordValidator {
128    fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
129        let lower = password.trim().to_lowercase();
130        // The denylist is line-delimited and stored lowercase; a linear
131        // scan over a few hundred entries is well under a microsecond and
132        // runs once per registration, so a HashSet isn't worth the
133        // allocation.
134        let hit = COMMON_PASSWORDS
135            .lines()
136            .map(str::trim)
137            .filter(|l| !l.is_empty() && !l.starts_with('#'))
138            .any(|entry| entry == lower);
139        if hit {
140            Err("This password is too common.".to_string())
141        } else {
142            Ok(())
143        }
144    }
145}
146
147/// Reject passwords made up entirely of digits (`"12345678"`). An
148/// all-numeric password — even a long one — has far less entropy than a
149/// mixed one and is trivially brute-forced. Empty strings are not numeric
150/// (the min-length rule catches those).
151#[derive(Debug, Clone, Copy, Default)]
152pub struct NumericPasswordValidator;
153
154impl PasswordValidator for NumericPasswordValidator {
155    fn validate(&self, password: &str, _ctx: &PasswordContext<'_>) -> Result<(), String> {
156        if !password.is_empty() && password.chars().all(|c| c.is_ascii_digit()) {
157            Err("This password is entirely numeric.".to_string())
158        } else {
159            Ok(())
160        }
161    }
162}
163
164/// Reject passwords that are too similar to the username or the email
165/// local-part. Uses a similarity ratio with a 0.7 threshold, approximated
166/// with a cheaper two-pronged check that catches the same real-world cases:
167///
168/// 1. **Substring containment** — the password contains the attribute (or
169///    vice-versa), case-insensitive. Catches `alice` → `alice123`.
170/// 2. **Character-overlap ratio** — the fraction of the attribute's
171///    characters present in the password is `>= threshold`. Catches
172///    rearrangements / interleavings that substring containment misses.
173///
174/// Attributes shorter than 3 characters are skipped (a 1-char username
175/// would match almost everything). The email is checked both whole and as
176/// its local-part (before `@`).
177#[derive(Debug, Clone, Copy)]
178pub struct UserAttributeSimilarityValidator {
179    /// Overlap ratio at or above which the password is rejected. The
180    /// default is 0.7.
181    pub threshold: f64,
182}
183
184impl Default for UserAttributeSimilarityValidator {
185    fn default() -> Self {
186        Self { threshold: 0.7 }
187    }
188}
189
190impl UserAttributeSimilarityValidator {
191    /// True when `password` is too similar to `attribute` under this
192    /// validator's rules. Both are lowercased by the caller.
193    fn too_similar(&self, password: &str, attribute: &str) -> bool {
194        if attribute.chars().count() < 3 {
195            return false;
196        }
197        if password.contains(attribute) || attribute.contains(password) {
198            return true;
199        }
200        // Fraction of the attribute's distinct characters that also appear
201        // in the password. A cheap stand-in for SequenceMatcher that still
202        // flags heavy reuse of the attribute's letters.
203        let pw_chars: std::collections::HashSet<char> = password.chars().collect();
204        let attr_chars: std::collections::HashSet<char> = attribute.chars().collect();
205        if attr_chars.is_empty() {
206            return false;
207        }
208        let shared = attr_chars.iter().filter(|c| pw_chars.contains(c)).count();
209        let ratio = shared as f64 / attr_chars.len() as f64;
210        ratio >= self.threshold
211    }
212}
213
214impl PasswordValidator for UserAttributeSimilarityValidator {
215    fn validate(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), String> {
216        let pw = password.to_lowercase();
217
218        let mut attributes: Vec<String> = Vec::new();
219        if let Some(username) = ctx.username {
220            attributes.push(username.to_lowercase());
221        }
222        if let Some(email) = ctx.email {
223            let email = email.to_lowercase();
224            if let Some((local, _domain)) = email.split_once('@') {
225                attributes.push(local.to_string());
226            }
227            attributes.push(email);
228        }
229
230        for attribute in attributes {
231            if self.too_similar(&pw, &attribute) {
232                return Err("This password is too similar to your username or email.".to_string());
233            }
234        }
235        Ok(())
236    }
237}
238
239// =========================================================================
240// PasswordPolicy
241// =========================================================================
242
243/// An ordered set of [`PasswordValidator`]s applied to every password.
244///
245/// [`PasswordPolicy::default`] is the default set: min-length 8,
246/// common-password denylist, all-numeric reject, and user-attribute
247/// similarity. Construct a custom policy with [`PasswordPolicy::new`] +
248/// [`PasswordPolicy::with`], or start from the defaults and tweak.
249#[derive(Debug)]
250pub struct PasswordPolicy {
251    validators: Vec<Box<dyn PasswordValidator>>,
252}
253
254impl PasswordPolicy {
255    /// An empty policy — no validators, every password passes. Used as the
256    /// base for a hand-built policy and as the marker for
257    /// [`crate::AuthPlugin::disable_password_validation`].
258    pub fn empty() -> Self {
259        Self {
260            validators: Vec::new(),
261        }
262    }
263
264    /// Alias for [`PasswordPolicy::empty`], read as "no validation".
265    pub fn none() -> Self {
266        Self::empty()
267    }
268
269    /// Start a custom policy from a vector of validators.
270    pub fn new(validators: Vec<Box<dyn PasswordValidator>>) -> Self {
271        Self { validators }
272    }
273
274    /// Append a validator, returning `self` for chaining.
275    pub fn with(mut self, validator: Box<dyn PasswordValidator>) -> Self {
276        self.validators.push(validator);
277        self
278    }
279
280    /// The number of validators in the policy. `0` means no enforcement.
281    pub fn len(&self) -> usize {
282        self.validators.len()
283    }
284
285    /// Whether the policy is empty (no enforcement).
286    pub fn is_empty(&self) -> bool {
287        self.validators.is_empty()
288    }
289
290    /// Run every validator against `password` and collect **all** failure
291    /// reasons. Returns `Ok(())` only when every validator passes.
292    pub fn check(&self, password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
293        let mut reasons = Vec::new();
294        for validator in &self.validators {
295            if let Err(reason) = validator.validate(password, ctx) {
296                reasons.push(reason);
297            }
298        }
299        if reasons.is_empty() {
300            Ok(())
301        } else {
302            Err(reasons)
303        }
304    }
305}
306
307/// The default policy IS the secure-by-default set: a sensible group of
308/// password validators that are enabled out of the box.
309impl PasswordPolicy {
310    /// The four built-in validators with their default settings. This is
311    /// what an unconfigured `AuthPlugin` enforces.
312    pub fn recommended_defaults() -> Self {
313        Self::new(vec![
314            Box::new(MinLengthValidator::default()),
315            Box::new(CommonPasswordValidator),
316            Box::new(NumericPasswordValidator),
317            Box::new(UserAttributeSimilarityValidator::default()),
318        ])
319    }
320}
321
322// `Default` and `recommended_defaults` are the same thing; keep both so callers
323// can read whichever is clearer at the call site.
324impl PasswordPolicy {
325    /// Construct the default secure policy. Named separately from the
326    /// `Default` trait impl so it reads clearly in the `OnceLock` fallback.
327    fn default_secure() -> Self {
328        Self::recommended_defaults()
329    }
330}
331
332impl std::default::Default for PasswordPolicy {
333    fn default() -> Self {
334        // SECURE BY DEFAULT: a default policy is the full validator set, NOT
335        // an empty one. An app that wants no validation must ask explicitly.
336        Self::recommended_defaults()
337    }
338}
339
340// =========================================================================
341// Ambient install + free-function entry point
342// =========================================================================
343
344/// The process-wide active policy. Installed once in
345/// [`crate::AuthPlugin::on_ready`]. Mirrors the sessions plugin's
346/// `SLIDING_EXPIRY_ENABLED` ambient flag.
347static PASSWORD_POLICY: OnceLock<PasswordPolicy> = OnceLock::new();
348
349/// Install the active policy. Called once at boot from `on_ready`.
350/// Idempotent — the first install wins (same "first wins" contract as the
351/// ambient pool / sliding-expiry flag), so a second plugin or a test that
352/// boots twice in one process can't clobber it.
353pub(crate) fn install_policy(policy: PasswordPolicy) {
354    let _ = PASSWORD_POLICY.set(policy);
355}
356
357/// Validate `password` against the active policy, collecting every failure.
358///
359/// Falls back to [`PasswordPolicy::default`] (the full secure set) when no
360/// policy has been installed yet — so the free-function helpers
361/// (`create_user`, `set_password`) are enforced even before `on_ready`
362/// runs, and in tests that exercise the helpers without a full boot.
363///
364/// Returns `Err(reasons)` listing all the rules the password failed.
365pub fn validate_password(password: &str, ctx: &PasswordContext<'_>) -> Result<(), Vec<String>> {
366    match PASSWORD_POLICY.get() {
367        Some(policy) => policy.check(password, ctx),
368        None => {
369            // Build the default lazily rather than installing it: an
370            // explicit `on_ready` install must still win, so we don't seed
371            // the OnceLock here.
372            PasswordPolicy::default_secure().check(password, ctx)
373        }
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380
381    #[test]
382    fn min_length_rejects_short() {
383        let v = MinLengthValidator::default();
384        assert!(v.validate("abc", &PasswordContext::empty()).is_err());
385        assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_ok());
386    }
387
388    #[test]
389    fn min_length_honours_custom_threshold() {
390        let v = MinLengthValidator(12);
391        assert!(v.validate("abcdefgh", &PasswordContext::empty()).is_err());
392        assert!(
393            v.validate("abcdefghijkl", &PasswordContext::empty())
394                .is_ok()
395        );
396    }
397
398    #[test]
399    fn common_rejects_password_case_insensitive() {
400        let v = CommonPasswordValidator;
401        assert!(v.validate("password", &PasswordContext::empty()).is_err());
402        assert!(v.validate("PASSWORD", &PasswordContext::empty()).is_err());
403        assert!(v.validate("qwerty", &PasswordContext::empty()).is_err());
404        assert!(v.validate("letmein", &PasswordContext::empty()).is_err());
405        assert!(
406            v.validate("Tr0ub4dour&3xpl", &PasswordContext::empty())
407                .is_ok()
408        );
409    }
410
411    #[test]
412    fn numeric_rejects_all_digits() {
413        let v = NumericPasswordValidator;
414        assert!(v.validate("12345678", &PasswordContext::empty()).is_err());
415        assert!(v.validate("0000000000", &PasswordContext::empty()).is_err());
416        assert!(v.validate("abc12345", &PasswordContext::empty()).is_ok());
417        // Empty is handled by min-length, not the numeric rule.
418        assert!(v.validate("", &PasswordContext::empty()).is_ok());
419    }
420
421    #[test]
422    fn similarity_rejects_username_in_password() {
423        let v = UserAttributeSimilarityValidator::default();
424        let ctx = PasswordContext::for_username("alice");
425        assert!(v.validate("alice123", &ctx).is_err());
426        assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
427    }
428
429    #[test]
430    fn similarity_uses_email_local_part() {
431        let v = UserAttributeSimilarityValidator::default();
432        let ctx = PasswordContext::new(None, Some("bob.smith@example.com"));
433        assert!(v.validate("bob.smith99", &ctx).is_err());
434    }
435
436    #[test]
437    fn similarity_skips_short_attributes() {
438        let v = UserAttributeSimilarityValidator::default();
439        let ctx = PasswordContext::for_username("ab");
440        // A 2-char username must not flag an unrelated strong password.
441        assert!(v.validate("Tr0ub4dour&3xpl", &ctx).is_ok());
442    }
443
444    #[test]
445    fn policy_aggregates_multiple_failures() {
446        let policy = PasswordPolicy::default();
447        // "alice" → too short, similar to username, and (lowercased) a
448        // common-ish weak choice. Expect at least two distinct reasons.
449        let ctx = PasswordContext::for_username("alice");
450        let err = policy
451            .check("alice", &ctx)
452            .expect_err("weak password must fail");
453        assert!(
454            err.len() >= 2,
455            "expected multiple failure reasons, got {err:?}"
456        );
457    }
458
459    #[test]
460    fn strong_password_passes_all() {
461        let policy = PasswordPolicy::default();
462        let ctx = PasswordContext::new(Some("alice"), Some("alice@example.com"));
463        assert!(
464            policy.check("Tr0ub4dour&3xpl", &ctx).is_ok(),
465            "a strong password must pass the default policy"
466        );
467    }
468
469    #[test]
470    fn default_policy_is_not_empty() {
471        // The load-bearing secure-by-default guarantee.
472        assert!(!PasswordPolicy::default().is_empty());
473        assert_eq!(PasswordPolicy::default().len(), 4);
474    }
475
476    #[test]
477    fn empty_policy_passes_everything() {
478        let policy = PasswordPolicy::empty();
479        assert!(policy.check("a", &PasswordContext::empty()).is_ok());
480    }
481
482    #[test]
483    fn validate_password_falls_back_to_secure_default() {
484        // Even with no install, a weak password is rejected.
485        assert!(validate_password("a", &PasswordContext::empty()).is_err());
486        assert!(validate_password("Tr0ub4dour&3xpl", &PasswordContext::empty()).is_ok());
487    }
488}