fix(auth): cap + anti-replay TOTP verification on agent login #400

Merged
David merged 1 commit from fix/PMS-502-mfa-totp-attempt-cap-anti-replay into main 2026-06-29 21:25:01 +02:00
Owner

The legacy agent login MFA path verified the TOTP code with totp::verify
but discarded the matched step and kept no per-account attempt accounting. The
only throttle was the in-memory per-email login limiter (5/min), which resets
on restart, does not coordinate across replicas, and is shared with password
attempts. With a +/-1 window over a 6-digit space (3 of 1,000,000 live codes at
any instant) that let an attacker holding the password grind second-factor
codes until one matched.

Persist per-account second-factor state on the users row (migration 084): mfa_last_used_step (anti-replay watermark), mfa_failed_attempts, and mfa_locked_until. The login MFA branch now reads this state, rejects with 429 while a lockout window is active, only honours a TOTP step strictly greater than the last accepted one (so a captured code cannot be replayed inside its window), and on a wrong or replayed code increments the counter and arms an exponential-backoff lockout (mfa_lockout_until: none under 3 failures, then 30s doubling to a 1h cap). A successful code advances the watermark and clears the counter + lockout. This second-factor defence is persistent and independent of, and stricter than, the in-memory password-login bucket; it mirrors the portal-login remediation (PMS-501, migration 082).

Tests: unit tests pin the lockout schedule; integration tests pin TOTP replay rejection and that repeated wrong codes arm a persistent lockout that rejects even a correct code with 429.

#PMS-502

The legacy agent login MFA path verified the TOTP code with `totp::verify` but discarded the matched step and kept no per-account attempt accounting. The only throttle was the in-memory per-email login limiter (5/min), which resets on restart, does not coordinate across replicas, and is shared with password attempts. With a +/-1 window over a 6-digit space (3 of 1,000,000 live codes at any instant) that let an attacker holding the password grind second-factor codes until one matched. Persist per-account second-factor state on the `users` row (migration 084): `mfa_last_used_step` (anti-replay watermark), `mfa_failed_attempts`, and `mfa_locked_until`. The login MFA branch now reads this state, rejects with 429 while a lockout window is active, only honours a TOTP step strictly greater than the last accepted one (so a captured code cannot be replayed inside its window), and on a wrong or replayed code increments the counter and arms an exponential-backoff lockout (`mfa_lockout_until`: none under 3 failures, then 30s doubling to a 1h cap). A successful code advances the watermark and clears the counter + lockout. This second-factor defence is persistent and independent of, and stricter than, the in-memory password-login bucket; it mirrors the portal-login remediation (PMS-501, migration 082). Tests: unit tests pin the lockout schedule; integration tests pin TOTP replay rejection and that repeated wrong codes arm a persistent lockout that rejects even a correct code with 429. #PMS-502
fix(auth): cap + anti-replay TOTP verification on agent login
All checks were successful
Check / fmt + clippy + build + tests (pull_request) Successful in 3m33s
Integration / integration tests (pull_request) Successful in 11m8s
E2E / Playwright against staging (pull_request) Successful in 34s
Create release / Gate (release-branch merges only) (pull_request) Successful in 1s
Create release / Create release from merged PR (pull_request) Has been skipped
9f617802fe
The legacy agent login MFA path verified the TOTP code with `totp::verify`
but discarded the matched step and kept no per-account attempt accounting. The
only throttle was the in-memory per-email login limiter (5/min), which resets
on restart, does not coordinate across replicas, and is shared with password
attempts. With a +/-1 window over a 6-digit space (3 of 1,000,000 live codes at
any instant) that let an attacker holding the password grind second-factor
codes until one matched.

Persist per-account second-factor state on the `users` row (migration 084): `mfa_last_used_step` (anti-replay watermark), `mfa_failed_attempts`, and `mfa_locked_until`. The login MFA branch now reads this state, rejects with 429 while a lockout window is active, only honours a TOTP step strictly greater than the last accepted one (so a captured code cannot be replayed inside its window), and on a wrong or replayed code increments the counter and arms an exponential-backoff lockout (`mfa_lockout_until`: none under 3 failures, then 30s doubling to a 1h cap). A successful code advances the watermark and clears the counter + lockout. This second-factor defence is persistent and independent of, and stricter than, the in-memory password-login bucket; it mirrors the portal-login remediation (PMS-501, migration 082).

Tests: unit tests pin the lockout schedule; integration tests pin TOTP replay rejection and that repeated wrong codes arm a persistent lockout that rejects even a correct code with 429.

#PMS-502
David scheduled this pull request to auto merge when all checks succeed 2026-06-29 00:42:50 +02:00
David merged commit 7da858b822 into main 2026-06-29 21:25:01 +02:00
David deleted branch fix/PMS-502-mfa-totp-attempt-cap-anti-replay 2026-06-29 21:25:01 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/mokosh-server!400
No description provided.