Files
cameleer-saas/docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
hsiegeln 67f7d634c9 docs: refine password reset + MFA spec from review feedback
- Add security notification email after password reset (warns MFA
  was not required, recommends enabling it)
- Use distinct APP_MFA_REQUIRED error code + X-Cameleer-Error header
  for MFA enforcement 403s to avoid collision with generic access denied
- Make backup code fallback prominent in MFA verification UI (visible
  secondary action, not a subtle link)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:26:55 +02:00

15 KiB

Password Reset & Multi-Factor Authentication Design

Date: 2026-04-26 Status: Approved Scope: Self-service password reset, TOTP MFA with backup codes, per-tenant MFA enforcement

Overview

Add two missing auth capabilities to the Cameleer SaaS platform:

  1. Self-service password reset — "Forgot password?" flow in the custom sign-in UI, using Logto's Experience API and the already-configured ForgotPassword email template.
  2. Multi-factor authentication — TOTP (authenticator app) as primary factor, backup codes for recovery. Per-tenant enforcement (tenant admins control whether MFA is required for their org) plus optional opt-in for any user.

Decisions

Decision Choice Rationale
MFA methods TOTP + backup codes Universal, no extra infra. WebAuthn can be added later.
MFA policy at Logto level UserControlled Per-tenant enforcement is application-layer; Logto stays permissive.
Enforcement mechanism JWT claim (mfa_enrolled) Stateless, works for both SaaS and cameleer-server, extends existing custom JWT script.
MFA during password reset Not required Email verification code is sufficient proof of identity. Requiring TOTP risks deadlock if user lost both.
Enrollment location (SaaS) Tenant portal Settings page Natural home next to existing password change.
Enrollment location (server) Cameleer-server UI (handoff doc) Regular org members interact with server UI day-to-day.
MFA requirement storage settings JSONB on TenantEntity No migration needed.

1. Password Reset Flow

UI Changes (ui/sign-in/src/SignInPage.tsx)

Add a "Forgot password?" link below the password field on the sign-in form. New mode forgotPassword with two sub-steps:

  1. Email entry — user enters their email, clicks "Send code"
  2. Code + new password — 6-digit verification code (reuse existing OTP input pattern from registration) + new password + confirm password

The "Forgot password?" link is hidden when no email connector is configured — check the same sign-in experience config already fetched at page load.

Experience API Flow (ui/sign-in/src/experience-api.ts)

PUT  /api/experience
     { interactionEvent: 'ForgotPassword' }

POST /api/experience/verification/verification-code
     { identifier: { type: 'email', value: email }, interactionEvent: 'ForgotPassword' }

POST /api/experience/verification/verification-code/verify
     { identifier: { type: 'email', value: email }, verificationId, code }

POST /api/experience/identification
     { verificationId }

POST /api/experience/profile
     { type: 'password', value: newPassword }

POST /api/experience/submit
     -> redirect to sign-in page

Security Notification Email

After a successful password reset, Logto sends a confirmation redirect — but no security awareness email. The SaaS backend sends a separate security notification email to the user's address with:

  • Subject: "Your Cameleer password was reset"
  • Body: confirms the password was changed, states that MFA was not required for this change, and recommends enabling MFA if not already enrolled
  • Includes a timestamp and "If this wasn't you, contact your administrator immediately"

This is triggered by the custom sign-in UI after the Experience API submit succeeds — a POST /api/password-reset-notification call to the SaaS backend with the user's email. The backend sends the email via the configured SMTP connector. The endpoint is unauthenticated (the user hasn't signed in yet) but rate-limited and accepts only email addresses that exist in Logto.

Backend Changes

Minimal. The password reset flow itself is entirely between the custom sign-in UI and Logto's Experience API. The ForgotPassword email template is already configured in EmailConnectorService. The only new backend work is the security notification endpoint above.

2. MFA Configuration (Logto Bootstrap)

Bootstrap Changes (docker/logto-bootstrap.sh)

Add MFA configuration to the existing Phase 8c sign-in experience patch:

{
  "mfa": {
    "factors": ["Totp", "BackupCode"],
    "policy": "UserControlled"
  }
}

This is one additional field in the existing PATCH /api/sign-in-exp call — not a separate bootstrap phase.

Custom JWT Script Extension (Phase 7b)

Extend the existing getCustomJwtClaims script to include an mfa_enrolled claim. Logto's custom JWT context provides context.user.mfaVerificationFactors — an array of the user's enrolled MFA factors.

