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>
71 KiB
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:
// --- 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:
// --- 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:
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:
import {
signIn, startRegistration, completeRegistration,
startForgotPassword, forgotPasswordVerifyAndReset,
verifyTotp, verifyBackupCode, submitMfa,
MfaRequiredError,
} from './experience-api';
- Step 5: Commit
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:
type Mode = 'signIn' | 'register' | 'verifyCode' | 'forgotPassword' | 'forgotPasswordVerify' | 'mfaVerify' | 'mfaBackupCode';
Add state for forgot-password fields inside SignInPage(), after the existing verificationId state:
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:
// 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:
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:
const hasEmailConnector = Array.isArray(data.signUp?.identifiers) && data.signUp.identifiers.includes('email');
setEmailConnectorConfigured(hasEmailConnector);
- Step 3: Add forgot-password handler
After handleVerifyCode, add:
// --- 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:
{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:
{/* --- 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:
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:
.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
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:
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:
// --- 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:
{/* --- 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:
.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
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:
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:
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
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:
/** 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
/** 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
/** 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
/** 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
/** 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
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:
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:
@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:
@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:
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:
@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
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
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:
.addFilterAfter(mfaEnforcementFilter, org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter.class)
Inject the filter in the SecurityConfig constructor or method parameter:
@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
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
<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
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
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:
.requestMatchers("/api/password-reset-notification").permitAll()
Add this in the authorizeHttpRequests block, before the /api/onboarding/** line.
- Step 5: Commit
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:
// 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:
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:
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:
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
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
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:
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:
{/* --- MFA Section --- */}
<MfaSection />
Define MfaSection as a function inside SettingsPage (or above it):
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:
<MfaEnforcementToggle />
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
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:
import { useTenantTeam, useInviteTeamMember, useRemoveTeamMember, useResetTeamMemberPassword, useResetTeamMemberMfa } from '../../api/tenant-hooks';
- Step 2: Wire up the hook and add state
Inside the TeamPage component, add:
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":
<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:
{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
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
# 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:
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
- User clicks "Set up MFA" in settings
- Show QR code (200×200px) with the TOTP secret URI
- User scans with authenticator app
- User enters 6-digit verification code
- On success → show 10 backup codes in a 2-column monospace grid
- "Copy all" and "Download .txt" buttons
- Checkbox: "I've saved my backup codes" — must be checked before dismissing
- After dismissal, force token refresh to get
mfa_enrolled: truein 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 inuseMfaStatushook andMfaSectioncomponent ✓MfaSetupResponse→ used inuseMfaSetuphook andhandleStartSetup✓BackupCodesResponse→ used inuseMfaBackupCodeshook and backup codes display ✓MfaRequiredError→ thrown inexperience-api.ts, caught inSignInPage.tsx✓APP_MFA_REQUIRED→ consistent acrossMfaEnforcementFilter,client.tsinterceptor, handoff doc ✓TotpVerifyRequest(secret, code)→ matches frontenduseMfaVerifypayload{ secret, code }✓