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:
@@ -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)"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user