const getCustomJwtClaims = async ({ token, context, environmentVariables }) => {
  const roleMap = { owner: "server:admin", operator: "server:operator", viewer: "server:viewer" };
  const roles = new Set();
  if (context?.user?.organizationRoles) {
    for (const orgRole of context.user.organizationRoles) {
      const mapped = roleMap[orgRole.roleName];
      if (mapped) roles.add(mapped);
    }
  }
  if (context?.user?.roles) {
    for (const role of context.user.roles) {
      if (role.name === "saas-vendor") roles.add("server:admin");
    }
  }

  // MFA enrollment status
  const mfaFactors = context?.user?.mfaVerificationFactors || [];
  const mfaEnrolled = mfaFactors.some(f => f.type === 'Totp');

  const claims = {};
  if (roles.size > 0) claims.roles = [...roles];
  claims.mfa_enrolled = mfaEnrolled;
  return claims;
};

Every JWT now carries mfa_enrolled: true/false, readable by both SaaS backend and cameleer-server.

3. MFA Verification at Sign-in

How Logto Signals MFA Is Needed

After password verification + identification, POST /api/experience/submit returns an error with code mfa_factor_not_satisfied instead of a redirect URL. The custom sign-in UI intercepts this and prompts for TOTP.

UI Changes (ui/sign-in/src/SignInPage.tsx)

New mode mfaVerify — shown when submit returns mfa_factor_not_satisfied:

  • 6-digit TOTP code input (reuse existing OTP input pattern)
  • Prominent backup code fallback — not a subtle link. Below the TOTP input, a clearly visible secondary action: "Lost your device? Use a backup code" styled as a distinct button or card (not a footnote-sized link). Users in a panic skip small text. The backup code view shows a single text input with clear instructions: "Enter one of your 10 backup codes"

Experience API Flow

TOTP verification:

POST /api/experience/verification/totp/verify
     { code: '123456' }
     -> returns { verificationId }

POST /api/experience/identification
     { verificationId }

POST /api/experience/submit
     -> redirectTo

Backup code verification:

POST /api/experience/verification/backup-code/verify
     { code: 'abc123def456' }
     -> returns { verificationId }

POST /api/experience/identification
     { verificationId }

POST /api/experience/submit
     -> redirectTo

Modified Sign-in Flow

The existing signIn() function (init -> verifyPassword -> identify -> submit) becomes:

  1. init -> verifyPassword -> identify -> submit
  2. If submit returns mfa_factor_not_satisfied -> UI shows TOTP input
  3. User enters code -> verify TOTP -> identify -> submit -> redirect

Error Handling

Error Message
Invalid TOTP code "Invalid code, please try again"
Backup code already used "This backup code has already been used"
All backup codes exhausted "No backup codes remaining. Contact your administrator."

4. MFA Enrollment (Tenant Portal)

Settings Page UI (ui/src/pages/SettingsPage.tsx)

Add an "MFA" section to the existing Settings page (next to password change).

Not enrolled state:

  • Description: "Protect your account with two-factor authentication"
  • "Set up authenticator app" button
  • Enrollment flow:
    1. Backend generates TOTP secret -> returns secret + QR code URI
    2. UI renders QR code (lightweight library, e.g., qrcode.react)
    3. User scans with authenticator app, enters 6-digit verification code to confirm
    4. On success -> backend generates 10 backup codes, displays them once
    5. User must copy/download before dismissing (checkbox: "I've saved these codes")
    6. Force token refresh so mfa_enrolled JWT claim updates immediately

Already enrolled state:

  • "Authenticator app configured" with green status indicator
  • "Regenerate backup codes" button (new set of 10, invalidates old)
  • "Remove MFA" button with confirmation dialog + password re-entry

Backend Endpoints (new, in TenantPortalController)

Method Path Purpose
GET /api/tenant/mfa/status Check current user's MFA enrollment status
POST /api/tenant/mfa/totp/setup Generate TOTP secret via Logto Management API -> return secret + QR code
POST /api/tenant/mfa/totp/verify Verify TOTP code + bind to user via Management API
POST /api/tenant/mfa/backup-codes Generate backup codes via Management API
DELETE /api/tenant/mfa/totp Remove TOTP factor (requires password confirmation)

All proxy to Logto's Management API via LogtoManagementClient:

  • POST /api/users/{userId}/mfa-verifications — create TOTP or backup codes
  • GET /api/users/{userId}/mfa-verifications — list enrolled factors
  • DELETE /api/users/{userId}/mfa-verifications/{verificationId} — remove a factor

Team Management (ui/src/pages/TeamPage.tsx)

Add a "Reset MFA" action for team members — allows tenant owners/operators to remove MFA enrollment for a locked-out user. Calls DELETE /api/users/{userId}/mfa-verifications/{verificationId} via a new backend endpoint:

Method Path Purpose
DELETE /api/tenant/users/{userId}/mfa Remove all MFA factors for a team member

5. Per-Tenant MFA Enforcement

Tenant Setting

Stored in the existing settings JSONB column on TenantEntity:

{ "mfaRequired": true }

Default is absent/false — MFA is optional, users can still opt in.

Tenant Admin Toggle (ui/src/pages/SettingsPage.tsx)

