Middleware & Auth

Login Lockout

New to acton-service?

Start with the homepage to understand what acton-service is, then explore Core Concepts for foundational explanations. See the Glossary for technical term definitions.


The login-lockout feature provides brute force protection for login endpoints. It tracks failed login attempts per identity in Redis, applies configurable progressive delays, and locks accounts after repeated failures.

Feature flag: login-lockout (depends on auth + cache)

acton-service = { version = "0.11.0", features = ["login-lockout", "http", "observability"] }

Quick Start

1. Add lockout configuration to config.toml

[lockout]
enabled = true
max_attempts = 5
window_secs = 900
lockout_duration_secs = 1800

2. Create the lockout service and use in your handler

use acton_service::prelude::*;

async fn login(
    State(lockout): State<LoginLockout>,
    Json(creds): Json<LoginRequest>,
) -> Result<Json<TokenPair>> {
    // Check if account is locked
    let status = lockout.check(&creds.email).await?;
    if status.locked {
        return Err(Error::AccountLocked {
            message: format!("Try again in {} seconds", status.lockout_remaining_secs),
            retry_after_secs: status.lockout_remaining_secs,
        });
    }

    // Attempt authentication
    match authenticate(&creds).await {
        Ok(tokens) => {
            lockout.record_success(&creds.email).await?;
            Ok(Json(tokens))
        }
        Err(_) => {
            let status = lockout.record_failure(&creds.email).await?;
            if status.delay_ms > 0 {
                tokio::time::sleep(Duration::from_millis(status.delay_ms)).await;
            }
            Err(Error::Unauthorized("Invalid credentials".into()))
        }
    }
}

Configuration Reference

All fields have sensible defaults. Only override what you need.

FieldTypeDefaultDescription
enabledbooltrueWhether lockout enforcement is active
max_attemptsu325Failed attempts before account is locked
window_secsu64900 (15 min)Rolling window for counting failures
lockout_duration_secsu641800 (30 min)How long a locked account stays locked
progressive_delay_enabledbooltrueApply exponential backoff delays
base_delay_msu641000 (1s)Initial delay after first failure
max_delay_msu6430000 (30s)Maximum delay cap
delay_multiplierf642.0Exponential backoff multiplier
warning_thresholdu323Fire warning notification after N failures (0 = disabled)
key_prefixString"lockout"Redis key prefix (no : or whitespace)

Service API

LoginLockout

The core service. Construct once at startup, share via axum State or Extension.

use acton_service::prelude::*;

// Create the service
let lockout_config = LockoutConfig::default();
let lockout = LoginLockout::new(lockout_config, redis_pool);

// Add to your router
let app = Router::new()
    .route("/login", post(login_handler))
    .with_state(lockout);

Methods

check(identity) -> Result<LockoutStatus>

Check lockout status without recording a failure. Returns current attempt count, lock state, and recommended delay.

record_failure(identity) -> Result<LockoutStatus>

Record a failed login attempt. Increments the counter, fires notifications, and locks the account if the threshold is reached.

record_success(identity) -> Result<()>

Record a successful login. Clears all lockout state (attempt counter and lock flag) for the identity.

unlock(identity) -> Result<()>

Manually unlock an account (admin action). Clears both the attempt counter and lockout flag.

LockoutStatus

Returned by check() and record_failure():

FieldTypeDescription
lockedboolWhether the account is currently locked
attempt_countu32Number of failed attempts in the window
max_attemptsu32Maximum allowed attempts
lockout_remaining_secsu64Seconds until lock expires (0 if not locked)
delay_msu64Recommended delay before responding (0 if none)

Progressive Delay

When progressive_delay_enabled is true, each failed attempt increases the response delay exponentially:

delay = min(base_delay_ms * delay_multiplier^(attempt - 1), max_delay_ms)

With default settings (base=1000ms, multiplier=2.0, max=30000ms):

