fix(auth): escalating recovery-PIN lockout backoff (M4)

The failed-attempt counter is no longer reset when the lockout window expires,
so each further wrong PIN past the 3-strike threshold doubles the lockout —
15min, 30, 60, … capped at 24h. Combined with the now 6-digit PIN (1M space),
this makes sustained guessing via /recover infeasible. A successful recovery or
a host PIN reset clears the counter; legitimate users (who don't fail) are
unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-27 16:28:31 +02:00
parent 0737288ed9
commit 8272197cea

View File

@@ -175,20 +175,18 @@ pub async fn recover(
} }
for user in &users { for user in &users {
// Check PIN lockout. If the lockout has expired, also reset the failed-attempt // Check PIN lockout. The failed-attempt counter is deliberately NOT reset
// counter so the user gets a fresh 3-strike window — otherwise the counter // when the window expires — it drives the *escalating* backoff below, so a
// stays at 3+ and every subsequent wrong PIN immediately re-locks them, even // determined guesser faces an exponentially growing wait. A successful
// after waiting out the cooldown. Without this reset, a once-locked account // recovery (and a host PIN reset) is what clears it. The counter only ever
// is effectively permanently fragile. // grows on a *wrong* PIN, so a legitimate user is unaffected.
if let Some(locked_until) = user.pin_locked_until { if let Some(locked_until) = user.pin_locked_until {
if Utc::now() < locked_until { if Utc::now() < locked_until {
return Err(AppError::TooManyRequests( return Err(AppError::TooManyRequests(
"Zu viele Versuche. Bitte warte 15 Minuten.".into(), "Zu viele Versuche. Bitte warte und versuche es später erneut.".into(),
None, None,
)); ));
} }
// Lockout window expired — wipe the counter and the timestamp.
User::reset_pin_attempts(&state.pool, user.id).await?;
} }
let pin_matches = let pin_matches =
@@ -227,13 +225,19 @@ pub async fn recover(
"recover: wrong PIN" "recover: wrong PIN"
); );
if attempts >= 3 { if attempts >= 3 {
let lockout = Utc::now() + chrono::Duration::minutes(15); // Escalating backoff: 15min, 30, 60, … doubling per failure past the
// threshold, capped at 24h. Makes sustained guessing against the
// 6-digit space (1M combinations) astronomically slow.
let exp = (attempts as u32).saturating_sub(3).min(10);
let minutes = 15i64.saturating_mul(1i64 << exp).min(24 * 60);
let lockout = Utc::now() + chrono::Duration::minutes(minutes);
User::lock_pin(&state.pool, user.id, lockout).await?; User::lock_pin(&state.pool, user.id, lockout).await?;
tracing::warn!( tracing::warn!(
user_id = %user.id, user_id = %user.id,
event_id = %event.id, event_id = %event.id,
ip = %ip, ip = %ip,
"recover: account locked for 15 minutes" minutes,
"recover: account locked (escalating backoff)"
); );
} }
} }