Skip to content

fix(auth): accept each TOTP code once - #596

Merged
cristim merged 2 commits into
mainfrom
fix/totp-replay-and-mfa-lockout
Oct 6, 2026
Merged

cristim merged 2 commits into
mainfrom
fix/totp-replay-and-mfa-lockout

Conversation

@cristim

@cristim cristim commented Oct 6, 2026 •

Copy link
Copy Markdown
Member

Part 1 of #442 (TOTP replay). Refs #442; the issue stays open until the MFA lockout part lands.

  • Migration 000104 adds users.mfa_last_totp_counter.
  • ClaimTOTPCounter is a single conditional UPDATE, so a code is accepted once even when two requests race.
  • Login, MFADisable and MFARegenerateRecoveryCodes claim the matched counter; a replay returns the same invalid-code error as a wrong code. A claim error fails closed (tests for all three).
  • Tests: real-Postgres service tests (replay on login/disable/regenerate, next step accepted, older step rejected, wrong code does not spend, 8 racing logins accept one), a migration test, and mock-store unit tests. With the service changes reverted, the new integration test fails 5 subtests.

Behavior notes:

  • The claim UPDATE does bump updated_at: the update_users_updated_at trigger (000001_initial_schema.up.sql) fires on any users UPDATE. That is why two assertOnlyChanged-style assertions in the CAS tests were relaxed.
  • A login replay falls through to the recovery-code check and recordFailedLogin, so a double-submit counts toward MaxFailedLoginAttempts. This is intended.
  • The code is claimed before the downstream write, so a downstream failure (for example ErrUserChanged) spends the code for that 30s step. Login followed by MFADisable or regeneration with the same code in one step returns the same invalid-code error as a wrong code (MFADisable can use a recovery code instead).
  • MFAEnable stays stateless: the enrollment code can be replayed once at Login within its window. Follow-up: seed the counter in EnableMFA.

Not in this PR: lockout on MFA lifecycle failures and the per-user login key.

verifyTOTP accepted counters -1..+1 with no state, so an observed code
could be spent again by Login, MFADisable or recovery-code regeneration
for up to 90 seconds (RFC 6238 section 5.2).

Record the last accepted counter in users.mfa_last_totp_counter
(migration 000104) and claim it with a single conditional UPDATE, so a
replayed or racing code is rejected as an invalid code.

Refs #442
@cristim cristim added priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged urgency/this-quarter Within the quarter impact/all-users Affects every user effort/m Days type/security Security finding labels Oct 6, 2026
@coderabbitai

coderabbitai Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

This review includes 15 billable files and costs up to $3.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 14 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available. Your 64 included PR review attempts over the past 7 days set your current allowance at 1 review per hour.

Learn how review limits work.

Review configuration:

⚙️ Run configuration
  • Configuration used: Repository: LeanerCloud/cloud-commitments-platform/.coderabbit.yaml
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 071ae82e-8e5e-4627-a2e1-fe3840853b40
📥 Commits

Reviewing files that changed from the base of the PR and between 3de0c61 and c5a0143.

📒 Files selected for processing (15)
  • internal/auth/interfaces.go
  • internal/auth/service.go
  • internal/auth/service_credentials_db_test.go
  • internal/auth/service_mfa.go
  • internal/auth/service_mfa_cas_db_test.go
  • internal/auth/service_mfa_test.go
  • internal/auth/service_test.go
  • internal/auth/service_totp_replay_db_test.go
  • internal/auth/store_postgres_credentials.go
  • internal/auth/test_helpers.go
  • internal/database/postgres/migrations/000104_user_mfa_last_totp_counter.down.sql
  • internal/database/postgres/migrations/000104_user_mfa_last_totp_counter.up.sql
  • internal/database/postgres/migrations/000104_user_mfa_last_totp_counter_test.go
  • internal/mocks/stores.go
  • internal/server/health_test.go
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Comment @coderabbitai help to get the list of available commands.

@cristim
cristim merged commit eec6185 into main Oct 6, 2026
25 checks passed
cristim pushed a commit that referenced this pull request Oct 16, 2026
… per account (#1000)

The two items in the #442 body landed in #596 (TOTP counter) and #598
(lockout on lifecycle failures); this closes what was left open on the
issue:

- MFAEnable passes the enrollment code's TOTP counter to EnableMFA, which
  sets users.mfa_last_totp_counter in the same conditional UPDATE, so the
  code typed to turn MFA on cannot be replayed at Login inside its window.
  The counter is replaced, not raised, because it belonged to the previous
  secret.
- Login's TOTP / recovery-code leg gets a per-account "login_mfa" bucket
  (10 / 15 min, keyed by the parsed, lower-cased email, fail-closed), so
  rotating source IPs no longer resets the code-guessing budget.
- requireMFASession validates the session before the per-IP "mfa" bucket,
  so requests without a valid session do not spend that IP's budget.

Closes #442

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/all-users Affects every user priority/p2 Backlog-worthy severity/medium Moderate harm triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant