Skip to content

security(auth): a TOTP code can be replayed within its validity window #849

Description

@alex-dembele

Problem

A TOTP code is accepted as many times as it is typed within its window. The server checks it with otp.VerifyTOTP (pquerna/otp totp.Validate, ±1 step, so about 90 s) and never records that it was used: MFASecret.LastUsedAt is written after a login challenge but never read.

So someone who watches a user type a code (shoulder-surfing, screen share, a recorded session) and also has the password or an open session can use the same code again within that window. The second factor is then not a second proof, and a replay goes through anywhere a code is checked:

Where File
Login challenge backend/internal/application/auth/mfa_usecase.go — ChallengeMFAUseCase.Execute
Enrolment verification mfa_usecase.go — VerifyMFAUseCase.Execute
Turning MFA off (SSO accounts, #754 / D-061) mfa_usecase.go — DisableMFAUseCase.Execute
Step-up for sensitive actions backend/internal/infrastructure/authmfa/gate.go — Gate.VerifyRequired

Found while working on #754 (PR #848).

Acceptance criteria

  1. A TOTP code accepted once for an account is refused if presented again, at any of the four places above, for as long as it would otherwise still be valid. The refusal looks exactly like a wrong code (same status, same body, same audit reason).
  2. A code from an earlier time step than the last accepted one is refused too, so an older code seen on screen can't be used after a newer one.
  3. Accepting a code and recording it happen atomically: two concurrent requests carrying the same code cannot both succeed. Proven by a test that fires them in parallel against Postgres.
  4. Backup codes behave as today (already single use).
  5. Additive schema only: one nullable column on mfa_secrets, carried by AutoMigrate and by a numbered SQL migration with a down file. Nothing dropped or renamed.
  6. Tests: TestChallengeMFA_ReplayedCodeIsRefused, TestDisableMFA_ReplayedCodeIsRefused, TestGate_ReplayedCodeIsRefused, one test for an earlier step, and the concurrency test from criterion 3. The existing Success / NotFound / Unauthorized tests stay green.

Definition of Done

  • The criteria above are met, with test output pasted in the PR.
  • Full go test ./... passes, and the Postgres-gated tests pass with DATABASE_URL set on PostgreSQL 16.
  • A live run against a booted server shows a replayed login code refused.
  • No secret or code is logged.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions