Covers self-service password reset via Logto Experience API, TOTP + backup code MFA with per-tenant enforcement via JWT claims, and a server handoff document for cameleer-server MFA enrollment. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
13 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
Backend Changes
None. This is entirely between the custom sign-in UI and Logto's Experience API. The ForgotPassword email template is already configured in EmailConnectorService.
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)
- "Use backup code instead" link — switches to a single text input for the backup code
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{ "error": "MFA_ENROLLMENT_REQUIRED", "message": "Your organization requires multi-factor authentication" } - Exempt paths:
/api/tenant/mfa/*(enrollment endpoints),/api/config,/api/me
- Return
- Frontend catches 403
MFA_ENROLLMENT_REQUIRED-> redirects to Settings page with MFA section highlighted
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
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 |
ui/sign-in/src/experience-api.ts |
New functions: forgotPassword(), verifyTotp(), verifyBackupCode() |
ui/src/pages/SettingsPage.tsx |
MFA enrollment section, MFA requirement toggle |
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 (check JWT claim + tenant setting) |
| 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