Skip to content

fix(auth): require the password to turn MFA off (#754) - #848

Merged
alex-dembele merged 9 commits into
masterfrom
754-securityauth-disabling-mfa-does-not-re-verify-the-password
Oct 1, 2026
Merged

alex-dembele merged 9 commits into
masterfrom
754-securityauth-disabling-mfa-does-not-re-verify-the-password

Conversation

@alex-dembele

@alex-dembele alex-dembele commented Sep 30, 2026 •

Copy link
Copy Markdown
Member

Closes #754

Turning MFA off used to need nothing more than an open session. It now needs the current password, is refused for roles that must keep MFA, happens in one transaction, and is audited and reported to the account owner.

What changed

  • Password re-check: POST /auth/mfa/disable verifies the current password with PasswordHasher.Verify. A wrong or missing password gets 401 with a generic body (code: wrong_password). Accounts that sign in through an identity provider have no local password and get 409 no_local_password. Code: backend/internal/application/auth/mfa_usecase.go, backend/internal/handler/auth/mfa_handler.go.
  • Throttle: each account gets 5 attempts per 15 minutes, stored in Redis so the limit holds across instances. The per-IP authRateLimit also applies to the route. Over the limit: 429 too_many_attempts.
  • Roles that require MFA: members whose role is in mfaRequiredRoles / mfaRequiredBusinessRoles get 403 mfa_required_by_role, checked against both the stored membership and the token's org role. If the membership lookup fails, the request is refused.
  • Transaction: GormMFARepository.DisableMFA deletes the secret and the backup codes in a single transaction. The secret is hard-deleted because user_id is UNIQUE, so a soft-deleted row would block re-enrolment.
  • Audit and notice: every attempt writes an mfa_disable audit entry, with a reason code when it fails. The password is never logged. The owner gets an email (FR/EN, through the existing async security mailer) and an in-app mfa_disabled notification.
  • Settings UI: MFADisableDialog asks for the password (validated with Zod), shows a wrong password as a field error and other refusals as an inline message. Roles that can't turn MFA off see no button.

Two defects found in the live pass and fixed here

  1. A wrong password was sent 4 times. The app-wide mutations.retry: 3 resent the request, so one typo used 4 of the 5 attempts and the next try got a 429. useDisableMFA now sets retry: false. The test fails without the fix ("called 4 times").
  2. Focus went to the close button, not the password field. useDismissableLayer's deferred initial focus overrode autoFocus, so typing went nowhere. This also caused mfaDisable.test.tsx to fail 1–3 of its 5 tests per run. The fix is 4 lines in the design system: when focus is already inside the panel, leave it there. No API change. DeclareIncidentModal and CreateAssetModal get the same fix, since they also use autoFocus inside a Modal.

Verification

$ go build ./... && go test ./... -count=1        # full backend suite
(every package ok, no FAIL lines)

$ go test ./internal/application/auth/ ./internal/handler/auth/ ./internal/infrastructure/repository/ ./internal/infrastructure/authmail/ -run 'MFA|Mfa|Disable|Mailer|Async' -count=1
ok  internal/application/auth           0.696s
ok  internal/handler/auth               1.448s
ok  internal/infrastructure/repository  0.027s
ok  internal/infrastructure/authmail    0.017s

$ npx vitest run src/features/settings src/features/auth src/shared src/features/notifications
 Test Files  32 passed (32)
      Tests  390 passed (390)
$ for i in 1..6; npx vitest run src/features/settings/__tests__/mfaDisable.test.tsx   -> 6 passed, six times
$ npx tsc --noEmit -p .   -> OK
$ npx eslint <touched files>   -> OK

Tests named in the acceptance criteria: TestDisableMFA_Success, TestDisableMFA_NotFound, TestDisableMFA_Unauthorized (wrong password), TestGormMFARepository_DisableMFA_RollsBackWhenTheSecondWriteFails, and the handler E2E in mfa_disable_e2e_test.go.

Live pass: the real MFAAccountPanel rendered in Chromium, with the API mocked by Playwright:

  • focus lands in the password field when the dialog opens
  • a wrong password produces 1 request and a field error after 32 ms (aria-invalid=true). Before the fix: 4 requests, about 7 s
  • the request body carries {password, locale}, and after success the dialog closes and the toast shows
  • a role that requires MFA sees no button
  • screenshot: .playwright-mcp/754-disable-wrong-password.png

SSO accounts (owner decision D-061)

An account that signs in through an identity provider has no local password to re-check. It now confirms with a current TOTP code from its authenticator app:

  • A wrong or missing code gets 401 wrong_code and counts against the same 5-per-15-minute budget. Backup codes are not accepted. A code never replaces the password of an account that has one.
  • If the key isn't wired, the request is refused, so the endpoint fails closed.
  • /auth/me returns has_password (a boolean). The dialog uses it to show the password field or a 6-digit code field, with matching copy. If that read fails, a wrong_code answer switches the dialog to the code field.
  • The deactivation email and in-app notice now say "identity confirmed" instead of claiming a password was checked.

Live run on real Postgres and Redis

Docker still wasn't reachable, so I used the local PostgreSQL 18 binaries (the stack targets 16) in a throwaway cluster on :55754, plus a throwaway redis-server on :56754. The branch server was booted from the repo root, so every migration ran. All of it has since been stopped.

Step Result
Admin (default policy), correct password 403 mfa_required_by_role
Missing password / wrong password 401 wrong_password / 401 wrong_password
Correct password 200; mfa_secrets 1→0 and mfa_backup_codes 8→0 (hard delete)
Again 404 not_enrolled
6th attempt, spanning two server processes 429 too_many_attempts; Redis key ratelimit:mfa-disable:<user>, TTL 900 s
Audit auth_audit_logs: 8 mfa_disable rows, each with its reason code; chained audit_events: create disable
Notices notifications row mfa_disabled (in_app); email logged with subject "Two-factor authentication was turned off…"
Password in logs 0 occurrences
SSO, in the real React app (logged in with password + TOTP, then users.password blanked) dialog opens on the code field with focus in it; 000000 gives "Code incorrect." and an audit wrong_code; the live code gives 200, both tables at 0, toast shown, panel back to "Activer le MFA"; request body {code, locale}

Screenshots: .playwright-mcp/754-sso-wrong-code.png, 754-sso-dialog-light.png, 754-sso-dialog-dark.png.

A new test, TestGormMFARepository_DisableMFA_Postgres (gated on DATABASE_URL), uses a real Postgres trigger to make the backup-code delete fail, then checks that the secret delete rolled back. It fails when the transaction is removed. With DATABASE_URL set, all 7 Postgres-gated tests pass, and so does the full go test ./....

PostgreSQL 16 (target version)

Docker was reachable again, so the whole run was repeated on postgres:16-alpine (16.15) and redis:7-alpine:

  • privileged admin: 403
  • missing or wrong password: 401 ×2
  • correct password: 200, secret 1→0, codes 8→0
  • again: 404
  • 6th attempt: 429 (Redis key present)
  • audit reasons, the chained create disable entry and the notification are all present
  • SSO path: has_password: false; password only → 401 wrong_code; wrong code → 401; valid code → 200, tables at 0

DATABASE_URL=<pg16> go test ./...: no failures, all 7 Postgres-gated tests PASS.

OpenAPI

docs/openapi.yaml now describes GET /auth/me (MeResponse, including has_password) and POST /auth/mfa/disable (DisableMFAInput, DisableMFAError with every error code), in commit 4c568540.

Follow-ups opened

Not done

  • frontend/src/types/openapi.generated.ts is not regenerated. On master it already drifts from the spec on unrelated routes (933 diff lines), and regenerating it here would pull those changes into this PR. The client keeps hand-written types for these two routes.
  • The react-hooks/set-state-in-effect lint error in MFAPolicyPanel.tsx:44 was already on master and is left as it was.

🤖 Generated with Claude Code

…754)

