- 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>
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:
- Self-service password reset — "Forgot password?" flow in the custom sign-in UI, using Logto's Experience API and the already-configured
ForgotPasswordemail template. - 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:
- Email entry — user enters their email, clicks "Send code"
- 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:
init -> verifyPassword -> identify -> submit- If submit returns
mfa_factor_not_satisfied-> UI shows TOTP input - 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:
- Backend generates TOTP secret -> returns secret + QR code URI
- UI renders QR code (lightweight library, e.g.,
qrcode.react) - User scans with authenticator app, enters 6-digit verification code to confirm
- On success -> backend generates 10 backup codes, displays them once
- User must copy/download before dismissing (checkbox: "I've saved these codes")
- Force token refresh so
mfa_enrolledJWT 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 codesGET /api/users/{userId}/mfa-verifications— list enrolled factorsDELETE /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/settingswith{ "mfaRequired": true/false }
Backend Enforcement
A Spring Security filter that runs after JWT validation:
- Extract
mfa_enrolledclaim from JWT - Look up the user's tenant -> check
settings.mfaRequired - If tenant requires MFA and
mfa_enrolledisfalse:- Return
403with 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_REQUIREDas 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
- Return
- Frontend intercepts 403 responses and checks for
APP_MFA_REQUIREDspecifically (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_enrolledfrom JWT (same token, same parsing asrolesclaim) - Query tenant MFA policy: SaaS exposes
GET /api/tenant/{slug}/mfa-policyreturning{ "mfaRequired": true/false }— server caches with 5-minute TTL - Same enforcement logic: if required and not enrolled -> 403 with
APP_MFA_REQUIREDerror 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:
- API contract — Logto Management API endpoints for MFA enrollment/unenrollment (exact URLs, request/response payloads, M2M token scope requirements)
- UX requirements — QR code display, backup code download, verification step, enrollment/removal flows
- Enforcement model — reading
mfa_enrolledfrom JWT, queryingGET /api/tenant/{slug}/mfa-policyfor tenant requirement, 403 response format - 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