fix: prevent MFA lockout and move enrollment to modal dialog
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:
@@ -62,7 +62,7 @@ public class AccountController {
|
||||
@PostMapping("/mfa/totp/verify")
|
||||
public Map<String, Boolean> verifyTotp(@AuthenticationPrincipal Jwt jwt,
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -108,11 +108,22 @@ public class AccountService {
|
||||
new SecureRandom().nextBytes(secretBytes);
|
||||
String secret = base32Encode(secretBytes);
|
||||
|
||||
var result = logtoClient.createTotpVerification(userId, secret);
|
||||
String qrCode = result.containsKey("secretQrCode")
|
||||
? String.valueOf(result.get("secretQrCode"))
|
||||
: String.valueOf(result.getOrDefault("qrCode", ""));
|
||||
return new MfaSetupData(secret, qrCode);
|
||||
// Build otpauth URI locally — do NOT register with Logto yet.
|
||||
// The secret is only registered after the user verifies the 6-digit code.
|
||||
var user = logtoClient.getUser(userId);
|
||||
String email = user != null ? String.valueOf(user.getOrDefault("primaryEmail", "")) : "";
|
||||
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) {
|
||||
|
||||
@@ -269,8 +269,9 @@ export async function verifyBackupCode(code: string): Promise<string> {
|
||||
return data.verificationId;
|
||||
}
|
||||
|
||||
export async function submitMfa(verificationId: string): Promise<string> {
|
||||
await identifyUser(verificationId);
|
||||
export async function submitMfa(_verificationId: string): Promise<string> {
|
||||
// User is already identified from the initial sign-in step.
|
||||
// MFA verification is stored in the experience session — just submit.
|
||||
return submitInteraction();
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Card,
|
||||
FormField,
|
||||
Input,
|
||||
Modal,
|
||||
Spinner,
|
||||
useToast,
|
||||
} from '@cameleer/design-system';
|
||||
@@ -34,6 +35,8 @@ export function MfaSection() {
|
||||
const [codesSaved, setCodesSaved] = useState(false);
|
||||
const [confirmRemove, setConfirmRemove] = useState(false);
|
||||
|
||||
const modalOpen = !!setupData || !!codes;
|
||||
|
||||
async function handleStartSetup() {
|
||||
try {
|
||||
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() {
|
||||
if (!codes) return;
|
||||
navigator.clipboard.writeText(codes.join('\n'));
|
||||
@@ -114,150 +130,146 @@ export function MfaSection() {
|
||||
);
|
||||
}
|
||||
|
||||
// Backup codes display
|
||||
if (codes) {
|
||||
return (
|
||||
return (
|
||||
<>
|
||||
<Card title="Multi-Factor Authentication">
|
||||
<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={() => 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} />
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12 }}>
|
||||
<span className={styles.description} style={{ margin: 0 }}>Status:</span>
|
||||
{mfaStatus?.enrolled ? (
|
||||
<Badge label="Enrolled" color="success" />
|
||||
) : (
|
||||
<QRCodeSVG value={setupData.secretQrCode} size={200} />
|
||||
<Badge label="Not enrolled" color="auto" />
|
||||
)}
|
||||
</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 ? (
|
||||
<Badge label="Enrolled" color="success" />
|
||||
) : (
|
||||
<Badge label="Not enrolled" color="auto" />
|
||||
)}
|
||||
</div>
|
||||
{mfaStatus?.enrolled ? (
|
||||
<>
|
||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||
Your account is protected with a TOTP authenticator app.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
||||
Regenerate backup codes
|
||||
</Button>
|
||||
{confirmRemove ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Alert variant="error" title="This will disable MFA on your account." />
|
||||
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
||||
Confirm removal
|
||||
<>
|
||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||
Your account is protected with a TOTP authenticator app.
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Button variant="secondary" onClick={handleRegenerateCodes} loading={backupCodes.isPending}>
|
||||
Regenerate backup codes
|
||||
</Button>
|
||||
{confirmRemove ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
||||
<Alert variant="error" title="This will disable MFA on your account." />
|
||||
<Button variant="danger" onClick={handleRemove} loading={remove.isPending}>
|
||||
Confirm removal
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setConfirmRemove(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
||||
Remove MFA
|
||||
</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
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button variant="danger" onClick={() => setConfirmRemove(true)}>
|
||||
Remove MFA
|
||||
</Button>
|
||||
)}
|
||||
</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>
|
||||
</form>
|
||||
</>
|
||||
) : null}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user