Disabling MFA ran two writes outside a transaction: a failure on the second
left backup codes alive after their secret was gone. Both now go in one
transaction, and the secret is hard-deleted so re-enrolment does not trip the
unique user_id constraint on a tombstone.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
An open session was enough to remove the second factor. The disable endpoint
now re-verifies the current password (wrong or missing: 401, generic body),
counts every attempt against a per-account budget of 5 per 15 minutes on top
of the per-IP limiter, refuses roles that login requires MFA for (403), writes
an mfa_disable audit entry with a reason code on failure, and notifies the
owner by email and in-app.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
The deferred initial focus moved focus to the first focusable element, the
close button, a frame after an autoFocus field had taken it. A user typing
straight away typed into nothing. It now stays put when focus is already in
the panel.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…#754)

A dialog asks for the current password, reports a wrong one on the field and
refusals it cannot fix plainly, and hides the button for roles that require
MFA. The mutation never retries: every request is a password guess counted
against the server's budget, and the app-wide retry of 3 spent four of five
attempts on one typo.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
The sqlite tests fail the second write through a GORM callback. This one,
gated on DATABASE_URL, makes Postgres refuse it with a trigger and checks the
secret delete rolls back, the tenant scope holds, and re-enrolment works after
a hard delete.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
#754)

An account that signs in through an identity provider has no local password,
so the disable endpoint refused it outright. It now confirms with a current
TOTP code instead (401 wrong_code otherwise, same per-account budget). Backup
codes do not count, and a code never replaces the password of an account that
has one. Without the key wired, the refusal stays: it fails closed.

/auth/me returns has_password so the client asks for the right proof, and the
deactivation notice no longer claims a password was confirmed. Owner decision
D-061.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
… MFA off (#754)

The dialog reads has_password when it opens and shows either the password
field or a six-digit code field, with copy that matches. If it guessed wrong
(the read failed), a wrong_code answer to a password switches it to the code.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
…a code (#754)

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
Neither route was in the spec, so has_password and the disable body and error
codes existed only in the code. The generated client types are not refreshed
here: the committed file already drifts from the spec on unrelated routes.

Signed-off-by: alex-dembele <alexandredembele16@gmail.com>
@alex-dembele alex-dembele linked an issue Sep 30, 2026 that may be closed by this pull request
@alex-dembele
alex-dembele merged commit e28925b into master Oct 1, 2026
13 of 30 checks passed
@alex-dembele
alex-dembele deleted the 754-securityauth-disabling-mfa-does-not-re-verify-the-password branch October 1, 2026 12:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

security(auth): disabling MFA does not re-verify the password

1 participant