Skip to main content

arcly_http_identity/
lockout.rs

1//! Brute-force / credential-stuffing lockout, backed by a pluggable counter.
2//!
3//! Reuses the framework's [`RateLimitBackend`]
4//! so the same Redis sliding-window that fronts `/auth/login` also drives
5//! per-account lockout — one backend, cluster-wide, with an explicit
6//! fail-open/closed posture.
7
8use std::sync::Arc;
9
10use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};
11
12use crate::error::{IdentityError, Result};
13
14/// Per-principal lockout policy. A "principal" is typically the user id (so a
15/// stuffing attack against one account can't lock out others) or, before the
16/// user is known, the client IP.
17pub struct LockoutPolicy {
18    backend: Arc<dyn RateLimitBackend>,
19    max_attempts: u32,
20    window_secs: u32,
21    fail_closed: bool,
22}
23
24impl LockoutPolicy {
25    /// `max_attempts` failed logins allowed per `window_secs` sliding window.
26    pub fn new(backend: Arc<dyn RateLimitBackend>, max_attempts: u32, window_secs: u32) -> Self {
27        Self {
28            backend,
29            max_attempts,
30            window_secs,
31            fail_closed: true,
32        }
33    }
34
35    /// If the backend is unavailable, *allow* the attempt instead of blocking
36    /// (default is fail-closed — deny). Use only when availability outranks the
37    /// brute-force risk for this deployment.
38    pub fn fail_open(mut self) -> Self {
39        self.fail_closed = false;
40        self
41    }
42
43    /// Count this login attempt against the principal's sliding window and
44    /// reject once the threshold is crossed.
45    ///
46    /// The backend exposes only an incrementing `hit` (no peek), so — exactly
47    /// like the framework's `DistributedRateLimit` fronting `/auth/login` — this
48    /// counts **every attempt**, not only failures. Call it once at the top of
49    /// login, keyed by a principal that identifies the target *before* the user
50    /// is known (e.g. `tenant + email`, or the client IP), so unknown-user
51    /// probing is throttled too and the response can't enumerate accounts.
52    ///
53    /// Returns [`IdentityError::AccountLocked`] when the window is exhausted, or
54    /// [`IdentityError::Unavailable`] when the backend is down and the policy is
55    /// fail-closed (the default).
56    pub async fn check(&self, principal: &str) -> Result<()> {
57        let key = format!("lockout::{principal}");
58        match self
59            .backend
60            .hit(&key, self.max_attempts, self.window_secs)
61            .await
62        {
63            RateDecision::Allow { .. } => Ok(()),
64            RateDecision::Deny { .. } => Err(IdentityError::AccountLocked),
65            RateDecision::Unavailable => {
66                if self.fail_closed {
67                    Err(IdentityError::Unavailable)
68                } else {
69                    Ok(())
70                }
71            }
72        }
73    }
74}