feat(settings): regenerate 2FA recovery codes UI (BUNYIP-355 part 1) #355

Merged
nrupard merged 3 commits from feat/BUNYIP-355-recovery-codes-ui into main 2026-07-10 16:54:51 +02:00
Owner

Part 1 of BUNYIP-355: surface the existing (but UI-less) recovery-code regeneration in the /settings 2FA card. Backend-only endpoint POST /auth/2fa/recovery-codes already exists (password-gated, invalidates old codes, audits TwoFactorRecoveryCodesRegenerated); this wires the front end.

What's here

  • The 2FA card gains a "Regenerate recovery codes" action, shown to every 2FA-enabled user - admins included, since they cannot disable 2FA and so depend most on working backup codes.
  • New /settings/2fa/recovery-codes route: GET renders a password-confirm form (mirroring the API's password gate); POST calls auth_api::regenerate_recovery_codes and shows the 8 new codes once, reusing the setup flow's codes panel. Errors re-render the form with the message.

Tests

The codes-result view renders every code + the invalidation warning; the form surfaces an error only when one is passed.

Verification

just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets; bunyip-web 91 tests incl. the 2 new). The live regen round trip runs on a dev stack.

AC status

  • /settings 2FA card exposes "Regenerate recovery codes" (2FA-enabled only); collects the password, invalidates the old codes, shows the 8 new codes once. (AC 1)
  • Remaining ACs (re-key flow, no-lockout guarantee, re-key audit, staging tests) land in part 2.

Part 2 (follow-up)

The step-up-gated authenticator re-key: a staged pending secret (nullable pending_* columns on user_totp) so the active authenticator keeps working until the new one is confirmed, plus begin_rekey / confirm_rekey endpoints and the web UI. Speced on BUNYIP-355.

#BUNYIP-355

Part 1 of BUNYIP-355: surface the existing (but UI-less) recovery-code regeneration in the `/settings` 2FA card. Backend-only endpoint `POST /auth/2fa/recovery-codes` already exists (password-gated, invalidates old codes, audits `TwoFactorRecoveryCodesRegenerated`); this wires the front end. ## What's here - The 2FA card gains a "Regenerate recovery codes" action, shown to every 2FA-enabled user - **admins included**, since they cannot disable 2FA and so depend most on working backup codes. - New `/settings/2fa/recovery-codes` route: GET renders a password-confirm form (mirroring the API's password gate); POST calls `auth_api::regenerate_recovery_codes` and shows the 8 new codes once, reusing the setup flow's codes panel. Errors re-render the form with the message. ## Tests The codes-result view renders every code + the invalidation warning; the form surfaces an error only when one is passed. ## Verification `just check-container` green (fmt + clippy `-D warnings` + `cargo test --workspace --all-targets`; bunyip-web 91 tests incl. the 2 new). The live regen round trip runs on a dev stack. ## AC status - [x] `/settings` 2FA card exposes "Regenerate recovery codes" (2FA-enabled only); collects the password, invalidates the old codes, shows the 8 new codes once. (AC 1) - Remaining ACs (re-key flow, no-lockout guarantee, re-key audit, staging tests) land in part 2. ## Part 2 (follow-up) The step-up-gated authenticator re-key: a staged pending secret (nullable `pending_*` columns on `user_totp`) so the active authenticator keeps working until the new one is confirmed, plus `begin_rekey` / `confirm_rekey` endpoints and the web UI. Speced on BUNYIP-355. #BUNYIP-355
feat(settings): regenerate 2FA recovery codes from the settings page (BUNYIP-355 part 1)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 6s
Check / fmt + clippy + build + tests (pull_request) Successful in 11m44s
e6cda1aef1
Surfaces the existing (but UI-less) recovery-code regeneration in the /settings 2FA card. The API endpoint POST /auth/2fa/recovery-codes already exists (password-gated, invalidates the old codes, audits TwoFactorRecoveryCodesRegenerated); this wires the front end.

