docs: add password reset and MFA design spec
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>
This commit is contained in:
323
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
323
docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md
Normal file
@@ -0,0 +1,323 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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:
|
||||
|
||||
```json
|
||||
{
|
||||
"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.
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
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`:
|
||||
|
||||
```json
|
||||
{ "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 `{ "error": "MFA_ENROLLMENT_REQUIRED", "message": "Your organization requires multi-factor authentication" }`
|
||||
- **Exempt paths**: `/api/tenant/mfa/*` (enrollment endpoints), `/api/config`, `/api/me`
|
||||
4. Frontend catches 403 `MFA_ENROLLMENT_REQUIRED` -> redirects to Settings page with MFA section highlighted
|
||||
|
||||
### 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
|
||||
|
||||
### 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` |
|
||||
| `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
|
||||
Reference in New Issue
Block a user