Files
cameleer-saas/docs/superpowers/plans/2026-04-26-password-reset-mfa-plan.md
hsiegeln cfcf852e2d docs: add password reset and MFA implementation plan
12-task plan covering:
- Password reset Experience API + sign-in UI
- MFA verification at sign-in (TOTP + backup codes)
- Logto bootstrap MFA config + mfa_enrolled JWT claim
- LogtoManagementClient MFA methods
- MFA enrollment endpoints + Settings page UI
- MFA enforcement filter (APP_MFA_REQUIRED)
- Password reset security notification email
- Team page Reset MFA action
- Server handoff document

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-26 13:35:42 +02:00

2121 lines
71 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 }`