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 axum::Extension;
use runique::middleware::security::trusted_proxies::ClientIp;
use runique::prelude::*;
use std::sync::LazyLock;
static GUARD: LazyLock<LoginGuard> = LazyLock::new(|| {
LoginGuard::new()
.max_attempts(5)
.lockout_secs(300)
});
pub async fn sign_in(
client_ip: Option<Extension<ClientIp>>,
mut request: Request,
) -> AppResult<Response> {
let form: LoginForm = request.form("login");
let validated = match ValidationForm::try_form(form, &request).await {
Ok(validated) => validated,
Err(form) => {
context_update!(request => { "login_form" => &form });
return request.render("login.html");
}
};
let username = validated.cleaned::<String>("username").unwrap_or_default();
let password = validated.cleaned::<String>("password").unwrap_or_default();
// The real client IP, resolved by the trusted proxies middleware
let ip = client_ip.map(|Extension(ClientIp(ip))| ip.to_string()).unwrap_or_default();
// 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 Ok((StatusCode::TOO_MANY_REQUESTS, format!("Try again in {remaining}s")).into_response());
}
if let Some(user) = authenticate_user(&request.engine.db, &username, &password).await
&& login(&request.session, &user, None, false).await.is_ok()
{
GUARD.record_success(&key);
return Ok(Redirect::to("/dashboard").into_response());
}
GUARD.record_failure(&key);
context_update!(request => {
"login_form" => &*validated,
"messages" => flash_now!(error => "Invalid credentials"),
});
request.render("login.html")
}
Why in the handler and not middleware?
A middleware runs before the handler and cannot read the body without consuming it.
request.form("name") 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: request.user is None on this route, which does not protect the targeted account.
| Source | Available in middleware | Reliable for LoginGuard |
|---|---|---|
| IP address | ✅ | ✅ (used by effective_key for anon) |
Signed-in user (request.user) | ✅ | ❌ (None on /login, falls back to anon) |
| Username (form body) | ❌ (consumes the body) | ✅ (via request.form("name") 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.
An expired entry is removed when accessed. Periodic cleanup removes entries that are no longer accessed.
attempts(key) -> u32
Current failure count for this key.
remaining_lockout_secs(key) -> Option<u64>
Seconds remaining until unlock. None if not locked.
Periodic cleanup
The admin builder automatically starts cleanup for its configured LoginGuard. For standalone use, call spawn_cleanup once while a Tokio runtime is active; the effective period is clamped between 1 and 60 seconds. This purge limits retention of expired entries but does not impose a strict entry-count cap.
Combining with IP Rate Limiting
Both mechanisms cover different attack vectors and are complementary:
RateLimiter | LoginGuard | |
|---|---|---|
| Key | IP address | Username or anonym:{ip} |
| Where | Middleware (automatic) | Handler (manual) |
| Target | All routes | Login only |
| Protects against | Volume attack per IP | Brute-force per account |
| False positives | Possible (NAT) | None (per account) |
static IP_LIMITER: LazyLock<RateLimiter> = LazyLock::new(|| RateLimiter::new().max_requests(10).retry_after(60));
static GUARD: LazyLock<LoginGuard> = LazyLock::new(|| LoginGuard::new().max_attempts(5).lockout_secs(300));
// In the sign-in handler above, before authenticating:
// 1. Volume per IP → RateLimiter (as middleware on the route, or by hand)
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: authenticate_user, then record_success / record_failure
Known limit: an account can be locked by someone else
The lock is keyed by username, not by IP: that's what stops one attacker spreading guesses over many addresses. The flip side: anyone who knows a username (an admin's, for instance) can lock that account by failing on purpose, without knowing its password, and keep it locked by retrying.
To reduce it:
- keep the IP rate limiter in front (section above): it caps how many attempts one address can make, so locking accounts in a loop costs the attacker much more;
- block abusive addresses before the app, at the reverse proxy;
- choose admin usernames that aren't public (not
admin, not the name shown on the site).
The counters live in the memory of each instance: see Rate limiting — several instances.
Current limit: no memory cap
LoginGuard keeps one entry per username typed, including names that don't exist (otherwise the lockout would reveal which accounts exist), with no maximum number of entries. Made-up names sent in bulk therefore grow the memory until they expire. The reasons, and what to do until the cap is handled (a reverse proxy that rate-limits requests, a per-IP limiter in front): see Rate limiting — current limit.