Repository navigation
feat(auth): issue a single-use MFA login token instead of resending the password - #1571
Conversation
…he password
A correct password for an MFA-enrolled user answered only mfa_required, so
the browser kept the plaintext password in memory until the user typed the
code and sent password + code again.
That answer now also carries mfa_token: a random token whose SHA-256 is
stored in mfa_login_tokens (migration 000152), bound to the user and to the
password version the password step verified, valid for 5 minutes and for
3 code attempts. The code step can send {email, mfa_token, mfa_code} with
no password. Each attempt is claimed in one UPDATE before the code is
checked, so concurrent guesses cannot exceed the cap; only the request that
deletes the token gets a session. Wrong codes still count toward the
account lockout and the per-account login_mfa bucket, and the
password + code form keeps working until the frontend switches.
Refs LeanerCloud#134
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… through testReporter Main's bare-panic guard (LeanerCloud#1561) rejects the two bare panics in MockAuthStore.CreateMFALoginToken and ClaimMFALoginTokenAttempt; report them through m.reporter.wrongReturnType like the other mocks. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…doesn't flag it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Caution Review failedThe pull request is closed. Finishing Touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rI3. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Independent review (IAmJSD's agent, fresh context, given only the diff, the issue and the PR text): VERDICT: APPROVE Blocking:
Non-blocking:
Attacked:
Body-stale:
Reviewer: rI2. Bar while the maintainers are away (set by IAmJSD as acting owner): one approving independent review on the head, two for money, auth and security paths. |
|
Local verification at |
|
Merged by IAmJSD's agent at IAmJSD's request (IAmJSD is acting owner while the maintainers are away), under the bar IAmJSD set: 2 approving independent review(s) on |
Keeps LeanerCloud#1571's MFA-token handler tests alongside this branch's nil-limiter tests, and gives LeanerCloud#1571's tests a rate limiter now that a nil limiter fails closed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Summary
This is the backend half of the #134 item "MFA step retains the plaintext password in module scope after an abandoned login". The item's fix direction says to "prefer holding a server-issued short-lived MFA continuation token over the raw password".
Root cause:
POST /api/auth/loginwith a correct password for an MFA-enrolled user returned only401 {"error":"mfa_required"}. The only way to finish the login was to sendpassword + mfa_codeagain, so the browser had to keep the plaintext password until the user typed the code.Fix:
mfa_requiredanswer now also carriesmfa_tokenandmfa_token_expires_at. The token is 32 random bytes. Only its SHA-256 is stored, in the newmfa_login_tokenstable (migration 000152), using the same hash helper as session tokens.users.password_versionthat the password step verified. A password change between the two steps makes it invalid, as does MFA being turned off or the user being deleted (FK cascade).MFALoginTokenTTL, computed on the database clock) and allows 3 code attempts (MaxMFALoginTokenAttempts).{email, mfa_token, mfa_code}with no password.emailis required and must name the token's user.password + mfa_codeform is unchanged and keeps working until the frontend switches.invalid_mfa_codefor a wrong code (unchanged), newinvalid_mfa_tokenfor an unknown, expired, used, exhausted or wrong-account token (the client starts again from the password), andinvalid credentialsfor locked or inactive accounts (unchanged).mfa_requiredas before, and the old form still works.Concurrency:
UPDATE ... SET attempts = attempts + 1 WHERE token_hash = $1 AND expires_at > NOW() AND attempts < $max RETURNING .... The row lock plus READ COMMITTED re-evaluation means at most$maxcode checks happen per token, however many requests race.DELETE ... WHERE token_hash = $1 AND expires_at > NOW(). Only the request that actually deletes the row gets a session, so the token is single use even when two different valid factors (a TOTP code and a recovery code) arrive at the same time.Rate limiting and lockout:
loginbucket applies.emailandmfa_codeare required in the token form, the per-accountlogin_mfabucket (sec(auth): TOTP replay window and no lockout on MFA lifecycle failures (follow-up to #94) #442) applies too. It is keyed by email, and a mismatched email can never succeed, so leaving it out or faking it doesn't escape the bucket.RecordFailedLogin, so it counts toward the account lockout (5).Request validation:
passwordandmfa_token, or sendsmfa_tokenwithoutemailormfa_code.Refs #134
Changes
internal/database/postgres/migrations/000152_mfa_login_tokens.{up,down}.sql: new table (hash PK, user FKON DELETE CASCADE,password_version,attempts >= 0,expires_at), with indexes onexpires_atanduser_id. Expired rows are removed whenever a new token is issued.internal/auth/store_postgres_mfa_login_tokens.go:CreateMFALoginToken,ClaimMFALoginTokenAttempt,ConsumeMFALoginToken. These are added toStoreInterfaceand to the three test doubles (auth.MockStore,mocks.MockAuthStore,server.mockAuthStoreForHealth).internal/auth/service_mfa_login_token.go:MFARequiredError(it unwraps toErrMFARequired, so every existingerrors.Ischeck still holds),issueMFAChallenge,loginWithMFAToken,claimMFALoginToken.internal/auth/service.go:Loginroutes the token form and issues the challenge. The TOTP/recovery-code check is moved out ofverifyPasswordAndMFAintoverifyLoginMFACodeso both forms share it. Its behavior is unchanged.internal/auth/errors.go: newErrInvalidMFAToken.internal/auth/types.goandinternal/api/types.go: newLoginRequest.MFAToken.internal/server/app.go: the adapter forwards the field.internal/api/handler_auth.go:validateLoginFormandmapLoginError. The token and its expiry are flattened into the 401 body throughNewClientErrorWithDetails.internal/api/openapi.yaml: documents both login forms,mfa_tokenonLoginRequest(passwordis no longer listed as required), and a newLoginError401 schema with the four error codes plusmfa_token/mfa_token_expires_at.Verification
Commands run locally on the branch (Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true):New tests:
internal/auth/service_mfa_login_token_db_test.go,TestIntegration_MFALoginToken), one subtest per scenario:happy-path-stores-hash-and-signs-in: the hash is stored and the raw token is not.expired: no code is checked, no attempt is spent, and the row is removed on the next issue.reused: a recovery code does not bring a used token back, and no recovery code is spent.wrong-user: A's token with B's email or B's code, and the reverse, never produce a session.too-many-attempts: the token is spent after 3 wrong codes while the account is still unlocked, and a fresh password login works.password-changed-voids-token.old-password-and-code-form-still-works: it also leaves the token alone, and a non-MFA user gets no token.concurrent-guesses-capped: 12 parallel wrong guesses result in exactly 3 code checks andfailed_login_attempts == 3.concurrent-valid-codes-one-session: a barrier holds both requests until each has passed its code check (one TOTP, one recovery code), then exactly one session is created.000152_mfa_login_tokens_test.go): up, constraints (unique and non-empty hash,attempts >= 0), cascade on user delete, and down drops the table. The predecessor comes frompreviousMigrationVersion(t, 152).internal/auth/service_mfa_login_token_test.go, plus updatedTestService_Login_MFA/TestLogin_WithMFA_MissingCode): happy path, unclaimable token, claim error, malformed form, user / password-version / MFA-off mismatch, locked account, a wrong code that records a failure, a lost consume race, the challenge storing only the hash, and the tokenless fallback when storing fails.internal/api/handler_auth_login_mfa_limit_test.go): the 401 body carriesmfa_tokenandmfa_token_expires_at; the token form is forwarded andinvalid_mfa_tokenis mapped; mixed or incomplete forms get a 400 that spends nologin_mfaslot; the token form and the password form share one per-emaillogin_mfabudget.internal/server/adapter_mfa_token_test.go,TestLogin_MFATokenRoundTrip): router, handler,authServiceAdapterand the realauth.Servicewith a mocked store. Password step, then token, then a session.Fail-before / pass-after. This is local evidence: unit and mock tests plus real-Postgres integration tests through testcontainers.
origin/main, becauseLoginRequest.MFATokenandMFARequiredErrordon't exist there.Loginon the branch reverted to main's behavior (no token issued, token form not routed), these fail:TestLoginWithMFAToken_HappyPath,TestLogin_MFATokenRoundTrip, and all 9TestIntegration_MFALoginTokensubtests. Restoring the fix makes them pass.attempts < $maxfrom the claimtoo-many-attempts,concurrent-guesses-cappedconcurrent-valid-codes-one-sessionexpiredwrong-userpassword-changed-voids-tokenCombined-tree verification. This branch was tested together with the other queued branches on a local, never-pushed tree:
queue/platform@c00ef619= origin/main15dc8febplus these heads, merged in this order:Linux, Go 1.26.9,
AWS_CONFIG_FILE=/dev/null AWS_SHARED_CREDENTIALS_FILE=/dev/null AWS_EC2_METADATA_DISABLED=true; Docker Postgres via testcontainers. The full suite ran on644bfcaf, which differs from this tree only by a test-only rename on fix/issue-134-mfa-token (a password-like constant that git-secrets flagged):go build ./...,go vet ./...,go vet -tags integration ./...and the fullgo test -race -short -count=1 ./....--build-tags integration.gocyclo -over 10is silent on the changed non-test files.-tags integration -count=1):internal/api,internal/auth,internal/config,internal/database/postgres/migrations,internal/purchaseandinternal/serverall pass. Migration 000152 applies on top of main's 000151.scripts/setup-git-secrets.shpatterns in a fresh repo with an isolated HOME, found nothing in the changed files, and a planted fake key was flagged.scripts/test-git-secrets-allowlist.sh: 183 PASS, 0 FAIL..tftest.hclfiles were not run on this tree (CI runs them). Nothing was applied to any cloud.Out of scope
password + mfa_codeform. That should happen after the frontend switches, in a separate change.Frontend follow-up for the UI owner
frontend/src/auth.ts(pendingMFAPassword) andfrontend/src/api/auth.ts(login):error === "mfa_required", readmfa_tokenfrom the body (andmfa_token_expires_atif you want a countdown or auto-reset). Clear the password field and droppendingMFAPasswordright away. Keep only the email and the token.POST /api/auth/loginwith{"email": <email>, "mfa_token": <token>, "mfa_code": <code>}and nopasswordkey. Sending bothpasswordandmfa_tokenis a 400.invalid_mfa_code, keep the token and let the user retry. After 3 wrong codes the token is spent.invalid_mfa_token(expired, used, too many wrong codes, password changed), clear the token and return to the password step with a message such as "Your sign-in timed out, please enter your password again". Also clear the token when the user abandons the step ("Back to login", tab hidden for a long time, or aftermfa_token_expires_at).mfa_requiredanswer has nomfa_token(the server couldn't store one), the oldpassword + mfa_coderesend still works. The UI can either keep that path for this case or ask for the password again.Notes for reviewers
emailis required in the token form, although the issue text only mentions{mfa_token, code}. Requiring it is what keeps the per-accountlogin_mfabucket (sec(auth): TOTP replay window and no lockout on MFA lifecycle failures (follow-up to #94) #442) on this path, and it gives a concrete "wrong user" check.password_versionis the same binding sessions use (auth: password-rotation credential invalidation is best-effort, not atomic #398). A concurrent password change after the token is checked still revokes the new session throughcheckSessionUser.Please mirror the issue's triage labels onto this PR:
effort/l,type/chore,triaged,priority/p3,severity/low,urgency/eventually,impact/many.🤖 Generated with Claude Code