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}