fix: prevent MFA lockout and move enrollment to modal dialog
All checks were successful
CI / build (push) Successful in 1m58s
CI / docker (push) Successful in 1m47s

Three fixes for MFA enrollment and sign-in:

- Defer TOTP registration with Logto until after 6-digit code verification.
  Previously setupTotp() immediately registered the secret, so abandoning
  enrollment mid-way left MFA active without a working authenticator.
- Move entire MFA enrollment flow (QR code, verify, backup codes) into a
  Modal dialog instead of replacing the Card content inline.
- Fix sign-in MFA flow: submitMfa() no longer calls identifyUser() after
  TOTP verify — user is already identified, and passing the MFA
  verificationId to identification returned 422 ("method not activated").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-27 16:25:15 +02:00
parent 9231a1fc60
commit a5c20830a7
4 changed files with 168 additions and 144 deletions

View File

@@ -62,7 +62,7 @@ public class AccountController {
@PostMapping("/mfa/totp/verify") @PostMapping("/mfa/totp/verify")
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt, public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
@RequestBody TotpVerifyRequest request) { @RequestBody TotpVerifyRequest request) {
boolean ok = accountService.verifyTotpCode(request.secret(), request.code()); boolean ok = accountService.verifyAndEnableTotp(jwt.getSubject(), request.secret(), request.code());
return Map.of("verified", ok); return Map.of("verified", ok);
} }

View File

