Skip to content

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

Description

@alex-dembele

Issue

In backend/internal/application/auth/mfa_usecase.go, disabling MFA
does not prompt for the user's current password (TODO: Verify password before disabling remains in the code).

Impact

If a session remains active on a workstation (shared PC, failure to lock the screen),
anyone can disable the account's MFA with a single click, without re-authentication.
This constitutes a critical security control bypass (loss of an authentication
factor without proof of possession of the first factor).

Expected Fix

  • Require password re-entry (or even MFA re-verification) before
    disabling MFA.
  • Add an E2E test that fails if deactivation succeeds without a password.
  • Consider sending an email notification to the user when their MFA is disabled.

Severity

Critical — authentication bypass.

Activity

  1. added this to the trust-v1 milestone on Sep 22, 2026
  2. added theissue type on Sep 22, 2026
  3. alex-dembele commented on Sep 25, 2026

    @alex-dembele
    MemberAuthor

    audit — 2026-09-25

    Verified: backend/internal/application/auth/mfa_usecase.go:240 still has // TODO: Verify password before disabling (requires user repo + password verification). The use case deletes the TOTP secret (DisableMFA), then the backup codes (DeleteBackupCodes), as two writes outside a transaction (CLAUDE.md rule 7). If the second write fails, the backup codes survive the deactivation.

    Criteria: numbered here so the issue meets the Ready definition.

    1. POST disable-MFA requires the current password (PasswordHasher.Verify). A wrong or missing password returns 401 with a generic body, and each attempt counts against the per-account throttle (security(auth): one rate-limit bucket for all auth routes locks users out, login timing reveals registered addresses, no per-account throttle #688).
    2. The TOTP secret and the backup codes are deleted in one transaction. A test forces the second write to fail and proves that nothing was deleted.
    3. The deactivation writes an entry to the chained audit trail and notifies the user (email via feat(notifications): implement a proper notification system (replace NoOpNotifier) #757, in-app until then).
    4. If the member's role requires MFA (mfaRequiredRoles / mfaRequiredBusinessRoles), deactivation is refused with 403.
    5. Tests: TestDisableMFA_Success, TestDisableMFA_NotFound, TestDisableMFA_Unauthorized (wrong password), plus the transactional rollback test from criterion 2.

    Next: Sprint 1 of the launch board, first backend item after #807.
    Blocked on: nothing


    Generated by Claude Code

  4. added
    priority:P0Blocking: nothing else ships until this closes
    status:readyMeets the ready definition
    trustEvidence a buyer's CISO tests before features matter
    on Sep 25, 2026
  5. 3 remaining items

  6. alex-dembele commented on Sep 30, 2026

    @alex-dembele
    MemberAuthor

    backend-go + frontend-react — 2026-09-30

    Done — PR #848, 4 commits on 754-securityauth-disabling-mfa-does-not-re-verify-the-password:

    • eae19ebf gorm_mfa_repository.go: DisableMFA deletes the secret and the backup codes in one transaction. The secret is hard-deleted, because the UNIQUE user_id would otherwise block re-enrolment.
    • 48d0b517 mfa_usecase.go / mfa_handler.go / main.go: the endpoint re-checks the password (401 wrong_password), limits each account to 5 attempts per 15 min in Redis on top of the per-IP authRateLimit (429), refuses roles that require MFA (403), returns 409 for SSO accounts with no local password, writes an mfa_disable audit entry with reason codes, and sends an email (reset_mailer.go) plus an in-app mfa_disabled notification.
    • b3d9016f shared/ds/useDismissableLayer.ts: the Modal no longer takes focus away from an autoFocus field (it used to move it to the close button).
    • 6f0eb5bd MFADisableDialog.tsx, useMfa.ts, MFAPolicyPanel.tsx: the Settings dialog. The mutation uses retry: false, because the global retry of 3 turned one wrong password into 4 requests.

    Verified — full go test ./... -count=1: no failures. MFA/Disable/Mailer package tests: ok. vitest settings+auth+shared+notifications: 32 files, 390 passed. mfaDisable.test.tsx: green 6 runs out of 6 (it was flaky before the DS fix). tsc and eslint on touched files: OK. Both new tests fail when their fix is reverted. Live Chromium harness on the real MFAAccountPanel with the API mocked: focus lands on the password field, a wrong password produces 1 request and a field error in 32 ms, success closes the dialog, and a privileged role sees no button.

    Criteria — 1 ✅ · 2 ✅ (rollback test on SQLite) · 3 ✅ (audit + email + in-app) · 4 ✅ · 5 ✅

    Next — owner review of #848. Before merge: one run against real Postgres and Redis (the Unscoped delete inside a transaction, the Redis throttle). Docker wasn't reachable from this session.

    Blocked on — nothing (@owner decides the merge). Open design question, not blocking: SSO accounts can't disable MFA because they have no password to re-prove. A TOTP re-check would count as an auth design change, so it would need an escalation.

  7. alex-dembele commented on Sep 30, 2026

    @alex-dembele
    MemberAuthor

    backend-go + frontend-react — 2026-09-30 (follow-up)

    Done — The two remainders from the previous comment, 4 more commits on #848:

    • e125f6c5 gorm_mfa_repository_pg_test.go: rollback proven on a real Postgres. A trigger makes the backup-code delete fail, and the test checks the secret delete rolled back. Gated on DATABASE_URL.
    • 94d4dacc SSO accounts (no local password) confirm with a current TOTP code: mfa_usecase.go (WithTOTPKey, ErrMFADisableCodeIncorrect), mfa_handler.go (401 wrong_code), handler.go (/auth/me → has_password), main.go. The email and in-app copy no longer mention a password.
    • eb5f8e3f MFADisableDialog.tsx: shows a password field or a 6-digit code field depending on has_password, with a fallback when the server answers wrong_code.
    • 9a96d2f2 docs/DECISIONS.md: D-061 (owner decision, 2026-09-30).

    Verified — Local Postgres 18 (throwaway cluster on :55754) and Redis (:56754), branch server booted from the repo root:

    • default policy: 403 mfa_required_by_role
    • missing or wrong password: 401 ×2
    • correct password: 200, mfa_secrets 1→0, mfa_backup_codes 8→0
    • again: 404
    • 6th attempt: 429 across two server processes (Redis key, TTL 900)
    • 8 audit rows with their reasons; one chained create disable in audit_events; one in-app notification plus a logged email; the password appears in no log

    SSO path in the real React UI: the dialog opens on the code field with focus in it; a wrong code gives 401 plus an audit wrong_code; the correct code gives 200 and both tables at 0. DATABASE_URL=… go test ./...: no failures, and all 7 Postgres-gated tests PASS. The new Postgres test fails when the transaction is removed. vitest 32 files / 394 tests OK, mfaDisable.test.tsx 10/10 on 4 consecutive runs, tsc and eslint OK. Throwaway services are stopped.

    Criteria — 1 ✅ · 2 ✅ (SQLite + real Postgres) · 3 ✅ · 4 ✅ · 5 ✅

    Next — Owner review and merge of #848. Possible follow-up issue: TOTP replay protection (a code can be replayed within about 90 s, at login as well as on disable).

    Blocked on — nothing

  8. alex-dembele commented on Sep 30, 2026

    @alex-dembele
    MemberAuthor

    backend-go — 2026-09-30 (remainders)

    Done — The run on PostgreSQL 16.15 is repeated and green, including the SSO path. docs/openapi.yaml now documents /auth/me and /auth/mfa/disable (4c568540). TOTP replay is split out into #849 (PR #851) and the SSO "Change password" card into #850 (PR #852), both stacked on #848.
    Verified — DATABASE_URL=<pg16> go test ./...: no failures, 7 Postgres tests PASS. Live HTTP on PG 16: 403 / 401 / 401 / 200 / 404 / 429; SSO wrong_code ×2 then 200.
    Criteria — 1 ✅ · 2 ✅ · 3 ✅ · 4 ✅ · 5 ✅
    Next — Owner review: merge #848 first, then #851 and #852.
    Blocked on — nothing. openapi.generated.ts has pre-existing drift and is not regenerated here.

  9. added a commit that references this issue on Oct 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:backendGo, /internal, /pkgarea:securityCybersecurity and threat intelligenceauthenticationAuthenticationcriticalpriority:P0Blocking: nothing else ships until this closespriority:P0-criticalProduction broken or exposed — work nowsecurityProduct security posturestatus:in-reviewPR opentier:0-trustTrust: security, isolation, evidence integritytrustEvidence a buyer's CISO tests before features mattertype:securitySecurity defect or hardening

    Type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions