Authentication

LoginGuard — Brute-force Protection

LoginGuard tracks failed login attempts per username, independently of the IP address.

Full usage with `effective_key`

effective_key automatically determines the right key based on whether a username was submitted:

  • Username submitted → key by account (targeted protection)
  • Empty username → "anonym:{ip}" (per-IP protection, independent counters)
use runique::prelude::*;

static GUARD: LazyLock<LoginGuard> = LazyLock::new(|| {
    LoginGuard::new()
        .max_attempts(5)
        .lockout_secs(300)
});

pub async fn login(
    session: Session,
    State(db): State<DatabaseConnection>,
    Prisme(form): Prisme<LoginForm>,
) -> impl IntoResponse {
    let username = form.username();
    let ip = /* extract IP from headers */;

    // username submitted → "alice"  |  empty username → "anonym:1.2.3.4"
    let key = LoginGuard::effective_key(&username, &ip);

    if GUARD.is_locked(&key) {
        let remaining = GUARD.remaining_lockout_secs(&key).unwrap_or(0);
        return (StatusCode::TOO_MANY_REQUESTS, format!("Try again in {remaining}s")).into_response();
    }

    match authenticate(&username, &form.password(), &db).await {
        Some(user) => {
            GUARD.record_success(&key);
            login(&session, user.id, &user.username).await.unwrap();
            Redirect::to("/dashboard").into_response()
        }
        None => {
            GUARD.record_failure(&key);
            (StatusCode::UNAUTHORIZED, "Invalid credentials").into_response()
        }
    }
}

Why in the handler and not middleware?

A middleware runs before the handler and cannot read the body without consuming it. Prisme extracts the form exactly once — the username is only available after that extraction.

The session knows the authentication state, but at login time the user is not yet authenticated: session.username would always be "anonym" on this route, which does not protect the targeted account.

SourceAvailable in middlewareReliable for LoginGuard
IP address✅ (used by effective_key for anon)
Username (session)❌ (always anon on /login)
Username (form body)❌ (consumes the body)✅ (via Prisme in the handler)

Configuration

LoginGuard::new().max_attempts(5).lockout_secs(300)    // 5 failures → 5 minute lockout
LoginGuard::new().max_attempts(3).lockout_secs(900)    // 3 failures → 15 minute lockout
LoginGuard::new().max_attempts(10).lockout_secs(3600)  // 10 failures → 1 hour lockout

API

LoginGuard::new()

Creates a LoginGuard with default values (5 attempts / 300 s).

.max_attempts(max: u32)

Number of failures before account lockout.

.lockout_secs(secs: u64)

Lockout duration in seconds.

LoginGuard::effective_key(username, ip) -> Cow<str>

Returns the key to use based on context:

  • Non-empty username → key by username (targeted account protection)
  • Empty or missing username → "anonym:{ip}" (anonymous protection per IP)

Guarantees that two different anonymous IPs have independent counters — locking "anonym:1.2.3.4" does not affect "anonym:5.6.7.8".

Why not a global "anonym" key? A single abusive attempt would lock out every anonymous user worldwide. The per-IP key isolates each attacker.

record_failure(key)

Increments the failure counter for this key. Call after each failed authentication attempt.

record_success(key)

Resets the counter. Call after a successful login.

is_locked(key) -> bool

Returns true if the failure count exceeds max_attempts and the lockout duration has not elapsed.

attempts(key) -> u32

Current failure count for this key.

remaining_lockout_secs(key) -> Option<u64>

Seconds remaining until unlock. None if not locked.


Combining with IP Rate Limiting

Both mechanisms cover different attack vectors and are complementary:

RateLimiterLoginGuard
KeyIP addressUsername or anonym:{ip}
WhereMiddleware (automatic)Handler (manual)
TargetAll routesLogin only
Protects againstVolume attack per IPBrute-force per account
False positivesPossible (NAT)None (per account)
static IP_LIMITER: LazyLock<RateLimiter> = LazyLock::new(|| RateLimiter::new().max_requests(10).window_secs(60));
static GUARD:      LazyLock<LoginGuard>  = LazyLock::new(|| LoginGuard::new().max_attempts(5).lockout_secs(300));

pub async fn login(/* ... */) -> impl IntoResponse {
    // 1. Volume per IP → RateLimiter (middleware or manual)
    if !IP_LIMITER.is_allowed(&ip) { /* 429 */ }

    // 2. Brute-force per account or anonymous IP → LoginGuard
    let key = LoginGuard::effective_key(&username, &ip);
    if GUARD.is_locked(&key) { /* 429 */ }

    // 3. Authentication
    match authenticate(&username, &password, &db).await {
        Some(user) => { GUARD.record_success(&key); /* ... */ }
        None       => { GUARD.record_failure(&key); /* ... */ }
    }
}

Protection Middlewares | Complete Example