arcly-http-identity 0.9.0

CIAM building-block engine for arcly-http: identity store traits, password hashing, MFA (TOTP), passwordless & account lifecycle, risk signals, and an OIDC provider — storage-agnostic, zero-lock, all I/O behind traits.
Documentation
//! Brute-force / credential-stuffing lockout, backed by a pluggable counter.
//!
//! Reuses the framework's [`RateLimitBackend`]
//! so the same Redis sliding-window that fronts `/auth/login` also drives
//! per-account lockout — one backend, cluster-wide, with an explicit
//! fail-open/closed posture.

use std::sync::Arc;

use arcly_http_core::resilience::distributed_rate_limit::{RateDecision, RateLimitBackend};

use crate::error::{IdentityError, Result};

/// Per-principal lockout policy. A "principal" is typically the user id (so a
/// stuffing attack against one account can't lock out others) or, before the
/// user is known, the client IP.
pub struct LockoutPolicy {
    backend: Arc<dyn RateLimitBackend>,
    max_attempts: u32,
    window_secs: u32,
    fail_closed: bool,
}

impl LockoutPolicy {
    /// `max_attempts` failed logins allowed per `window_secs` sliding window.
    pub fn new(backend: Arc<dyn RateLimitBackend>, max_attempts: u32, window_secs: u32) -> Self {
        Self {
            backend,
            max_attempts,
            window_secs,
            fail_closed: true,
        }
    }

    /// If the backend is unavailable, *allow* the attempt instead of blocking
    /// (default is fail-closed — deny). Use only when availability outranks the
    /// brute-force risk for this deployment.
    pub fn fail_open(mut self) -> Self {
        self.fail_closed = false;
        self
    }

    /// Count this login attempt against the principal's sliding window and
    /// reject once the threshold is crossed.
    ///
    /// The backend exposes only an incrementing `hit` (no peek), so — exactly
    /// like the framework's `DistributedRateLimit` fronting `/auth/login` — this
    /// counts **every attempt**, not only failures. Call it once at the top of
    /// login, keyed by a principal that identifies the target *before* the user
    /// is known (e.g. `tenant + email`, or the client IP), so unknown-user
    /// probing is throttled too and the response can't enumerate accounts.
    ///
    /// Returns [`IdentityError::AccountLocked`] when the window is exhausted, or
    /// [`IdentityError::Unavailable`] when the backend is down and the policy is
    /// fail-closed (the default).
    pub async fn check(&self, principal: &str) -> Result<()> {
        let key = format!("lockout::{principal}");
        match self
            .backend
            .hit(&key, self.max_attempts, self.window_secs)
            .await
        {
            RateDecision::Allow { .. } => Ok(()),
            RateDecision::Deny { .. } => Err(IdentityError::AccountLocked),
            RateDecision::Unavailable => {
                if self.fail_closed {
                    Err(IdentityError::Unavailable)
                } else {
                    Ok(())
                }
            }
        }
    }
}