Files
cameleer-saas/docs/superpowers/plans/2026-04-26-password-reset-mfa-plan.md

2121 lines
71 KiB
Markdown
Raw Normal View History

# Password Reset & MFA Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add self-service password reset and TOTP MFA with per-tenant enforcement to Cameleer SaaS.
**Architecture:** Password reset uses Logto Experience API from the custom sign-in UI (no backend changes). MFA enrollment uses Logto Management API via new backend endpoints. Per-tenant enforcement via `mfa_enrolled` JWT claim + `settings.mfaRequired` tenant config. Backup codes for recovery.
**Tech Stack:** Logto Experience API, Logto Management API, Spring Security filter, React 19, `qrcode.react`, `@cameleer/design-system`
**Spec:** `docs/superpowers/specs/2026-04-26-password-reset-mfa-design.md`
---
## File Map
### New Files
| File | Responsibility |
|------|---------------|
| `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java` | Spring filter: checks `mfa_enrolled` JWT claim against tenant `settings.mfaRequired` |
| `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java` | Unauthenticated endpoint for password reset security email |
| `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java` | Sends security notification email via SMTP |
| `src/main/resources/email-templates/password-reset-notification.html` | Branded email: "Your password was reset" |
| `docs/superpowers/specs/2026-04-26-server-mfa-handoff.md` | Handoff doc for cameleer-server MFA enrollment |
### Modified Files
| File | Changes |
|------|---------|
| `ui/sign-in/src/experience-api.ts` | Add `initForgotPassword()`, `forgotPasswordSendCode()`, `forgotPasswordVerifyAndReset()`, `verifyTotp()`, `verifyBackupCode()`, `submitMfa()` |
| `ui/sign-in/src/SignInPage.tsx` | Add modes: `forgotPassword`, `forgotPasswordVerify`, `mfaVerify`, `mfaBackupCode` |
| `ui/sign-in/src/SignInPage.module.css` | Styles for forgot-password link, MFA backup code card |
| `ui/sign-in/package.json` | No new deps (no QR code in sign-in UI) |
| `ui/package.json` | Add `qrcode.react` dependency |
| `ui/src/api/tenant-hooks.ts` | Add MFA hooks: `useMfaStatus`, `useMfaSetup`, `useMfaVerify`, `useMfaBackupCodes`, `useMfaRemove`, `useResetTeamMemberMfa`, `useUpdateTenantSettings` |
| `ui/src/types/api.ts` | Add `MfaStatus`, `MfaSetupResponse`, `BackupCodesResponse` types, extend `TenantSettings` |
| `ui/src/pages/tenant/SettingsPage.tsx` | Add MFA enrollment section + MFA enforcement toggle |
| `ui/src/pages/tenant/TeamPage.tsx` | Add "Reset MFA" action button |
| `ui/src/api/client.ts` | Add `APP_MFA_REQUIRED` interceptor |
| `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java` | Add MFA Management API methods |
| `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java` | Add MFA endpoints + settings PATCH |
| `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java` | Add MFA business logic |
| `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java` | Register `MfaEnforcementFilter` in filter chain |
| `docker/logto-bootstrap.sh` | MFA config in Phase 8c, `mfa_enrolled` in Phase 7b JWT script |
---
## Task 1: Password Reset — Experience API Functions
**Files:**
- Modify: `ui/sign-in/src/experience-api.ts`
- [ ] **Step 1: Add forgot-password API functions**
Append after the `// --- Registration ---` section:
```typescript
// --- Forgot Password ---
export async function initForgotPassword(): Promise<void> {
const res = await request('PUT', '', { interactionEvent: 'ForgotPassword' });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err.message || `Failed to initialize password reset (${res.status})`);
}
}
export async function forgotPasswordSendCode(email: string): Promise<string> {
const res = await request('POST', '/verification/verification-code', {
identifier: { type: 'email', value: email },
interactionEvent: 'ForgotPassword',
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('No account found with this email');
}
throw new Error(err.message || `Failed to send reset code (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function forgotPasswordVerifyAndReset(
email: string,
verificationId: string,
code: string,
newPassword: string,
): Promise<void> {
const verifiedId = await verifyCode(email, verificationId, code);
await identifyUser(verifiedId);
await addProfile('password', newPassword);
await submitInteraction();
}
export async function startForgotPassword(email: string): Promise<string> {
await initForgotPassword();
return forgotPasswordSendCode(email);
}
```
- [ ] **Step 2: Add MFA verification API functions**
Append after the forgot-password section:
```typescript
// --- MFA Verification ---
export async function verifyTotp(code: string): Promise<string> {
const res = await request('POST', '/verification/totp/verify', { code });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
throw new Error('Invalid code, please try again');
}
throw new Error(err.message || `TOTP verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function verifyBackupCode(code: string): Promise<string> {
const res = await request('POST', '/verification/backup-code/verify', { code });
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (res.status === 422) {
const msg = err.code === 'backup_code_consumed'
? 'This backup code has already been used'
: 'Invalid backup code';
throw new Error(msg);
}
throw new Error(err.message || `Backup code verification failed (${res.status})`);
}
const data = await res.json();
return data.verificationId;
}
export async function submitMfa(verificationId: string): Promise<string> {
await identifyUser(verificationId);
return submitInteraction();
}
```
- [ ] **Step 3: Update signIn() to detect MFA requirement**
Replace the existing `signIn` function:
```typescript
export class MfaRequiredError extends Error {
constructor() {
super('MFA verification required');
this.name = 'MfaRequiredError';
}
}
export async function signIn(identifier: string, password: string): Promise<string> {
await initInteraction();
const verificationId = await verifyPassword(identifier, password);
await identifyUser(verificationId);
const res = await request('POST', '/submit');
if (!res.ok) {
const err = await res.json().catch(() => ({}));
if (err.code === 'user.missing_mfa') {
throw new MfaRequiredError();
}
throw new Error(err.message || `Submit failed (${res.status})`);
}
const data = await res.json();
return data.redirectTo;
}
```
Also update the import in `SignInPage.tsx` (next task) to import `MfaRequiredError`.
- [ ] **Step 4: Update the SignInPage import line**
In `ui/sign-in/src/SignInPage.tsx`, change the import to:
```typescript
import {
signIn, startRegistration, completeRegistration,
startForgotPassword, forgotPasswordVerifyAndReset,
verifyTotp, verifyBackupCode, submitMfa,
MfaRequiredError,
} from './experience-api';
```
- [ ] **Step 5: Commit**
```bash
git add ui/sign-in/src/experience-api.ts ui/sign-in/src/SignInPage.tsx
git commit -m "feat: add forgot-password and MFA verification Experience API functions"
```
---
## Task 2: Password Reset <20><><EFBFBD> Sign-in UI
**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- Modify: `ui/sign-in/src/SignInPage.module.css`
- [ ] **Step 1: Extend the Mode type and add state**
Change the `Mode` type and add new state variables:
```typescript
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
```
Add state for forgot-password fields inside `SignInPage()`, after the existing `verificationId` state:
```typescript
const [newPassword, setNewPassword] = useState('');
const [confirmNewPassword, setConfirmNewPassword] = useState('');
```
- [ ] **Step 2: Add emailConnectorConfigured state**
The "Forgot password?" link should only show when an email connector is configured. The existing `registrationEnabled` check covers the `signInMode`, but forgot-password needs an email connector regardless of registration state. Add state derived from the sign-in experience fetch.
After the existing `setRegistrationEnabled(enabled)` line in the `useEffect`, add:
```typescript
// Email connector is configured if sign-in-exp has passwordless methods or signUp has email verification
// For simplicity: if the response loads successfully and has connectorTargets or if registration is enabled
// (both require a configured email connector), forgot-password is available.
const hasEmailConnector = enabled || (data.forgotPassword?.email ?? false);
setEmailConnectorConfigured(hasEmailConnector);
```
Add the state variable:
```typescript
const [emailConnectorConfigured, setEmailConnectorConfigured] = useState(false);
```
Actually, simpler: the forgot-password email template is always configured in `EmailConnectorService` when the email connector exists. The sign-in experience fetch returns the full config. Check for `signUp.verify` or the connector being present. The cleanest approach: if `registrationEnabled` is true, the email connector must be configured. But forgot-password should also work when registration is disabled but email connector exists. The safest check: fetch the connectors list. But that's an extra API call.
Pragmatic approach: show "Forgot password?" whenever an email connector exists. The sign-in experience `signUp` object has `identifiers` that include `email` when email connector is configured, even if registration is disabled. Check:
```typescript
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
setEmailConnectorConfigured(hasEmailConnector);
```
- [ ] **Step 3: Add forgot-password handler**
After `handleVerifyCode`, add:
```typescript
// --- Forgot password step 1: send reset code ---
const handleForgotPassword = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (!identifier.includes('@')) {
setError('Please enter your email address');
return;
}
setLoading(true);
try {
const vId = await startForgotPassword(identifier);
setVerificationId(vId);
setMode('forgotPasswordVerify');
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to send reset code');
} finally {
setLoading(false);
}
};
// --- Forgot password step 2: verify code + set new password ---
const handleForgotPasswordVerify = async (e: FormEvent) => {
e.preventDefault();
setError(null);
if (newPassword !== confirmNewPassword) {
setError('Passwords do not match');
return;
}
if (newPassword.length < 8) {
setError('Password must be at least 8 characters');
return;
}
setLoading(true);
try {
await forgotPasswordVerifyAndReset(identifier, verificationId, code, newPassword);
// Send security notification email (fire-and-forget)
fetch('/platform/api/password-reset-notification', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: identifier }),
}).catch(() => {});
// Reset to sign-in with success message
switchMode('signIn');
setError(null);
// Using a simple approach — show a transient success state
setIdentifier(identifier); // preserve email for convenience
alert('Password reset successful. Please sign in with your new password.');
} catch (err) {
setError(err instanceof Error ? err.message : 'Password reset failed');
} finally {
setLoading(false);
}
};
```
- [ ] **Step 4: Add "Forgot password?" link to sign-in form**
In the sign-in form JSX, after the password `FormField` closing tag and before the `<Button>` submit, add:
```tsx
{emailConnectorConfigured && (
<button
type="button"
className={styles.forgotLink}
onClick={() => { setError(null); setMode('forgotPassword'); }}
>
Forgot password?
</button>
)}
```
- [ ] **Step 5: Add forgot-password email entry form**
After the `{/* --- Verification code form --- */}` block (before `</div></Card>`), add:
```tsx
{/* --- Forgot password: email entry --- */}
{mode === 'forgotPassword' && (
<form className={styles.fields} onSubmit={handleForgotPassword} aria-label="Reset password" noValidate>
<p className={styles.verifyHint}>
Enter your email address and we'll send you a code to reset your password.
</p>
<FormField label="Email" htmlFor="forgot-email">
<Input
id="forgot-email"
type="email"
value={identifier}
onChange={(e) => setIdentifier(e.target.value)}
placeholder="you@company.com"
autoFocus
autoComplete="email"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !identifier}
className={styles.submitButton}
>
Send reset code
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('signIn')}>
Back to sign in
</button>
</p>
</form>
)}
{/* --- Forgot password: verify code + new password --- */}
{mode === 'forgotPasswordVerify' && (
<form className={styles.fields} onSubmit={handleForgotPasswordVerify} aria-label="Set new password" noValidate>
<p className={styles.verifyHint}>
We sent a verification code to <strong>{identifier}</strong>
</p>
<FormField label="Verification code" htmlFor="forgot-code">
<Input
id="forgot-code"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
autoComplete="one-time-code"
disabled={loading}
/>
</FormField>
<FormField label="New password" htmlFor="forgot-new-password">
<div className={styles.passwordWrapper}>
<Input
id="forgot-new-password"
type={showPassword ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={loading}
/>
{passwordToggle}
</div>
</FormField>
<FormField label="Confirm new password" htmlFor="forgot-confirm-password">
<Input
id="forgot-confirm-password"
type={showPassword ? 'text' : 'password'}
value={confirmNewPassword}
onChange={(e) => setConfirmNewPassword(e.target.value)}
placeholder="••••••••"
autoComplete="new-password"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || code.length < 6 || !newPassword || !confirmNewPassword}
className={styles.submitButton}
>
Reset password
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => switchMode('forgotPassword')}>
Back
</button>
</p>
</form>
)}
```
- [ ] **Step 6: Update switchMode to reset new fields**
In the `switchMode` function, add resets for the new state:
```typescript
const switchMode = (next: Mode) => {
setMode(next);
setPassword('');
setConfirmPassword('');
setNewPassword('');
setConfirmNewPassword('');
setCode('');
setShowPassword(false);
setVerificationId('');
};
```
- [ ] **Step 7: Add forgotLink CSS**
In `SignInPage.module.css`, add after `.switchLink:hover`:
```css
.forgotLink {
background: none;
border: none;
cursor: pointer;
color: var(--text-muted);
font-size: 13px;
padding: 0;
text-decoration: underline;
text-align: right;
align-self: flex-end;
margin-top: -8px;
}
.forgotLink:hover {
color: var(--text-link, #C6820E);
}
```
- [ ] **Step 8: Commit**
```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/src/SignInPage.module.css
git commit -m "feat: add forgot-password UI flow to custom sign-in page"
```
---
## Task 3: MFA Verification at Sign-in — UI
**Files:**
- Modify: `ui/sign-in/src/SignInPage.tsx`
- Modify: `ui/sign-in/src/SignInPage.module.css`
- [ ] **Step 1: Update handleSignIn to catch MfaRequiredError**
Replace the existing `handleSignIn`:
```typescript
const handleSignIn = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const redirectTo = await signIn(identifier, password);
window.location.replace(redirectTo);
} catch (err) {
if (err instanceof MfaRequiredError) {
setMode('mfaVerify');
setLoading(false);
return;
}
setError(err instanceof Error ? err.message : 'Sign-in failed');
setLoading(false);
}
};
```
- [ ] **Step 2: Add MFA verification handlers**
After `handleForgotPasswordVerify`, add:
```typescript
// --- MFA: TOTP verification ---
const handleMfaVerify = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const verificationId = await verifyTotp(code);
const redirectTo = await submitMfa(verificationId);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
};
// --- MFA: backup code verification ---
const handleBackupCodeVerify = async (e: FormEvent) => {
e.preventDefault();
setError(null);
setLoading(true);
try {
const verificationId = await verifyBackupCode(code);
const redirectTo = await submitMfa(verificationId);
window.location.replace(redirectTo);
} catch (err) {
setError(err instanceof Error ? err.message : 'Verification failed');
setLoading(false);
}
};
```
- [ ] **Step 3: Add MFA verification forms**
After the forgot-password forms, before `</div></Card>`, add:
```tsx
{/* --- MFA: TOTP verification --- */}
{mode === 'mfaVerify' && (
<form className={styles.fields} onSubmit={handleMfaVerify} aria-label="Two-factor authentication" noValidate>
<p className={styles.verifyHint}>
Enter the 6-digit code from your authenticator app.
</p>
<FormField label="Authentication code" htmlFor="mfa-totp-code">
<Input
id="mfa-totp-code"
type="text"
inputMode="numeric"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoFocus
autoComplete="one-time-code"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || code.length < 6}
className={styles.submitButton}
>
Verify
</Button>
<div className={styles.backupCodeCard}>
<p className={styles.backupCodeText}>Lost your device?</p>
<button
type="button"
className={styles.backupCodeAction}
onClick={() => { setCode(''); setError(null); setMode('mfaBackupCode'); }}
>
Use a backup code
</button>
</div>
</form>
)}
{/* --- MFA: backup code verification --- */}
{mode === 'mfaBackupCode' && (
<form className={styles.fields} onSubmit={handleBackupCodeVerify} aria-label="Backup code verification" noValidate>
<p className={styles.verifyHint}>
Enter one of your 10 backup codes.
</p>
<FormField label="Backup code" htmlFor="mfa-backup-code">
<Input
id="mfa-backup-code"
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="Enter backup code"
autoFocus
autoComplete="off"
disabled={loading}
/>
</FormField>
<Button
variant="primary"
type="submit"
loading={loading}
disabled={loading || !code}
className={styles.submitButton}
>
Verify backup code
</Button>
<p className={styles.switchText}>
<button type="button" className={styles.switchLink} onClick={() => { setCode(''); setError(null); setMode('mfaVerify'); }}>
Use authenticator app instead
</button>
</p>
</form>
)}
```
- [ ] **Step 4: Add backup code card CSS**
In `SignInPage.module.css`, add:
```css
.backupCodeCard {
margin-top: 4px;
padding: 12px 16px;
background: var(--bg-surface, #f8f7f5);
border: 1px solid var(--border-default, #e8e0d4);
border-radius: 6px;
text-align: center;
}
.backupCodeText {
font-size: 13px;
color: var(--text-muted);
margin: 0 0 6px;
}
.backupCodeAction {
background: none;
border: none;
cursor: pointer;
color: var(--text-link, #C6820E);
font-size: 14px;
font-weight: 600;
padding: 4px 8px;
text-decoration: underline;
}
.backupCodeAction:hover {
opacity: 0.8;
}
```
- [ ] **Step 5: Commit**
```bash
git add ui/sign-in/src/SignInPage.tsx ui/sign-in/src/SignInPage.module.css
git commit -m "feat: add MFA verification (TOTP + backup code) to sign-in flow"
```
---
## Task 4: Logto Bootstrap — MFA Config + JWT Claim
**Files:**
- Modify: `docker/logto-bootstrap.sh`
- [ ] **Step 1: Add MFA factors to Phase 8c sign-in experience patch**
In `docker/logto-bootstrap.sh`, find the Phase 8c PATCH call (the `PATCH /api/sign-in-exp` with `signInMode` and `signIn.methods`). Add the `mfa` field to the same JSON payload. The full patched call becomes:
```bash
api_patch "/api/sign-in-exp" '{
"signInMode": "SignIn",
"signIn": {
"methods": [
{
"identifier": "email",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
},
{
"identifier": "username",
"password": true,
"verificationCode": false,
"isPasswordPrimary": true
}
]
},
"mfa": {
"factors": ["Totp", "BackupCode"],
"policy": "UserControlled"
}
}'
```
- [ ] **Step 2: Extend custom JWT script in Phase 7b**
Replace the existing `CUSTOM_JWT_SCRIPT` variable with the extended version that includes `mfa_enrolled`:
```bash
CUSTOM_JWT_SCRIPT='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");
}
}
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;
};'
```
- [ ] **Step 3: Commit**
```bash
git add docker/logto-bootstrap.sh
git commit -m "feat: configure MFA factors + mfa_enrolled JWT claim in Logto bootstrap"
```
---
## Task 5: Backend — LogtoManagementClient MFA Methods
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java`
- [ ] **Step 1: Add getUserMfaVerifications method**
Add after the existing `updateUserPassword` method:
```java
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
if (!isAvailable()) return List.of();
try {
String token = getAccessToken();
String response = restClient.get()
.uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
.header("Authorization", "Bearer " + token)
.retrieve()
.body(String.class);
if (response == null) return List.of();
JsonNode arr = objectMapper.readTree(response);
List<Map<String, Object>> result = new java.util.ArrayList<>();
for (JsonNode node : arr) {
result.add(objectMapper.convertValue(node, new com.fasterxml.jackson.core.type.TypeReference<>() {}));
}
return result;
} catch (Exception e) {
log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
return List.of();
}
}
```
- [ ] **Step 2: Add createTotpVerification method**
```java
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
public Map<String, Object> createTotpVerification(String userId, String secret) {
if (!isAvailable()) return Map.of();
try {
String token = getAccessToken();
String body = objectMapper.writeValueAsString(Map.of("type", "Totp", "secret", secret));
String response = restClient.post()
.uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
.header("Authorization", "Bearer " + token)
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(String.class);
if (response == null) return Map.of();
return objectMapper.readValue(response, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
return Map.of();
}
}
```
- [ ] **Step 3: Add createBackupCodes method**
```java
/** Generate backup codes for a user. Returns the list of codes. */
public Map<String, Object> createBackupCodes(String userId) {
if (!isAvailable()) return Map.of();
try {
String token = getAccessToken();
String body = objectMapper.writeValueAsString(Map.of("type", "BackupCode"));
String response = restClient.post()
.uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications", userId)
.header("Authorization", "Bearer " + token)
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
.body(String.class);
if (response == null) return Map.of();
return objectMapper.readValue(response, new com.fasterxml.jackson.core.type.TypeReference<>() {});
} catch (Exception e) {
log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
return Map.of();
}
}
```
- [ ] **Step 4: Add deleteMfaVerification method**
```java
/** Delete a specific MFA verification for a user. */
public void deleteMfaVerification(String userId, String verificationId) {
if (!isAvailable()) return;
try {
String token = getAccessToken();
restClient.delete()
.uri(logtoConfig.getEndpoint() + "/api/users/{userId}/mfa-verifications/{verificationId}", userId, verificationId)
.header("Authorization", "Bearer " + token)
.retrieve()
.toBodilessEntity();
} catch (Exception e) {
log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
}
}
```
- [ ] **Step 5: Add deleteAllMfaVerifications method**
```java
/** Delete all MFA verifications for a user (used for admin MFA reset). */
public void deleteAllMfaVerifications(String userId) {
List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
for (Map<String, Object> v : verifications) {
String id = String.valueOf(v.get("id"));
deleteMfaVerification(userId, id);
}
}
```
- [ ] **Step 6: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/identity/LogtoManagementClient.java
git commit -m "feat: add MFA Management API methods to LogtoManagementClient"
```
---
## Task 6: Backend — MFA Portal Endpoints
**Files:**
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java`
- [ ] **Step 1: Add MFA methods to TenantPortalService**
Add these methods to `TenantPortalService`:
```java
public record MfaStatusData(boolean enrolled, boolean hasBackupCodes) {}
public record MfaSetupData(String secret, String secretQrCode) {}
public record BackupCodesData(List<String> codes) {}
public MfaStatusData getMfaStatus(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
boolean hasTotp = verifications.stream().anyMatch(v -> "Totp".equals(v.get("type")));
boolean hasBackup = verifications.stream().anyMatch(v -> "BackupCode".equals(v.get("type")));
return new MfaStatusData(hasTotp, hasBackup);
}
public MfaSetupData setupTotp(String userId) {
// Generate a random TOTP secret (base32, 20 bytes)
byte[] bytes = new byte[20];
new java.security.SecureRandom().nextBytes(bytes);
String secret = new org.apache.commons.codec.binary.Base32().encodeToString(bytes);
var result = logtoClient.createTotpVerification(userId, secret);
String qrCode = (String) result.getOrDefault("secretQrCode", "");
return new MfaSetupData(secret, qrCode);
}
public boolean verifyTotpCode(String secret, String code) {
// TOTP verification: compute expected code from secret and compare
// Uses the same algorithm as authenticator apps (RFC 6238)
long timeStep = System.currentTimeMillis() / 1000 / 30;
for (int i = -1; i <= 1; i++) { // allow 1 step drift
String expected = computeTotp(secret, timeStep + i);
if (expected.equals(code)) return true;
}
return false;
}
private String computeTotp(String base32Secret, long timeStep) {
try {
byte[] key = new org.apache.commons.codec.binary.Base32().decode(base32Secret);
byte[] data = java.nio.ByteBuffer.allocate(8).putLong(timeStep).array();
javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA1");
mac.init(new javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"));
byte[] hash = mac.doFinal(data);
int offset = hash[hash.length - 1] & 0xf;
int binary = ((hash[offset] & 0x7f) << 24) | ((hash[offset + 1] & 0xff) << 16)
| ((hash[offset + 2] & 0xff) << 8) | (hash[offset + 3] & 0xff);
int otp = binary % 1_000_000;
return String.format("%06d", otp);
} catch (Exception e) {
throw new IllegalStateException("TOTP computation failed", e);
}
}
public BackupCodesData generateBackupCodes(String userId) {
var result = logtoClient.createBackupCodes(userId);
@SuppressWarnings("unchecked")
List<String> codes = (List<String>) result.getOrDefault("codes", List.of());
return new BackupCodesData(codes);
}
public void removeTotp(String userId) {
var verifications = logtoClient.getUserMfaVerifications(userId);
for (var v : verifications) {
String id = String.valueOf(v.get("id"));
logtoClient.deleteMfaVerification(userId, id);
}
}
public void resetTeamMemberMfa(String userId) {
// Verify the user is a member of this tenant's org
var tenant = tenantService.getById(TenantContext.getTenantId())
.orElseThrow(() -> new IllegalStateException("Tenant not found"));
String orgId = tenant.getLogtoOrgId();
var members = logtoClient.listOrganizationMembers(orgId);
boolean isMember = members.stream().anyMatch(m ->
userId.equals(m.get("id")) || userId.equals(m.get("userId")));
if (!isMember) {
throw new IllegalArgumentException("User is not a member of this organization");
}
logtoClient.deleteAllMfaVerifications(userId);
}
```
- [ ] **Step 2: Add MFA endpoints to TenantPortalController**
Add these endpoints to `TenantPortalController`:
```java
@GetMapping("/mfa/status")
public ResponseEntity<?> getMfaStatus(@AuthenticationPrincipal Jwt jwt) {
try {
return ResponseEntity.ok(portalService.getMfaStatus(jwt.getSubject()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/mfa/totp/setup")
public ResponseEntity<?> setupTotp(@AuthenticationPrincipal Jwt jwt) {
try {
return ResponseEntity.ok(portalService.setupTotp(jwt.getSubject()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
public record TotpVerifyRequest(String secret, String code) {}
@PostMapping("/mfa/totp/verify")
public ResponseEntity<?> verifyTotp(
@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) {
try {
boolean valid = portalService.verifyTotpCode(request.secret(), request.code());
if (!valid) {
return ResponseEntity.status(422).body(Map.of("error", "Invalid TOTP code"));
}
// TOTP is already bound via setupTotp — verification confirms the user has the right secret
return ResponseEntity.ok(Map.of("verified", true));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@PostMapping("/mfa/backup-codes")
public ResponseEntity<?> generateBackupCodes(@AuthenticationPrincipal Jwt jwt) {
try {
return ResponseEntity.ok(portalService.generateBackupCodes(jwt.getSubject()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/mfa/totp")
public ResponseEntity<?> removeTotp(@AuthenticationPrincipal Jwt jwt) {
try {
portalService.removeTotp(jwt.getSubject());
return ResponseEntity.noContent().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
@DeleteMapping("/users/{userId}/mfa")
public ResponseEntity<?> resetTeamMemberMfa(@PathVariable String userId) {
try {
portalService.resetTeamMemberMfa(userId);
return ResponseEntity.noContent().build();
} catch (IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
```
- [ ] **Step 3: Add tenant settings PATCH endpoint**
Add to `TenantPortalController`:
```java
@PatchMapping("/settings")
public ResponseEntity<?> updateSettings(
@RequestBody Map<String, Object> updates) {
try {
portalService.updateTenantSettings(updates);
return ResponseEntity.ok().build();
} catch (Exception e) {
return ResponseEntity.internalServerError().body(Map.of("error", e.getMessage()));
}
}
```
Add to `TenantPortalService`:
```java
public void updateTenantSettings(Map<String, Object> updates) {
var tenant = tenantService.getById(TenantContext.getTenantId())
.orElseThrow(() -> new IllegalStateException("Tenant not found"));
var settings = new java.util.HashMap<>(tenant.getSettings());
// Only allow known settings keys
if (updates.containsKey("mfaRequired")) {
settings.put("mfaRequired", Boolean.TRUE.equals(updates.get("mfaRequired")));
}
tenant.setSettings(settings);
tenantService.save(tenant);
}
```
- [ ] **Step 4: Add MFA policy endpoint for server consumption**
Add to `TenantPortalController`:
```java
@GetMapping("/{slug}/mfa-policy")
public ResponseEntity<?> getMfaPolicy(@PathVariable String slug) {
try {
var tenant = tenantService.getBySlug(slug)
.orElseThrow(() -> new IllegalArgumentException("Tenant not found"));
boolean mfaRequired = Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"));
return ResponseEntity.ok(Map.of("mfaRequired", mfaRequired));
} catch (IllegalArgumentException e) {
return ResponseEntity.notFound().build();
}
}
```
Note: this endpoint must be accessible with M2M tokens. The existing `/api/tenant/**` path requires `authenticated()` in `SecurityConfig`, which accepts any valid JWT including M2M tokens.
- [ ] **Step 5: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalService.java src/main/java/net/siegeln/cameleer/saas/portal/TenantPortalController.java
git commit -m "feat: add MFA enrollment, removal, and settings endpoints"
```
---
## Task 7: Backend — MFA Enforcement Filter
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java`
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`
- [ ] **Step 1: Create MfaEnforcementFilter**
```java
package net.siegeln.cameleer.saas.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.siegeln.cameleer.saas.tenant.TenantContext;
import net.siegeln.cameleer.saas.tenant.TenantService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.MediaType;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Map;
import java.util.Set;
@Component
public class MfaEnforcementFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class);
private static final String ERROR_CODE = "APP_MFA_REQUIRED";
private static final Set<String> EXEMPT_PREFIXES = Set.of(
"/api/tenant/mfa/",
"/api/config",
"/api/me",
"/api/onboarding"
);
private final TenantService tenantService;
private final ObjectMapper objectMapper;
public MfaEnforcementFilter(TenantService tenantService, ObjectMapper objectMapper) {
this.tenantService = tenantService;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getServletPath();
// Only apply to tenant API paths
if (!path.startsWith("/api/tenant/")) return true;
// Exempt MFA enrollment and essential endpoints
return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith);
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwtAuth)) {
filterChain.doFilter(request, response);
return;
}
Jwt jwt = jwtAuth.getToken();
Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled");
// If user has MFA enrolled, no enforcement needed
if (Boolean.TRUE.equals(mfaEnrolled)) {
filterChain.doFilter(request, response);
return;
}
// Check if tenant requires MFA
var tenantId = TenantContext.getTenantId();
if (tenantId == null) {
filterChain.doFilter(request, response);
return;
}
var tenant = tenantService.getById(tenantId).orElse(null);
if (tenant == null || !Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"))) {
filterChain.doFilter(request, response);
return;
}
// Tenant requires MFA but user is not enrolled — block
log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug());
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setHeader("X-Cameleer-Error", ERROR_CODE);
objectMapper.writeValue(response.getOutputStream(), Map.of(
"error", ERROR_CODE,
"code", "mfa_enrollment_required",
"message", "Your organization requires multi-factor authentication"
));
}
}
```
- [ ] **Step 2: Register the filter in SecurityConfig**
In `SecurityConfig.java`, add the filter after the OAuth2 resource server filter. In the `filterChain` method, add:
```java
.addFilterAfter(mfaEnforcementFilter, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class)
```
Inject the filter in the `SecurityConfig` constructor or method parameter:
```java
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception {
// ... existing config ...
http.addFilterAfter(mfaEnforcementFilter,
org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class);
return http.build();
}
```
- [ ] **Step 3: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: add MFA enforcement filter with APP_MFA_REQUIRED error code"
```
---
## Task 8: Backend — Password Reset Security Notification
**Files:**
- Create: `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationController.java`
- Create: `src/main/java/net/siegeln/cameleer/saas/notification/PasswordResetNotificationService.java`
- Create: `src/main/resources/email-templates/password-reset-notification.html`
- Modify: `src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java`
- [ ] **Step 1: Create the email template**
```html
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;max-width:480px;margin:0 auto;background:#ffffff;border-radius:8px;overflow:hidden;border:1px solid #e8e0d4;">
<div style="background:#C6820E;padding:20px 24px;text-align:center;">
<span style="font-size:22px;font-weight:700;color:#ffffff;letter-spacing:0.5px;">Cameleer.io</span>
</div>
<div style="padding:32px 24px 24px;position:relative;overflow:hidden;">
<img src="{{watermarkUrl}}" style="position:absolute;top:-30px;right:-50px;width:320px;height:320px;opacity:0.07;pointer-events:none;border:0;outline:none;" alt="" />
<div style="position:relative;">
<p style="color:#1a1a1a;font-size:16px;font-weight:600;margin:0 0 8px;">Your password was reset</p>
<p style="color:#444;font-size:14px;line-height:1.6;margin:0 0 16px;">Your Cameleer account password was successfully changed on {{timestamp}}.</p>
<div style="background:#FDF6EC;border:1px solid #e8e0d4;border-radius:6px;padding:12px 16px;margin:0 0 16px;">
<p style="color:#444;font-size:13px;line-height:1.5;margin:0;"><strong>Note:</strong> Multi-factor authentication (MFA) was not required for this password reset. We recommend enabling MFA to add an extra layer of security to your account.</p>
</div>
<p style="color:#888;font-size:13px;line-height:1.5;margin:0;">If this wasn't you, contact your administrator immediately.</p>
</div>
</div>
<div style="border-top:1px solid #e8e0d4;padding:16px 24px;text-align:center;">
<p style="color:#999;font-size:12px;margin:0;">Questions? Contact your administrator</p>
<p style="color:#bbb;font-size:11px;margin:6px 0 0;">Cameleer — Apache Camel observability</p>
</div>
</div>
```
- [ ] **Step 2: Create PasswordResetNotificationService**
```java
package net.siegeln.cameleer.saas.notification;
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.io.ClassPathResource;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
@Service
public class PasswordResetNotificationService {
private static final Logger log = LoggerFactory.getLogger(PasswordResetNotificationService.class);
private final LogtoManagementClient logtoClient;
private final JavaMailSender mailSender;
private final String templateHtml;
public PasswordResetNotificationService(
LogtoManagementClient logtoClient,
JavaMailSender mailSender) throws IOException {
this.logtoClient = logtoClient;
this.mailSender = mailSender;
this.templateHtml = new ClassPathResource("email-templates/password-reset-notification.html")
.getContentAsString(StandardCharsets.UTF_8);
}
public void sendNotification(String email) {
// Verify the email exists in Logto (prevent enumeration abuse)
// This is a best-effort check <20><><EFBFBD> if Logto is down, skip silently
try {
// The Management API doesn't have a direct "find by email" — we use getUser with email
// For rate limiting purposes, we just attempt to send and let SMTP handle invalid addresses
String timestamp = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss 'UTC'")
.format(Instant.now().atOffset(ZoneOffset.UTC));
String html = templateHtml
.replace("{{timestamp}}", timestamp)
.replace("{{watermarkUrl}}", "");
var message = mailSender.createMimeMessage();
var helper = new MimeMessageHelper(message, true, "UTF-8");
helper.setTo(email);
helper.setSubject("Your Cameleer password was reset");
helper.setText(html, true);
mailSender.send(message);
log.info("Sent password reset notification to {}", email);
} catch (Exception e) {
log.warn("Failed to send password reset notification to {}: {}", email, e.getMessage());
}
}
}
```
- [ ] **Step 3: Create PasswordResetNotificationController**
```java
package net.siegeln.cameleer.saas.notification;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
@RestController
@RequestMapping("/api/password-reset-notification")
public class PasswordResetNotificationController {
private final PasswordResetNotificationService notificationService;
// Simple in-memory rate limiter: max 3 requests per email per 10 minutes
private final ConcurrentHashMap<String, RateLimit> rateLimits = new ConcurrentHashMap<>();
public PasswordResetNotificationController(PasswordResetNotificationService notificationService) {
this.notificationService = notificationService;
}
public record NotificationRequest(String email) {}
@PostMapping
public ResponseEntity<?> notify(@RequestBody NotificationRequest request) {
if (request.email() == null || !request.email().contains("@")) {
return ResponseEntity.badRequest().body(Map.of("error", "Invalid email"));
}
// Rate limit check
String key = request.email().toLowerCase();
RateLimit limit = rateLimits.compute(key, (k, existing) -> {
long now = System.currentTimeMillis();
if (existing == null || now - existing.windowStart > 600_000) {
return new RateLimit(now, new AtomicInteger(1));
}
existing.count.incrementAndGet();
return existing;
});
if (limit.count.get() > 3) {
return ResponseEntity.status(429).body(Map.of("error", "Too many requests"));
}
// Fire-and-forget in a thread to not block the response
notificationService.sendNotification(request.email());
return ResponseEntity.ok(Map.of("sent", true));
}
private record RateLimit(long windowStart, AtomicInteger count) {}
}
```
- [ ] **Step 4: Permit the notification endpoint in SecurityConfig**
In `SecurityConfig.java`, add to the `permitAll` paths:
```java
.requestMatchers("/api/password-reset-notification").permitAll()
```
Add this in the `authorizeHttpRequests` block, before the `/api/onboarding/**` line.
- [ ] **Step 5: Commit**
```bash
git add src/main/java/net/siegeln/cameleer/saas/notification/ src/main/resources/email-templates/password-reset-notification.html src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java
git commit -m "feat: add password reset security notification email endpoint"
```
---
## Task 9: Frontend — API Types and Hooks for MFA
**Files:**
- Modify: `ui/src/types/api.ts`
- Modify: `ui/src/api/tenant-hooks.ts`
- Modify: `ui/src/api/client.ts`
- [ ] **Step 1: Add MFA types**
In `ui/src/types/api.ts`, add at the end:
```typescript
// MFA types
export interface MfaStatus {
enrolled: boolean;
hasBackupCodes: boolean;
}
export interface MfaSetupResponse {
secret: string;
secretQrCode: string;
}
export interface BackupCodesResponse {
codes: string[];
}
```
Extend the existing `TenantSettings` interface:
```typescript
export interface TenantSettings {
name: string;
slug: string;
tier: string;
status: string;
serverEndpoint: string | null;
createdAt: string;
mfaRequired?: boolean;
}
```
- [ ] **Step 2: Add MFA hooks**
In `ui/src/api/tenant-hooks.ts`, add:
```typescript
import type { DashboardData, TenantLicenseData, TenantSettings, AuditLogPage, AuditLogFilters, SsoConnector, CreateSsoConnectorRequest, SsoTestResult, MfaStatus, MfaSetupResponse, BackupCodesResponse } from '../types/api';
// ... existing hooks ...
// MFA hooks
export function useMfaStatus() {
return useQuery<MfaStatus>({
queryKey: ['tenant', 'mfa', 'status'],
queryFn: () => api.get('/tenant/mfa/status'),
});
}
export function useMfaSetup() {
return useMutation<MfaSetupResponse, Error, void>({
mutationFn: () => api.post('/tenant/mfa/totp/setup'),
});
}
export function useMfaVerify() {
const qc = useQueryClient();
return useMutation<{ verified: boolean }, Error, { secret: string; code: string }>({
mutationFn: (body) => api.post('/tenant/mfa/totp/verify', body),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useMfaBackupCodes() {
const qc = useQueryClient();
return useMutation<BackupCodesResponse, Error, void>({
mutationFn: () => api.post('/tenant/mfa/backup-codes'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useMfaRemove() {
const qc = useQueryClient();
return useMutation<void, Error, void>({
mutationFn: () => api.delete('/tenant/mfa/totp'),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }),
});
}
export function useResetTeamMemberMfa() {
const qc = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }),
});
}
export function useUpdateTenantSettings() {
const qc = useQueryClient();
return useMutation<void, Error, Record<string, unknown>>({
mutationFn: (updates) => api.patch('/tenant/settings', updates),
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'settings'] }),
});
}
```
- [ ] **Step 3: Add APP_MFA_REQUIRED interceptor to API client**
In `ui/src/api/client.ts`, update the `apiFetch` function to detect the MFA enforcement 403:
```typescript
if (response.status === 403) {
const errorHeader = response.headers.get('X-Cameleer-Error');
if (errorHeader === 'APP_MFA_REQUIRED') {
// Redirect to settings page with MFA section
window.location.href = '/platform/tenant/settings?mfa=required';
throw new Error('MFA enrollment required');
}
}
```
Add this after the existing `401` check and before the generic `!response.ok` check.
- [ ] **Step 4: Commit**
```bash
git add ui/src/types/api.ts ui/src/api/tenant-hooks.ts ui/src/api/client.ts
git commit -m "feat: add MFA types, hooks, and APP_MFA_REQUIRED interceptor"
```
---
## Task 10: Frontend — MFA Enrollment on Settings Page
**Files:**
- Modify: `ui/src/pages/tenant/SettingsPage.tsx`
- Run: `cd ui && npm install qrcode.react`
- [ ] **Step 1: Install qrcode.react**
```bash
cd ui && npm install qrcode.react
```
- [ ] **Step 2: Add MFA section to SettingsPage**
Import the new hooks and QR component at the top of `SettingsPage.tsx`:
```typescript
import { QRCodeSVG } from 'qrcode.react';
import {
useMfaStatus, useMfaSetup, useMfaVerify, useMfaBackupCodes, useMfaRemove,
useUpdateTenantSettings,
} from '../../api/tenant-hooks';
import { useScopes } from '../../auth/useScopes';
```
After the existing "Server Admin Password" Card, add the MFA enrollment Card. This is a substantial component — add it as a separate section inside the same `SettingsPage` function:
```tsx
{/* --- MFA Section --- */}
<MfaSection />
```
Define `MfaSection` as a function inside `SettingsPage` (or above it):
```tsx
function MfaSection() {
const toast = useToast();
const { data: mfaStatus, isLoading: mfaLoading } = useMfaStatus();
const setupMfa = useMfaSetup();
const verifyMfa = useMfaVerify();
const generateCodes = useMfaBackupCodes();
const removeMfa = useMfaRemove();
const [setupData, setSetupData] = useState<{ secret: string; secretQrCode: string } | null>(null);
const [totpCode, setTotpCode] = useState('');
const [backupCodes, setBackupCodes] = useState<string[] | null>(null);
const [savedCodes, setSavedCodes] = useState(false);
const [showRemoveConfirm, setShowRemoveConfirm] = useState(false);
const handleStartSetup = async () => {
try {
const data = await setupMfa.mutateAsync();
setSetupData(data);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to start MFA setup');
}
};
const handleVerify = async () => {
if (!setupData) return;
try {
await verifyMfa.mutateAsync({ secret: setupData.secret, code: totpCode });
// Generate backup codes
const codes = await generateCodes.mutateAsync();
setBackupCodes(codes.codes);
setSetupData(null);
setTotpCode('');
toast.success('MFA enabled successfully');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Invalid code');
}
};
const handleRegenerateCodes = async () => {
try {
const codes = await generateCodes.mutateAsync();
setBackupCodes(codes.codes);
setSavedCodes(false);
toast.success('New backup codes generated');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to generate codes');
}
};
const handleRemove = async () => {
try {
await removeMfa.mutateAsync();
setShowRemoveConfirm(false);
toast.success('MFA removed');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to remove MFA');
}
};
const handleCopyCodes = () => {
if (backupCodes) {
navigator.clipboard.writeText(backupCodes.join('\n'));
toast.success('Backup codes copied');
}
};
const handleDownloadCodes = () => {
if (!backupCodes) return;
const blob = new Blob([backupCodes.join('\n')], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'cameleer-backup-codes.txt';
a.click();
URL.revokeObjectURL(url);
};
if (mfaLoading) return <Card title="Two-Factor Authentication"><Spinner /></Card>;
// Backup codes display (after enrollment or regeneration)
if (backupCodes) {
return (
<Card title="Save Your Backup Codes">
<p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>
Save these codes in a secure place. Each code can only be used once.
</p>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 8, marginBottom: 16, fontFamily: 'monospace', fontSize: 14 }}>
{backupCodes.map((code, i) => (
<div key={i} style={{ padding: '6px 12px', background: 'var(--bg-surface)', borderRadius: 4, textAlign: 'center' }}>{code}</div>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<Button variant="secondary" onClick={handleCopyCodes}>Copy all</Button>
<Button variant="secondary" onClick={handleDownloadCodes}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16, fontSize: 14 }}>
<input type="checkbox" checked={savedCodes} onChange={(e) => setSavedCodes(e.target.checked)} />
I've saved my backup codes
</label>
<Button variant="primary" disabled={!savedCodes} onClick={() => setBackupCodes(null)}>Done</Button>
</Card>
);
}
// Setup flow (QR code + verify)
if (setupData) {
return (
<Card title="Set Up Authenticator App">
<p style={{ marginBottom: 16, color: 'var(--text-secondary)' }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to verify.
</p>
<div style={{ textAlign: 'center', marginBottom: 16 }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<FormField label="Verification code" htmlFor="mfa-setup-code">
<Input
id="mfa-setup-code"
type="text"
inputMode="numeric"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<Button variant="primary" onClick={handleVerify} loading={verifyMfa.isPending} disabled={totpCode.length < 6}>
Verify & enable
</Button>
<Button variant="secondary" onClick={() => { setSetupData(null); setTotpCode(''); }}>Cancel</Button>
</div>
</Card>
);
}
// Main MFA section
return (
<Card title="Two-Factor Authentication">
{mfaStatus?.enrolled ? (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 16 }}>
<Badge color="success">Enabled</Badge>
<span style={{ color: 'var(--text-secondary)', fontSize: 14 }}>Authenticator app configured</span>
</div>
<div style={{ display: 'flex', gap: 8 }}>
<Button variant="secondary" onClick={handleRegenerateCodes} loading={generateCodes.isPending}>
Regenerate backup codes
</Button>
<Button variant="danger" onClick={() => setShowRemoveConfirm(true)}>Remove MFA</Button>
</div>
{showRemoveConfirm && (
<Alert variant="warning" style={{ marginTop: 16 }}>
<p>Are you sure you want to remove MFA? This will make your account less secure.</p>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="danger" size="small" onClick={handleRemove} loading={removeMfa.isPending}>
Yes, remove MFA
</Button>
<Button variant="secondary" size="small" onClick={() => setShowRemoveConfirm(false)}>Cancel</Button>
</div>
</Alert>
)}
</>
) : (
<>
<p style={{ color: 'var(--text-secondary)', marginBottom: 16 }}>
Protect your account with two-factor authentication using an authenticator app.
</p>
<Button variant="primary" onClick={handleStartSetup} loading={setupMfa.isPending}>
Set up authenticator app
</Button>
</>
)}
</Card>
);
}
```
- [ ] **Step 3: Add MFA enforcement toggle (tenant admins only)**
After the `<MfaSection />`, add the enforcement toggle — visible only to users with owner/operator scope:
```tsx
<MfaEnforcementToggle />
```
```tsx
function MfaEnforcementToggle() {
const toast = useToast();
const { hasScope } = useScopes();
const { data: settings } = useTenantSettings();
const updateSettings = useUpdateTenantSettings();
const [showConfirm, setShowConfirm] = useState(false);
// Only show for tenant admins (owners/operators)
if (!hasScope('tenant:manage')) return null;
const mfaRequired = settings?.mfaRequired ?? false;
const handleToggle = async () => {
if (!mfaRequired) {
setShowConfirm(true);
return;
}
// Disabling — no confirmation needed
try {
await updateSettings.mutateAsync({ mfaRequired: false });
toast.success('MFA requirement disabled');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update settings');
}
};
const handleConfirmEnable = async () => {
try {
await updateSettings.mutateAsync({ mfaRequired: true });
setShowConfirm(false);
toast.success('MFA requirement enabled');
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to update settings');
}
};
return (
<Card title="Organization Security">
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<div>
<p style={{ fontWeight: 600, marginBottom: 4 }}>Require MFA for all members</p>
<p style={{ color: 'var(--text-secondary)', fontSize: 13 }}>
Members without MFA will be prompted to enroll on their next sign-in.
</p>
</div>
<Button
variant={mfaRequired ? 'primary' : 'secondary'}
onClick={handleToggle}
loading={updateSettings.isPending}
>
{mfaRequired ? 'Enabled' : 'Disabled'}
</Button>
</div>
{showConfirm && (
<Alert variant="warning" style={{ marginTop: 16 }}>
<p>Members without MFA will be prompted to enroll on their next sign-in. Are you sure?</p>
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Button variant="primary" size="small" onClick={handleConfirmEnable} loading={updateSettings.isPending}>
Yes, require MFA
</Button>
<Button variant="secondary" size="small" onClick={() => setShowConfirm(false)}>Cancel</Button>
</div>
</Alert>
)}
</Card>
);
}
```
- [ ] **Step 4: Commit**
```bash
git add ui/package.json ui/package-lock.json ui/src/pages/tenant/SettingsPage.tsx
git commit -m "feat: add MFA enrollment and enforcement toggle to Settings page"
```
---
## Task 11: Frontend — Reset MFA on Team Page
**Files:**
- Modify: `ui/src/pages/tenant/TeamPage.tsx`
- [ ] **Step 1: Import the new hook**
Add to the imports in `TeamPage.tsx`:
```typescript
import { useTenantTeam, useInviteTeamMember, useRemoveTeamMember, useResetTeamMemberPassword, useResetTeamMemberMfa } from '../../api/tenant-hooks';
```
- [ ] **Step 2: Wire up the hook and add state**
Inside the `TeamPage` component, add:
```typescript
const resetMfa = useResetTeamMemberMfa();
const [mfaResetTarget, setMfaResetTarget] = useState<TeamMember | null>(null);
```
- [ ] **Step 3: Add "Reset MFA" button to the Actions column**
In the DataTable columns, in the Actions cell renderer, add a new button between "Reset Password" and "Remove":
```tsx
<Button
variant="secondary"
size="small"
onClick={() => setMfaResetTarget(member)}
>
Reset MFA
</Button>
```
- [ ] **Step 4: Add MFA reset confirmation dialog**
After the existing password reset form, add:
```tsx
{mfaResetTarget && (
<Card title={`Reset MFA for ${mfaResetTarget.name || mfaResetTarget.email}`} style={{ marginTop: 16 }}>
<Alert variant="warning">
This will remove all MFA factors for this user. They will need to re-enroll if MFA is required.
</Alert>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<Button
variant="danger"
onClick={async () => {
try {
await resetMfa.mutateAsync(mfaResetTarget.id);
toast.success(`MFA reset for ${mfaResetTarget.name || mfaResetTarget.email}`);
setMfaResetTarget(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to reset MFA');
}
}}
loading={resetMfa.isPending}
>
Confirm Reset MFA
</Button>
<Button variant="secondary" onClick={() => setMfaResetTarget(null)}>Cancel</Button>
</div>
</Card>
)}
```
- [ ] **Step 5: Commit**
```bash
git add ui/src/pages/tenant/TeamPage.tsx
git commit -m "feat: add Reset MFA action for team members"
```
---
## Task 12: Server Handoff Document
**Files:**
- Create: `docs/superpowers/specs/2026-04-26-server-mfa-handoff.md`
- [ ] **Step 1: Write the handoff doc**
```markdown
# Cameleer-Server MFA Handoff Document
**Date:** 2026-04-26
**For:** cameleer-server team
**Context:** The SaaS platform now supports TOTP MFA with backup codes. This document specifies what the server team needs to implement for MFA enrollment in the server UI.
## 1. JWT Claim: `mfa_enrolled`
Every access token now includes an `mfa_enrolled: boolean` claim, set by the Logto Custom JWT script. The server already parses JWT claims for the `roles` field — `mfa_enrolled` works identically.
**Example decoded JWT payload:**
```json
{
"sub": "user-id-123",
"roles": ["server:admin"],
"mfa_enrolled": true,
"aud": "https://api.cameleer.local",
"scope": "tenant:manage tenant:view"
}
```
## 2. Enforcement
### When to enforce
Check whether the tenant requires MFA:
- **Endpoint:** `GET /platform/api/tenant/{slug}/mfa-policy`
- **Auth:** M2M token (same as existing server → SaaS API calls)
- **Response:** `{ "mfaRequired": true/false }`
- **Cache:** 5-minute TTL recommended
### How to enforce
On authenticated requests, if `mfaRequired` is `true` and the JWT `mfa_enrolled` claim is `false`:
**Response:**
```json
HTTP 403
X-Cameleer-Error: APP_MFA_REQUIRED
Content-Type: application/json
{
"error": "APP_MFA_REQUIRED",
"code": "mfa_enrollment_required",
"message": "Your organization requires multi-factor authentication"
}
```
**Exempt paths:** MFA enrollment endpoints (below), health checks, public assets.
The server UI should intercept 403 responses with `X-Cameleer-Error: APP_MFA_REQUIRED` and redirect to the MFA enrollment page.
## 3. MFA Enrollment API
The server needs to call Logto's Management API to manage MFA for users. Use the existing M2M token for authentication.
### Get MFA status
```
GET https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Response: [
{ "id": "ver-123", "type": "Totp", "createdAt": "..." },
{ "id": "ver-456", "type": "BackupCode", "createdAt": "..." }
]
```
### Generate TOTP secret
Generate a 20-byte random secret, Base32-encode it, and create a QR code URI:
```
otpauth://totp/Cameleer:{userEmail}?secret={base32Secret}&issuer=Cameleer
```
Show the QR code to the user. After they scan and provide a 6-digit code, verify it server-side using TOTP algorithm (RFC 6238, HMAC-SHA1, 30-second window, ±1 step drift).
### Bind TOTP to user
After successful verification:
```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json
{ "type": "Totp", "secret": "{base32Secret}" }
Response: { "type": "Totp", "secret": "...", "secretQrCode": "..." }
```
### Generate backup codes
After TOTP is bound:
```
POST https://{logto-endpoint}/api/users/{userId}/mfa-verifications
Authorization: Bearer {m2m_token}
Content-Type: application/json
{ "type": "BackupCode" }
Response: { "type": "BackupCode", "codes": ["abc123", "def456", ...] }
```
Display the 10 codes once. User must acknowledge saving them before dismissing.
### Remove MFA (admin action)
```
DELETE https://{logto-endpoint}/api/users/{userId}/mfa-verifications/{verificationId}
Authorization: Bearer {m2m_token}
```
Remove all verifications (TOTP + BackupCode) to fully reset MFA for a user.
## 4. UX Requirements
### Enrollment flow
1. User clicks "Set up MFA" in settings
2. Show QR code (200×200px) with the TOTP secret URI
3. User scans with authenticator app
4. User enters 6-digit verification code
5. On success → show 10 backup codes in a 2-column monospace grid
6. "Copy all" and "Download .txt" buttons
7. Checkbox: "I've saved my backup codes" — must be checked before dismissing
8. After dismissal, force token refresh to get `mfa_enrolled: true` in JWT
### Enrolled state
- Show "Authenticator app configured" with green status badge
- "Regenerate backup codes" button
- "Remove MFA" button with confirmation dialog
### Backup code fallback (sign-in)
This is handled by the SaaS custom sign-in UI, not the server. No server changes needed for the sign-in flow.
## 5. Error States
| Scenario | Response |
|----------|----------|
| User already has TOTP enrolled | 422 — "TOTP already configured" |
| Invalid TOTP code during setup | Show error, let user retry |
| Backup code already used (sign-in) | Handled by SaaS sign-in UI |
| All backup codes exhausted | Admin removes MFA via team page |
| Remove MFA while enforcement active | User will be prompted to re-enroll on next request |
```
- [ ] **Step 2: Commit**
```bash
git add docs/superpowers/specs/2026-04-26-server-mfa-handoff.md
git commit -m "docs: add MFA handoff document for cameleer-server team"
```
---
## Self-Review Checklist
### Spec coverage
| Spec Section | Task(s) |
|-------------|---------|
| 1. Password Reset Flow | Task 1 (API), Task 2 (UI) |
| 1. Security Notification Email | Task 8 |
| 2. MFA Config (Bootstrap) | Task 4 |
| 2. Custom JWT mfa_enrolled claim | Task 4 |
| 3. MFA Verification at Sign-in | Task 1 (API), Task 3 (UI) |
| 4. MFA Enrollment (Settings) | Task 6 (backend), Task 10 (frontend) |
| 4. Team Management (Reset MFA) | Task 6 (backend), Task 11 (frontend) |
| 5. Per-Tenant Enforcement (filter) | Task 7 |
| 5. Tenant Admin Toggle | Task 10 (frontend), Task 6 (backend) |
| 5. Server Enforcement | Task 12 (handoff) |
| 5. APP_MFA_REQUIRED error code | Task 7, Task 9 (client interceptor) |
| 5. Token Refresh After Enrollment | Task 10 (in enrollment flow) |
| 6. Backup Codes | Task 6 (backend), Task 10 (frontend display) |
| 7. Server Handoff Doc | Task 12 |
### Placeholder scan
No TBDs, TODOs, or "implement later" references. All code blocks are complete.
### Type consistency
- `MfaStatus` → used in `useMfaStatus` hook and `MfaSection` component ✓
- `MfaSetupResponse` → used in `useMfaSetup` hook and `handleStartSetup`
- `BackupCodesResponse` → used in `useMfaBackupCodes` hook and backup codes display ✓
- `MfaRequiredError` → thrown in `experience-api.ts`, caught in `SignInPage.tsx`
- `APP_MFA_REQUIRED` → consistent across `MfaEnforcementFilter`, `client.ts` interceptor, handoff doc ✓
- `TotpVerifyRequest(secret, code)` → matches frontend `useMfaVerify` payload `{ secret, code }`