fix(auth): increment attempt counters atomically #474
Loading…
Reference in a new issue
No description provided.
Delete branch "fix/PMS-693-atomic-mfa-attempt-counter"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The MFA lockout read
mfa_failed_attemptsin one transaction and wrote backprior_count + 1as an absolute value in another. Under READ COMMITTED a burst of concurrent logins all read the same pre-burst value, so 500 wrong second-factor codes left the column at 1 andmfa_lockout_untilnever armed: the attacker got a whole burst of TOTP guesses per round trip instead of two.register_failed_mfanow takes noprior_countand its UPDATE increments the column relatively, deriving the new lock window from the post-increment value inside the same statement, which makes the stale read unrepresentable.The anti-replay watermark had the same read-then-write shape: two concurrent logins presenting the same valid code both saw
mfa_last_used_step < stepand both succeeded.record_mfa_successnow carries the comparison in its WHERE clause and returns whether a row moved; a zero-row update is treated as a replay and falls through to the failure branch.The lock schedule now exists twice, as
mfa_lockout_untilin Rust and as the SQL expressionmfa_lock_seconds_sqlthat the UPDATE evaluates, so a DB-backed parity test pins the two together across the whole documented table (failed_count 1..=12).Invariant sweep over every persistent attempt counter:
portal::service::register_failed_login(contacts.portal_failed_login_count) had the identical defect and gets the identical fix plus its own schedule parity test;verify_login_approvalreadlogin_approvals.attemptsand wrote backattempts + 1, so concurrent guesses against an emailed approval code collapsed into one tick and the 5-attempt cap never bit, now a relative UPDATE ... RETURNING drives the destroy decision.notifications::workerclaims rows with FOR UPDATE SKIP LOCKED (single writer, compliant) andknowledge_baserecomputes its vote tallies from the votes table rather than incrementing (not applicable).New pins in tests/auth.rs: 20 concurrent logins with distinct wrong codes leave
mfa_failed_attempts >= 20and the next attempt is RateLimited (pre-fix the counter reads 1); two concurrent logins with the same valid TOTP code yield exactly one success (pre-fix both succeeded). Both driveAuthService::logindirectly because the router's 5/min per-email limiter would reject most of the burst before it reached the MFA branch. A source-grep unit test fails if eitherregister_failed_*regrows a count parameter or any counter write assigns a bind placeholder instead of incrementing the column.#PMS-693