diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java index 957a377..21718ec 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountController.java @@ -62,7 +62,7 @@ public class AccountController { @PostMapping("/mfa/totp/verify") public Map 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); } diff --git a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java index b6d6ab7..62483a2 100644 --- a/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java +++ b/src/main/java/net/siegeln/cameleer/saas/account/AccountService.java @@ -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) { diff --git a/ui/sign-in/src/experience-api.ts b/ui/sign-in/src/experience-api.ts index 137514f..e9c1651 100644 --- a/ui/sign-in/src/experience-api.ts +++ b/ui/sign-in/src/experience-api.ts @@ -269,8 +269,9 @@ export async function verifyBackupCode(code: string): Promise { return data.verificationId; } -export async function submitMfa(verificationId: string): Promise { - await identifyUser(verificationId); +export async function submitMfa(_verificationId: string): Promise { + // User is already identified from the initial sign-in step. + // MFA verification is stored in the experience session — just submit. return submitInteraction(); } diff --git a/ui/src/components/account/MfaSection.tsx b/ui/src/components/account/MfaSection.tsx index e9dde51..47052a4 100644 --- a/ui/src/components/account/MfaSection.tsx +++ b/ui/src/components/account/MfaSection.tsx @@ -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 ( + <> - - 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. - -
- {codes.map((code) => ( - {code} - ))} -
-
- - -
- -
- -
-
- ); - } - - // Setup flow — QR code + verification - if (setupData) { - return ( - -

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

-
- {setupData.secretQrCode.startsWith('data:') ? ( - TOTP QR Code +
+ Status: + {mfaStatus?.enrolled ? ( + ) : ( - + )}
-
- {setupData.secret} -
-
- - setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} - placeholder="Enter 6-digit code" - required - autoComplete="one-time-code" - /> - -
- - -
-
- - ); - } - - // Main view — enrolled or not - return ( - -
- Status: {mfaStatus?.enrolled ? ( - - ) : ( - - )} -
- {mfaStatus?.enrolled ? ( - <> -

- Your account is protected with a TOTP authenticator app. -

-
- - {confirmRemove ? ( -
- - + {confirmRemove ? ( +
+ + + +
+ ) : ( + -
+ + ) : ( + <> +

+ Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. +

+
+ +
+ + )} + + + + {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. + +
+ {codes.map((code) => ( + {code} + ))} +
+
+ + +
+ +
+ +
+ + ) : setupData ? ( + <> +

+ Scan this QR code with your authenticator app (Google Authenticator, Authy, 1Password, etc.), then enter the 6-digit code below. +

+
+ +
+
+ {setupData.secret} +
+
+ + setVerifyCode(e.target.value.replace(/\D/g, '').slice(0, 6))} + placeholder="Enter 6-digit code" + required + autoComplete="one-time-code" + /> + +
+ +
- ) : ( - - )} -
- - ) : ( - <> -

- Add an extra layer of security to your account by enabling multi-factor authentication with an authenticator app. -

-
- -
- - )} -
+ + + ) : null} + + ); }