- 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>
343 lines
15 KiB
Markdown
343 lines
15 KiB
Markdown
# 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:
|
|
|
|
```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)
|
|
- **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`:
|
|
|
|
```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 a **distinct application error code** to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
|
|
```json
|
|
{
|
|
"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
|