AttemptDelay
11,000 ms (1s)
22,000 ms (2s)
34,000 ms (4s)
48,000 ms (8s)
516,000 ms (16s)
6+30,000 ms (30s, capped)

The delay is returned in LockoutStatus.delay_ms. The caller is responsible for applying the sleep:

let status = lockout.record_failure(&email).await?;
if status.delay_ms > 0 {
    tokio::time::sleep(Duration::from_millis(status.delay_ms)).await;
}

Middleware Approach

For automatic enforcement without manual check/record_failure/record_success calls, use LockoutMiddleware:

use acton_service::prelude::*;

let lockout = LoginLockout::new(config, redis_pool);
let mw = LockoutMiddleware::new(lockout, "email"); // "email" = JSON field name

let app = Router::new()
    .route("/login", post(login_handler))
    .route_layer(axum::middleware::from_fn_with_state(
        mw,
        LockoutMiddleware::middleware,
    ));

The middleware:

  1. Extracts the identity from the JSON request body (field name configurable)
  2. If locked, returns HTTP 423 with Retry-After header
  3. Forwards the request to your handler
  4. If the handler returns 401, records a failure and applies progressive delay
  5. If the handler returns 2xx, records a success

Non-JSON requests pass through without enforcement.


Notification Hooks

Register handlers to react to lockout lifecycle events:

use acton_service::prelude::*;

struct EmailNotifier { /* ... */ }

#[async_trait]
impl LockoutNotification for EmailNotifier {
    async fn on_event(&self, event: LockoutEvent) {
        match event {
            LockoutEvent::AccountLocked { identity, .. } => {
                // Send "your account has been locked" email
            }
            LockoutEvent::ApproachingThreshold { identity, remaining_attempts, .. } => {
                // Send "N attempts remaining" warning
            }
            _ => {}
        }
    }
}

let lockout = LoginLockout::new(config, redis_pool)
    .with_notification(Arc::new(EmailNotifier { /* ... */ }));

Event Types

EventWhenKey Fields
FailedAttemptEvery failed loginidentity, attempt_count, max_attempts
ApproachingThresholdattempt_count == warning_thresholdidentity, remaining_attempts
AccountLockedattempt_count >= max_attemptsidentity, lockout_duration_secs
AccountUnlockedLock cleared (success, expiry, admin)identity, reason

Notifications are dispatched via tokio::spawn (fire-and-forget) and never block the login response.


Audit Integration

When both login-lockout and audit features are active, use the built-in audit notification handler:

let lockout = LoginLockout::new(config, redis_pool)
    .with_audit(audit_logger);

This automatically emits:

  • auth.account.locked when an account is locked
  • auth.account.unlocked when an account is unlocked

Events include metadata with the identity, attempt count, and reason.


Admin Unlock

To manually unlock an account (e.g., from an admin endpoint):

async fn admin_unlock(
    State(lockout): State<LoginLockout>,
    Path(user_id): Path<String>,
) -> Result<StatusCode> {
    lockout.unlock(&user_id).await?;
    Ok(StatusCode::NO_CONTENT)
}

Best Practices

Identity Normalization

Normalize identities before passing to lockout methods to prevent bypasses:

let email = creds.email.trim().to_lowercase();
let status = lockout.check(&email).await?;

Redis Availability

The lockout service requires Redis. If Redis is unavailable, lockout operations will return errors. Consider:

  • Setting redis.optional = false in config to fail fast
  • Using the resilience feature for retry on transient Redis failures

Testing

For unit tests that don't need Redis, test the configuration and delay logic directly:

let config = LockoutConfig {
    max_attempts: 3,
    base_delay_ms: 500,
    ..Default::default()
};
assert!(config.validate().is_ok());

For integration tests with Redis, use a test Redis instance and verify the full flow:

let lockout = LoginLockout::new(config, test_redis_pool);
lockout.record_failure("test@example.com").await?;
lockout.record_failure("test@example.com").await?;
let status = lockout.record_failure("test@example.com").await?;
assert!(status.locked);
Previous
Rate Limiting