# 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 { 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 { 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 { const verifiedId = await verifyCode(email, verificationId, code); await identifyUser(verifiedId); await addProfile('password', newPassword); await submitInteraction(); } export async function startForgotPassword(email: string): Promise { 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 { 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 { 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 { 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 { 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 ��� 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 ` )} ``` - [ ] **Step 5: Add forgot-password email entry form** After the `{/* --- Verification code form --- */}` block (before ``), add: ```tsx {/* --- Forgot password: email entry --- */} {mode === 'forgotPassword' && (

Enter your email address and we'll send you a code to reset your password.

setIdentifier(e.target.value)} placeholder="you@company.com" autoFocus autoComplete="email" disabled={loading} />

)} {/* --- Forgot password: verify code + new password --- */} {mode === 'forgotPasswordVerify' && (

We sent a verification code to {identifier}

setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" autoFocus autoComplete="one-time-code" disabled={loading} />
setNewPassword(e.target.value)} placeholder="At least 8 characters" autoComplete="new-password" disabled={loading} /> {passwordToggle}
setConfirmNewPassword(e.target.value)} placeholder="••••••••" autoComplete="new-password" disabled={loading} />

)} ``` - [ ] **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 ``, add: ```tsx {/* --- MFA: TOTP verification --- */} {mode === 'mfaVerify' && (

Enter the 6-digit code from your authenticator app.

setCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" autoFocus autoComplete="one-time-code" disabled={loading} />

Lost your device?

)} {/* --- MFA: backup code verification --- */} {mode === 'mfaBackupCode' && (

Enter one of your 10 backup codes.

setCode(e.target.value)} placeholder="Enter backup code" autoFocus autoComplete="off" disabled={loading} />

)} ``` - [ ] **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> 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> 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 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 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> verifications = getUserMfaVerifications(userId); for (Map 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 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 codes = (List) 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 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 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 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
Cameleer.io

Your password was reset

Your Cameleer account password was successfully changed on {{timestamp}}.

Note: 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.

If this wasn't you, contact your administrator immediately.

Questions? Contact your administrator

Cameleer — Apache Camel observability

``` - [ ] **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 ��� 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 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({ queryKey: ['tenant', 'mfa', 'status'], queryFn: () => api.get('/tenant/mfa/status'), }); } export function useMfaSetup() { return useMutation({ 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({ mutationFn: () => api.post('/tenant/mfa/backup-codes'), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), }); } export function useMfaRemove() { const qc = useQueryClient(); return useMutation({ mutationFn: () => api.delete('/tenant/mfa/totp'), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'mfa'] }), }); } export function useResetTeamMemberMfa() { const qc = useQueryClient(); return useMutation({ mutationFn: (userId) => api.delete(`/tenant/users/${userId}/mfa`), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenant', 'team'] }), }); } export function useUpdateTenantSettings() { const qc = useQueryClient(); return useMutation>({ 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 --- */} ``` 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(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 ; // Backup codes display (after enrollment or regeneration) if (backupCodes) { return (

Save these codes in a secure place. Each code can only be used once.

{backupCodes.map((code, i) => (
{code}
))}
); } // Setup flow (QR code + verify) if (setupData) { return (

Scan this QR code with your authenticator app (Google Authenticator, Authy, etc.), then enter the 6-digit code to verify.

setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))} placeholder="000000" autoComplete="one-time-code" />
); } // Main MFA section return ( {mfaStatus?.enrolled ? ( <>
Enabled Authenticator app configured
{showRemoveConfirm && (

Are you sure you want to remove MFA? This will make your account less secure.

)} ) : ( <>

Protect your account with two-factor authentication using an authenticator app.

)}
); } ``` - [ ] **Step 3: Add MFA enforcement toggle (tenant admins only)** After the ``, add the enforcement toggle — visible only to users with owner/operator scope: ```tsx ``` ```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 (

Require MFA for all members

Members without MFA will be prompted to enroll on their next sign-in.

{showConfirm && (

Members without MFA will be prompted to enroll on their next sign-in. Are you sure?

)}
); } ``` - [ ] **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(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 ``` - [ ] **Step 4: Add MFA reset confirmation dialog** After the existing password reset form, add: ```tsx {mfaResetTarget && ( This will remove all MFA factors for this user. They will need to re-enroll if MFA is required.
)} ``` - [ ] **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 }` ✓