- The 2FA card gains a "Regenerate recovery codes" action, shown to every 2FA-enabled user - admins included, since they cannot disable 2FA and so depend most on working backup codes.
- New /settings/2fa/recovery-codes route: GET renders a password-confirm form (mirroring the API's password gate); POST calls the existing auth_api::regenerate_recovery_codes and shows the 8 new codes once, reusing the setup flow's codes panel. Errors re-render the form with the message.

Tests: the codes-result view renders every code + the invalidation warning; the form surfaces an error only when one is passed.

Verified: just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets; bunyip-web 91 tests incl. the 2 new). The live regen round trip runs on a dev stack.

Part 2 (follow-up, BUNYIP-355): the step-up-gated authenticator re-key (staged pending secret so the active authenticator keeps working until the new one is confirmed) - schema + begin_rekey/confirm_rekey endpoints + web UI.

#BUNYIP-355
feat(2fa): step-up-gated authenticator re-key with a staged pending secret (BUNYIP-355 part 2)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 6s
Check / fmt + clippy + build + tests (pull_request) Successful in 9m17s
f39d1f54eb
Lets a 2FA-enabled user reset their authenticator (e.g. a new phone) from /settings without ever losing access to the currently-active one. The new secret is staged and only becomes active once the user confirms a code from it.

Domain:
- Migration: nullable pending_* columns on user_totp (pending_encrypted_secret, pending_nonce, pending_key_version, pending_created_at). New nullable columns; no query! touches user_totp, so the offline sqlx cache is unaffected.
- TotpService::begin_rekey generates a fresh secret and stages it in the pending columns WITHOUT touching the active secret or `verified` - the old authenticator keeps working. confirm_rekey verifies a code against the pending secret, then TotpRepository::promote_pending_secret atomically swaps it into the active columns (a guarded UPDATE that only fires while a complete pending secret is present, so it can never null out the active secret or double-promote) and regenerates recovery codes.
- Audit actions TwoFactorRekeyStarted / TwoFactorRekeyed.

API:
- POST /v1/auth/2fa/rekey (begin_rekey): step-up gated exactly like disable_2fa (password + a fresh TOTP/recovery code), so a hijacked or trusted-device session cannot silently reset the second factor. Returns the new otpauth URI + secret.
- POST /v1/auth/2fa/rekey/confirm (confirm_rekey): verifies a code from the new authenticator, promotes the pending secret, returns fresh recovery codes. Both steps audited.

Web:
- The /settings 2FA card gains "Reset authenticator app" (admins included). Flow: step-up form -> new-secret QR (twofa_setup_view refactored into the shared, parameterized twofa_qr_view) -> confirm code -> new recovery codes shown once. A wrong confirm code re-renders a bare code form (the pending secret is still staged) with a restart link; the active authenticator is never disturbed on failure.

Tests: the re-key step-up form asks for password + current code; the QR view points its confirm form at the rekey-confirm route; the code form offers restart + surfaces errors.

Verified: just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets; bunyip-web 94 tests, bunyip-oidc 266, all crates). Migration smoke on postgres:18.2-alpine3.23: all migrations apply, pending_* columns present with the right types. The end-to-end re-key (staging, step-up, swap, no-lockout) runs on the dev-sso stack; the staging/promote SQL is compile- and migration-checked here.

Completes BUNYIP-355 with part 1 (recovery-code regeneration UI).

#BUNYIP-355
fix(2fa): revoke trusted devices and rate-limit on authenticator re-key confirm (BUNYIP-355)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 7s
Check / fmt + clippy + build + tests (pull_request) Successful in 8m36s
Create release / Create release from merged PR (pull_request) Has been skipped
f31f9a1d01
Review follow-ups on the re-key flow.

- Revoke trusted devices when a re-key completes (confirm_rekey), mirroring disable_2fa. A trusted-device cookie bypasses the 2FA prompt; re-keying (especially the lost/compromised-device recovery path) should drop that old trust context rather than let it linger against the new second factor.
- Rate-limit confirm_rekey per account (TWO_FACTOR_VERIFY_FAILURES), the same guard the login 2FA verify uses. Confirm verifies a TOTP with no password gate, so cap the attempts as defense-in-depth.

Verified: just check-container green (fmt + clippy -D warnings + cargo test --workspace --all-targets).

#BUNYIP-355
nrupard deleted branch feat/BUNYIP-355-recovery-codes-ui 2026-07-10 16:54:51 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!355
No description provided.