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
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.
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.
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.
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`:
- **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"
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.
- Return `403` with a **distinct application error code** to avoid collision with generic Spring Security 403s (which get caught by global error handlers):
- 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
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
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)