@@ -108,11 +108,22 @@ public class AccountService {
new SecureRandom().nextBytes(secretBytes); new SecureRandom().nextBytes(secretBytes);
String secret = base32Encode(secretBytes); String secret = base32Encode(secretBytes);
var result = logtoClient.createTotpVerification(userId, secret); // Build otpauth URI locally — do NOT register with Logto yet.
String qrCode = result.containsKey("secretQrCode") // The secret is only registered after the user verifies the 6-digit code.
? String.valueOf(result.get("secretQrCode")) var user = logtoClient.getUser(userId);
: String.valueOf(result.getOrDefault("qrCode", "")); String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
return new MfaSetupData(secret, qrCode); String label = email.isBlank() ? userId : email;
String otpauthUri = String.format(
"otpauth://totp/Cameleer:%s?secret=%s&issuer=Cameleer&algorithm=SHA1&digits=6&period=30",
label, secret);
return new MfaSetupData(secret, otpauthUri);
}
public boolean verifyAndEnableTotp(String userId, String secret, String code) {
if (!verifyTotpCode(secret, code)) return false;
logtoClient.createTotpVerification(userId, secret);
return true;
} }
public boolean verifyTotpCode(String secret, String code) { public boolean verifyTotpCode(String secret, String code) {

View File

@@ -269,8 +269,9 @@ export async function verifyBackupCode(code: string): Promise<string> {
return data.verificationId; return data.verificationId;
} }
export async function submitMfa(verificationId: string): Promise<string> { export async function submitMfa(_verificationId: string): Promise<string> {
await identifyUser(verificationId); // User is already identified from the initial sign-in step.
// MFA verification is stored in the experience session — just submit.
return submitInteraction(); return submitInteraction();
} }

View File

@@ -8,6 +8,7 @@ import {
Card, Card,
FormField, FormField,
Input, Input,
Modal,
Spinner, Spinner,
useToast, useToast,
} from '@cameleer/design-system'; } from '@cameleer/design-system';
@@ -34,6 +35,8 @@ export function MfaSection() {
const [codesSaved, setCodesSaved] = useState(false); const [codesSaved, setCodesSaved] = useState(false);
const [confirmRemove, setConfirmRemove] = useState(false); const [confirmRemove, setConfirmRemove] = useState(false);
const modalOpen = !!setupData || !!codes;
async function handleStartSetup() { async function handleStartSetup() {
try { try {
const data = await setup.mutateAsync(); const data = await setup.mutateAsync();
@@ -87,6 +90,19 @@ export function MfaSection() {
} }
} }
function handleModalClose() {
// During backup codes step, only allow close after confirming saved
if (codes) return;
// During setup, safe to cancel — TOTP is not registered until verified
setSetupData(null);
setVerifyCode('');
}
function handleBackupCodesDone() {
setCodes(null);
setCodesSaved(false);
}
function handleCopyAll() { function handleCopyAll() {
if (!codes) return; if (!codes) return;
navigator.clipboard.writeText(codes.join('\n')); navigator.clipboard.writeText(codes.join('\n'));
@@ -114,150 +130,146 @@ export function MfaSection() {
); );
} }
// Backup codes display return (
if (codes) { <>
return (
<Card title="Multi-Factor Authentication"> <Card title="Multi-Factor Authentication">
<Alert variant="warning" title="Save your backup codes"> <div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place. <span className={styles.description} style={{ margin: 0 }}>Status:</span>
</Alert> {mfaStatus?.enrolled ? (
<div style={{ <Badge label="Enrolled" color="success" />
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={() => setCodes(null)}>
Done
</Button>
</div>
</Card>
);
}
// Setup flow — QR code + verification
if (setupData) {
return (
<Card title="Multi-Factor Authentication">
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
{setupData.secretQrCode.startsWith('data:') ? (
<img src={setupData.secretQrCode} alt="TOTP QR Code" width={200} height={200} />
) : ( ) : (
<QRCodeSVG value={setupData.secretQrCode} size={200} /> <Badge label="Not enrolled" color="auto" />
)} )}
</div> </div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={() => { setSetupData(null); setVerifyCode(''); }}>
Cancel
</Button>
</div>
</form>
</Card>
);
}
// Main view — enrolled or not
return (
<Card title="Multi-Factor Authentication">
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
{mfaStatus?.enrolled ? ( {mfaStatus?.enrolled ? (
<Badge label="Enrolled" color="success" /> <>
) : ( <p className={styles.description} style={{ marginTop: 0 }}>
<Badge label="Not enrolled" color="auto" /> Your account is protected with a TOTP authenticator app.
)} </p>
</div> <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{mfaStatus?.enrolled ? ( <Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
<> Regenerate backup codes
<p className={styles.description} style={{ marginTop: 0 }}> </Button>
Your account is protected with a TOTP authenticator app. {confirmRemove ? (
</p> <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}> <Alert variant="error" title="This will disable MFA on your account." />
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}> <Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
Regenerate backup codes Confirm removal
</Button> </Button>
{confirmRemove ? ( <Button variant="secondary" onClick={() => setConfirmRemove(false)}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}> Cancel
<Alert variant="error" title="This will disable MFA on your account." /> </Button>
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}> </div>
Confirm removal ) : (
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
Remove MFA
</Button> </Button>
<Button variant="secondary" onClick={() => setConfirmRemove(false)}> )}
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
<Modal
open={modalOpen}
onClose={handleModalClose}
title={codes ? 'Save your backup codes' : 'Set up authenticator'}
size="md"
>
{codes ? (
<>
<Alert variant="warning" title="Save your backup codes">
These codes can be used to sign in if you lose access to your authenticator app. Each code can only be used once. Store them in a safe place.
</Alert>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px 24px',
marginTop: 16,
padding: '16px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.875rem',
}}>
{codes.map((code) => (
<span key={code}>{code}</span>
))}
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 12 }}>
<Button variant="secondary" onClick={handleCopyAll}>Copy all</Button>
<Button variant="secondary" onClick={handleDownload}>Download .txt</Button>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 16, fontSize: '0.875rem', cursor: 'pointer' }}>
<input type="checkbox" checked={codesSaved} onChange={(e) => setCodesSaved(e.target.checked)} />
I've saved my backup codes
</label>
<div style={{ marginTop: 12 }}>
<Button variant="primary" disabled={!codesSaved} onClick={handleBackupCodesDone}>
Done
</Button>
</div>
</>
) : setupData ? (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below.
</p>
<div style={{ display: 'flex', justifyContent: 'center', padding: '16px 0' }}>
<QRCodeSVG value={setupData.secretQrCode} size={200} />
</div>
<div style={{
textAlign: 'center',
padding: '8px 12px',
background: 'var(--bg-inset)',
border: '1px solid var(--border)',
borderRadius: 6,
fontFamily: 'var(--font-mono, monospace)',
fontSize: '0.75rem',
wordBreak: 'break-all',
marginBottom: 16,
}}>
{setupData.secret}
</div>
<form onSubmit={handleVerify} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
<FormField label="Verification code" htmlFor="mfa-code">
<Input
id="mfa-code"
type="text"
inputMode="numeric"
maxLength={6}
pattern="[0-9]{6}"
value={verifyCode}
onChange={(e) => setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="Enter 6-digit code"
required
autoComplete="one-time-code"
/>
</FormField>
<div style={{ display: 'flex', gap: 8 }}>
<Button type="submit" variant="primary" loading={verify.isPending || backupCodes.isPending} disabled={verifyCode.length !== 6}>
Verify & Enable
</Button>
<Button type="button" variant="secondary" onClick={handleModalClose}>
Cancel Cancel
</Button> </Button>
</div> </div>
) : ( </form>
<Button variant="danger" onClick={() => setConfirmRemove(true)}> </>
Remove MFA ) : null}
</Button> </Modal>
)} </>
</div>
</>
) : (
<>
<p className={styles.description} style={{ marginTop: 0 }}>
Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app.
</p>
<div>
<Button variant="primary" onClick={handleStartSetup} loading={setup.isPending}>
Set up authenticator app
</Button>
</div>
</>
)}
</Card>
); );
} }