Visible only to tenant admins (owner/operator role):

  • Toggle: "Require MFA for all organization members"
  • Enabling shows confirmation: "Members without MFA will be prompted to enroll on their next sign-in. Are you sure?"
  • Calls PATCH /api/tenant/settings with { "mfaRequired": true/false }

Backend Enforcement

A Spring Security filter that runs after JWT validation:

  1. Extract mfa_enrolled claim from JWT
  2. Look up the user's tenant -> check settings.mfaRequired
  3. If tenant requires MFA and mfa_enrolled is false:
    • Return 403 with a distinct application error code to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
      {
        "error": "APP_MFA_REQUIRED",
        "code": "mfa_enrollment_required",
        "message": "Your organization requires multi-factor authentication"
      }
      
    • The response includes a custom header X-Cameleer-Error: APP_MFA_REQUIRED as a belt-and-suspenders signal — frontends can check either the body or the header
    • Exempt paths: /api/tenant/mfa/* (enrollment endpoints), /api/config, /api/me
  4. Frontend intercepts 403 responses and checks for APP_MFA_REQUIRED specifically (not all 403s) -> redirects to Settings page with MFA section highlighted and an inline banner explaining the requirement

Cameleer-Server Enforcement (via handoff doc)

  • Read mfa_enrolled from JWT (same token, same parsing as roles claim)
  • Query tenant MFA policy: SaaS exposes GET /api/tenant/{slug}/mfa-policy returning { "mfaRequired": true/false } — server caches with 5-minute TTL
  • Same enforcement logic: if required and not enrolled -> 403 with APP_MFA_REQUIRED error code (same format as SaaS backend)

Token Refresh After Enrollment

When a user completes MFA enrollment in the tenant portal, the frontend calls getAccessToken() from the Logto SDK with a forced refresh. This gets a new JWT with mfa_enrolled: true, and subsequent requests pass enforcement.

Edge Case: Admin Enables Enforcement While Users Are Active

Existing sessions have mfa_enrolled: false. On next API call, backend returns 403. Frontend redirects to enrollment. After enrolling + token refresh, they're unblocked. No forced logout needed.

6. Backup Codes

Generation

10 codes generated by Logto's Management API (POST /api/users/{userId}/mfa-verifications with { type: "BackupCode" }). Logto generates the codes.

Display

Shown exactly once, immediately after TOTP enrollment succeeds:

  • Grid of 10 codes in monospace font
  • "Copy all" button
  • "Download as .txt" button
  • Dialog cannot be dismissed until user checks "I've saved my backup codes"

Regeneration

Available in Settings page for enrolled users. "Regenerate backup codes" creates a new set of 10, invalidates all previous codes. Same display/acknowledgment flow.

Low-Code Warning

When a user signs in with a backup code and fewer than 3 remain, the sign-in UI shows a warning after successful auth: "You have N backup codes remaining."

Exhaustion Recovery

If all backup codes are used and the user can't access their authenticator app, a tenant admin removes their MFA via the "Reset MFA" action on the Team page (DELETE /api/tenant/users/{userId}/mfa).

7. Server Handoff Document

A separate spec file to be delivered alongside this design, covering:

  1. API contract — Logto Management API endpoints for MFA enrollment/unenrollment (exact URLs, request/response payloads, M2M token scope requirements)
  2. UX requirements — QR code display, backup code download, verification step, enrollment/removal flows
  3. Enforcement model — reading mfa_enrolled from JWT, querying GET /api/tenant/{slug}/mfa-policy for tenant requirement, 403 response format
  4. Error states — already enrolled, invalid TOTP, backup code exhaustion, removal while enforcement is active

Summary of Changes by Component

Component Changes
ui/sign-in/src/SignInPage.tsx New modes: forgotPassword, mfaVerify (with prominent backup code fallback)
ui/sign-in/src/experience-api.ts New functions: forgotPassword(), verifyTotp(), verifyBackupCode(), notifyPasswordReset()
ui/src/pages/SettingsPage.tsx MFA enrollment section, MFA requirement toggle, APP_MFA_REQUIRED 403 interceptor
ui/src/pages/TeamPage.tsx "Reset MFA" action for team members
docker/logto-bootstrap.sh MFA factors in Phase 8c, mfa_enrolled claim in Phase 7b
TenantPortalController.java MFA endpoints (setup, verify, backup codes, status, remove)
TenantPortalService.java MFA business logic proxying to LogtoManagementClient
LogtoManagementClient.java New methods for MFA Management API calls
SecurityConfig.java or new filter MFA enforcement filter (APP_MFA_REQUIRED error code + X-Cameleer-Error header)
New: password reset notification POST /api/password-reset-notification — security email after reset (unauthenticated, rate-limited)
New: server handoff doc MFA API contract + UX + enforcement spec for cameleer-server team

Out of Scope

  • WebAuthn / passkey support (future addition)
  • SMS-based MFA
  • Password policies beyond Logto defaults (complexity, history, expiry)
  • Passwordless authentication
  • Social login / OAuth providers
  • Account lockout configuration