From 65d9d41462ccb752e048038d93f4d15aff717554 Mon Sep 17 00:00:00 2001 From: Addison LeClair Date: Sat, 19 Sep 2026 23:58:52 +0000 Subject: [PATCH 1/4] feat(dashboard): email-verified TOTP reset flow --- .../dialogs/totp-reset-dialog.svelte | 528 ++++++++++++++++++ .../dialogs/user-settings-dialog.svelte | 67 ++- .../src/lib/emails/totp-reset-code.svelte | 53 ++ .../src/lib/remote/two-factor.remote.ts | 77 +++ apps/dashboard/src/lib/server/totp-reset.ts | 136 +++++ apps/dashboard/src/lib/totp-reset-flow.ts | 35 ++ apps/dashboard/tests/totp-reset.test.ts | 81 +++ 7 files changed, 962 insertions(+), 15 deletions(-) create mode 100644 apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte create mode 100644 apps/dashboard/src/lib/emails/totp-reset-code.svelte create mode 100644 apps/dashboard/src/lib/server/totp-reset.ts create mode 100644 apps/dashboard/src/lib/totp-reset-flow.ts create mode 100644 apps/dashboard/tests/totp-reset.test.ts diff --git a/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte new file mode 100644 index 00000000..1387a7e8 --- /dev/null +++ b/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte @@ -0,0 +1,528 @@ + + + { + if (!value) resetFlow(); + }} +> + + + Reset authenticator app + {description} + + +

+ Step {stepNumber} of {TOTP_RESET_FLOW_STEP_COUNT} · {stepLabel} +

+ + {#if step === 'email'} +
{ + e.preventDefault(); + void verifyEmail(); + }} + > +
+ + + {#if codeSent} +

We sent a code to {emailTarget}.

+ {/if} + {#if emailError} +

{emailError}

+ {/if} +
+ + + + + +
+ {:else if step === 'choice'} +
+
+ + +
+ + + + + +
+ {:else if step === 'reset'} +
{ + e.preventDefault(); + void confirmResetPassword(); + }} + > +
+ + + {#if confirmError} +

{confirmError}

+ {/if} +
+ + + + + +
+ {:else if step === 'setup' && totpUri} +
+
+
+ Authenticator app QR code +
+
+ +
+ +
+ + {secretKey} + + +
+
+ + {#if backupCodes.length > 0} +
+
+ + +
+
+ {#each backupCodes as code (code)} + {code} + {/each} +
+
+ {/if} + +
+ + e.key === 'Enter' && verifySetup()} + /> + {#if setupError} +

{setupError}

+ {/if} +
+ + +
+ {:else if step === 'disable'} +
{ + e.preventDefault(); + void confirmDisable(); + }} + > +
+

+ After disabling, signing in will only require your email and password. +

+
+ +
+ + + {#if confirmError} +

{confirmError}

+ {/if} +
+ + + + + +
+ {/if} +
+
diff --git a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte index 5843c376..e9032c06 100644 --- a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte +++ b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte @@ -12,6 +12,7 @@ import { Skeleton } from '$lib/components/ui/skeleton'; import { authClient } from '$lib/auth-client'; import TotpOnboardingDialog from './totp-onboarding-dialog.svelte'; + import TotpResetDialog from './totp-reset-dialog.svelte'; import PasskeyOnboardingDialog from './passkey-onboarding-dialog.svelte'; import PasswordVerificationDialog from './password-verification-dialog.svelte'; import { @@ -212,6 +213,7 @@ let totpDialogOpen = $state(false); let passkeyDialogOpen = $state(false); let totpDisableDialogOpen = $state(false); + let totpResetDialogOpen = $state(false); let totpDisablePassword = $state(''); type TotpDisableMethod = 'totp' | 'backupCode'; let totpDisableMethod = $state('totp'); @@ -688,21 +690,33 @@ {#if twoFactorEnabled} - +
+ + +
{:else} + - - - - {:else if step === 'choice'} -
-
- - -
- - - - - -
- {:else if step === 'reset'} -
{ - e.preventDefault(); - void confirmResetPassword(); - }} - > -
- - - {#if confirmError} -

{confirmError}

- {/if} -
- - - - - -
- {:else if step === 'setup' && totpUri} -
-
-
- Authenticator app QR code -
-
- -
- -
- - {secretKey} - - -
-
- - {#if backupCodes.length > 0} -
-
- - -
-
- {#each backupCodes as code (code)} - {code} - {/each} -
-
- {/if} - -
- - e.key === 'Enter' && verifySetup()} - /> - {#if setupError} -

{setupError}

- {/if} -
- - -
- {:else if step === 'disable'} -
{ - e.preventDefault(); - void confirmDisable(); - }} - > -
-

- After disabling, signing in will only require your email and password. -

-
- -
- - - {#if confirmError} -

{confirmError}

- {/if} -
- - - - - -
- {/if} + (open = false)} /> diff --git a/apps/dashboard/src/lib/components/totp-reset-flow.svelte b/apps/dashboard/src/lib/components/totp-reset-flow.svelte new file mode 100644 index 00000000..7a894d4c --- /dev/null +++ b/apps/dashboard/src/lib/components/totp-reset-flow.svelte @@ -0,0 +1,557 @@ + + +
+

{description}

+

+ Step {stepNumber} of {TOTP_RESET_FLOW_STEP_COUNT} · {stepLabel} +

+ + {#if step === 'email'} +
{ + e.preventDefault(); + void verifyEmail(); + }} + > +
+ + + {#if codeSent} +

We sent a code to {emailTarget}.

+ {/if} + {#if emailError} +

{emailError}

+ {/if} +
+ +
+ + +
+
+ {:else if step === 'choice'} +
+
+ + +
+ +
+ {#if onCancel} + + {/if} + +
+
+ {:else if step === 'reset'} +
{ + e.preventDefault(); + void confirmResetPassword(); + }} + > +
+ + + {#if confirmError} +

{confirmError}

+ {/if} +
+ +
+ + +
+
+ {:else if step === 'setup' && totpUri} +
+
+
+ Authenticator app QR code +
+
+ +
+ +
+ + {secretKey} + + +
+
+ + {#if backupCodes.length > 0} +
+
+ + +
+
+ {#each backupCodes as code (code)} + {code} + {/each} +
+
+ {/if} + +
+ + e.key === 'Enter' && verifySetup()} + /> + {#if setupError} +

{setupError}

+ {/if} +
+ + +
+ {:else if step === 'disable'} +
{ + e.preventDefault(); + void confirmDisable(); + }} + > +
+

+ After disabling, signing in will only require your email and password. +

+
+ +
+ + + {#if confirmError} +

{confirmError}

+ {/if} +
+ +
+ + +
+
+ {/if} +
diff --git a/apps/dashboard/src/lib/remote/two-factor.remote.ts b/apps/dashboard/src/lib/remote/two-factor.remote.ts index 04542544..d910df74 100644 --- a/apps/dashboard/src/lib/remote/two-factor.remote.ts +++ b/apps/dashboard/src/lib/remote/two-factor.remote.ts @@ -12,8 +12,11 @@ import { beginTotpReset, clearTotpResetGrant, normalizeTotpResetChoice, + removeTotpWithVerifiedPassword, requireTotpResetGrant, - verifyTotpResetCode as verifyTotpResetEmailCode + resolvePendingTwoFactorUser, + verifyTotpResetCode as verifyTotpResetEmailCode, + type TotpResetUser } from '$lib/server/totp-reset'; const CODE_LENGTH = 6; @@ -66,12 +69,26 @@ export const disableTwoFactorWithVerification = command(disableTwoFactorParams, }); }); +async function resolveTotpResetUser( + event: ReturnType, + db: ReturnType +): Promise { + if (event.locals.user) { + const { id, email, name } = event.locals.user; + return { id, email, name, hasSession: true }; + } + + const pending = await resolvePendingTwoFactorUser(event, db, getRuntimeEnv().BETTER_AUTH_SECRET); + if (!pending) error(401, 'Authentication required'); + return { ...pending, hasSession: false }; +} + export const sendTotpResetCode = command(async () => { const event = getRequestEvent(); - const user = event.locals.user; - if (!user) error(401, 'Authentication required'); + const db = initDrizzle(); + const user = await resolveTotpResetUser(event, db); - const code = await beginTotpReset(initDrizzle(), user.id); + const code = await beginTotpReset(db, user.id); await sendRenderedEmail({ component: TotpResetCodeEmail, @@ -85,10 +102,10 @@ const verifyTotpResetParams = type({ code: 'string' }); export const verifyTotpResetCode = command(verifyTotpResetParams, async (params) => { const event = getRequestEvent(); - const user = event.locals.user; - if (!user) error(401, 'Authentication required'); + const db = initDrizzle(); + const user = await resolveTotpResetUser(event, db); - await verifyTotpResetEmailCode(initDrizzle(), user.id, params.code); + await verifyTotpResetEmailCode(db, user.id, params.code); return { verified: true }; }); @@ -97,17 +114,39 @@ const confirmTotpResetParams = type({ password: 'string', choice: 'string' }); export const confirmTotpResetChoice = command(confirmTotpResetParams, async (params) => { const event = getRequestEvent(); - const user = event.locals.user; - if (!user) error(401, 'Authentication required'); + const db = initDrizzle(); + const user = await resolveTotpResetUser(event, db); const choice = normalizeTotpResetChoice(params.choice); if (!choice) error(400, 'Choose whether to reset or disable two-factor authentication.'); if (!params.password) error(400, 'Enter your current password.'); - const db = initDrizzle(); await requireTotpResetGrant(db, user.id); const auth = initAuth(); + + if (!user.hasSession) { + const authContext = await auth.$context; + await removeTotpWithVerifiedPassword(db, user.id, params.password, (hash, password) => + authContext.password.verify({ hash, password }) + ); + await clearTotpResetGrant(db, user.id); + await sendSecurityAlertEmail({ + to: user.email, + userName: user.name, + alertType: + choice === 'reset' + ? 'Two-factor authentication reset' + : 'Two-factor authentication disabled', + message: + choice === 'reset' + ? 'Authenticator app two-factor authentication was reset for your Stack account during sign-in. The old authenticator no longer works. Finish setting up the new one to turn two-factor authentication back on.' + : 'Authenticator app two-factor authentication was disabled for your Stack account during sign-in.', + actionUrl: event.url.origin + }); + return { choice, requiresSignIn: true, totpURI: null, backupCodes: [] as string[] }; + } + const headers = new Headers(event.request.headers); headers.set(VERIFIED_2FA_DISABLE_HEADER, getRuntimeEnv().BETTER_AUTH_SECRET); @@ -115,19 +154,33 @@ export const confirmTotpResetChoice = command(confirmTotpResetParams, async (par headers, body: { password: params.password } }); + + if (choice === 'disable') { + await clearTotpResetGrant(db, user.id); + await sendSecurityAlertEmail({ + to: user.email, + userName: user.name, + alertType: 'Two-factor authentication disabled', + message: 'Authenticator app two-factor authentication was disabled for your Stack account.', + actionUrl: event.url.origin + }); + return { choice, requiresSignIn: false, totpURI: null, backupCodes: [] as string[] }; + } + + const setup = await auth.api.enableTwoFactor({ + headers: event.request.headers, + body: { password: params.password, issuer: 'Fyra Stack' } + }); await clearTotpResetGrant(db, user.id); await sendSecurityAlertEmail({ to: user.email, userName: user.name, - alertType: - choice === 'reset' ? 'Two-factor authentication reset' : 'Two-factor authentication disabled', + alertType: 'Two-factor authentication reset', message: - choice === 'reset' - ? 'Authenticator app two-factor authentication was reset for your Stack account. Set up your new authenticator app to finish securing your account.' - : 'Authenticator app two-factor authentication was disabled for your Stack account.', + 'Authenticator app two-factor authentication was reset for your Stack account. The old authenticator no longer works. Finish setting up the new one in Stack to turn two-factor authentication back on.', actionUrl: event.url.origin }); - return { choice }; + return { choice, requiresSignIn: false, totpURI: setup.totpURI, backupCodes: setup.backupCodes }; }); diff --git a/apps/dashboard/src/lib/server/auth.ts b/apps/dashboard/src/lib/server/auth.ts index 38ca21e5..3dcd5f6a 100644 --- a/apps/dashboard/src/lib/server/auth.ts +++ b/apps/dashboard/src/lib/server/auth.ts @@ -24,6 +24,7 @@ const PENDING_PASSKEY_HINT_COOKIE = 'pending_passkey_2fa_hint'; const PENDING_PASSKEY_MAX_AGE = 600; const PASSKEY_PASSWORD_CHANGE_MAX_AGE_MS = 60 * 1000; export const VERIFIED_2FA_DISABLE_HEADER = 'x-fyra-verified-2fa-disable'; +export const TOTP_SECRET_KEY_VERSION = 1; function passwordChangePasskeyIdentifier(userId: string) { return `password-change-passkey:${userId}`; @@ -123,6 +124,7 @@ function buildAuth() { appName: 'Stack', baseURL, secret: env.BETTER_AUTH_SECRET, + secrets: [{ version: TOTP_SECRET_KEY_VERSION, value: env.BETTER_AUTH_SECRET }], database: drizzleAdapter(db, { provider: 'pg' }), advanced: { database: { diff --git a/apps/dashboard/src/lib/server/totp-reset.ts b/apps/dashboard/src/lib/server/totp-reset.ts index 24682907..463b056c 100644 --- a/apps/dashboard/src/lib/server/totp-reset.ts +++ b/apps/dashboard/src/lib/server/totp-reset.ts @@ -1,15 +1,26 @@ -import { error } from '@sveltejs/kit'; +import { error, type RequestEvent } from '@sveltejs/kit'; import { and, eq, gt } from 'drizzle-orm'; -import { twoFactor, verification } from './db/auth.schema'; +import { account, twoFactor, user, verification } from './db/auth.schema'; import type { initDrizzle } from './db'; import { ulid } from './id'; export const TOTP_RESET_CODE_LENGTH = 6; export const TOTP_RESET_CODE_TTL_MS = 10 * 60 * 1000; export const TOTP_RESET_GRANT_TTL_MS = 10 * 60 * 1000; +export const TOTP_RESET_MAX_ATTEMPTS = 5; +export const TOTP_RESET_RESEND_INTERVAL_MS = 60 * 1000; +export const TOTP_SECRET_ENVELOPE_PREFIX = '$ba$'; + +const pendingTwoFactorCookieNames = ['__Secure-better-auth.two_factor', 'better-auth.two_factor']; export type TotpResetChoice = 'reset' | 'disable'; +export type TotpResetCodeRecord = { hash: string; attempts: number }; + +export type TotpResetUser = { id: string; email: string; name: string }; + +type VerifyPassword = (hash: string, password: string) => Promise; + type Db = ReturnType; export function normalizeTotpResetChoice(value: string): TotpResetChoice | null { @@ -49,6 +60,123 @@ export async function hashTotpResetCode(userId: string, code: string) { return Array.from(new Uint8Array(hash), (byte) => byte.toString(16).padStart(2, '0')).join(''); } +export function encodeTotpResetCodeRecord(record: TotpResetCodeRecord) { + return JSON.stringify(record); +} + +export function parseTotpResetCodeRecord(value: string): TotpResetCodeRecord | null { + try { + const parsed: unknown = JSON.parse(value); + if ( + typeof parsed === 'object' && + parsed !== null && + 'hash' in parsed && + typeof parsed.hash === 'string' && + 'attempts' in parsed && + typeof parsed.attempts === 'number' && + Number.isInteger(parsed.attempts) && + parsed.attempts >= 0 + ) { + return { hash: parsed.hash, attempts: parsed.attempts }; + } + } catch { + return null; + } + return null; +} + +export function canResendTotpResetCode(lastSentAt: Date | null, now = new Date()) { + if (!lastSentAt) return true; + return now.getTime() - lastSentAt.getTime() >= TOTP_RESET_RESEND_INTERVAL_MS; +} + +export function totpResetAttemptsExhausted(record: TotpResetCodeRecord) { + return record.attempts >= TOTP_RESET_MAX_ATTEMPTS; +} + +export function isLegacyTotpSecret(encryptedSecret: string) { + return !encryptedSecret.startsWith(TOTP_SECRET_ENVELOPE_PREFIX); +} + +async function verifySignedCookieValue(raw: string, secret: string) { + const separator = raw.lastIndexOf('.'); + if (separator < 1) return null; + const value = raw.slice(0, separator); + const signature = raw.slice(separator + 1); + if (signature.length !== 44 || !signature.endsWith('=')) return null; + + const encoder = new TextEncoder(); + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['verify'] + ); + const signatureBytes = Uint8Array.from(atob(signature), (char) => char.charCodeAt(0)); + const valid = await crypto.subtle.verify('HMAC', key, signatureBytes, encoder.encode(value)); + return valid ? value : null; +} + +export async function resolvePendingTwoFactorUser( + event: RequestEvent, + db: Db, + secret: string +): Promise { + for (const cookieName of pendingTwoFactorCookieNames) { + const raw = event.cookies.get(cookieName); + if (!raw) continue; + + const identifier = await verifySignedCookieValue(raw, secret); + if (!identifier) continue; + + const [pending] = await db + .select({ userId: verification.value }) + .from(verification) + .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date()))) + .limit(1); + if (!pending) continue; + + const [pendingUser] = await db + .select({ id: user.id, email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, pending.userId)) + .limit(1); + if (pendingUser) return pendingUser; + } + + return null; +} + +export async function requiresTotpReset(db: Db, userId: string) { + const [registeredTotp] = await db + .select({ secret: twoFactor.secret }) + .from(twoFactor) + .where(eq(twoFactor.userId, userId)) + .limit(1); + + return !!registeredTotp && isLegacyTotpSecret(registeredTotp.secret); +} + +export async function removeTotpWithVerifiedPassword( + db: Db, + userId: string, + password: string, + verifyPassword: VerifyPassword +) { + const [credential] = await db + .select({ password: account.password }) + .from(account) + .where(and(eq(account.userId, userId), eq(account.providerId, 'credential'))) + .limit(1); + + if (!credential?.password || !(await verifyPassword(credential.password, password))) + error(400, 'Incorrect password.'); + + await db.delete(twoFactor).where(eq(twoFactor.userId, userId)); + await db.update(user).set({ twoFactorEnabled: false }).where(eq(user.id, userId)); +} + export async function requireExistingTotp(db: Db, userId: string) { const [registeredTotp] = await db .select({ id: twoFactor.id }) @@ -63,9 +191,21 @@ export async function requireExistingTotp(db: Db, userId: string) { export async function beginTotpReset(db: Db, userId: string) { await requireExistingTotp(db, userId); - const code = generateTotpResetCode(); const identifier = totpResetCodeIdentifier(userId); - const value = await hashTotpResetCode(userId, code); + const [existing] = await db + .select({ createdAt: verification.createdAt }) + .from(verification) + .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date()))) + .limit(1); + + if (existing && !canResendTotpResetCode(existing.createdAt)) + error(429, 'A code was sent recently. Check your email or wait a minute to request another.'); + + const code = generateTotpResetCode(); + const value = encodeTotpResetCodeRecord({ + hash: await hashTotpResetCode(userId, code), + attempts: 0 + }); await db.delete(verification).where(eq(verification.identifier, identifier)); await db.insert(verification).values({ @@ -86,21 +226,35 @@ export async function verifyTotpResetCode(db: Db, userId: string, code: string) error(400, 'Enter the verification code from your email.'); const identifier = totpResetCodeIdentifier(userId); - const value = await hashTotpResetCode(userId, normalizedCode); - const [record] = await db - .select({ id: verification.id }) + const [row] = await db + .select({ id: verification.id, value: verification.value }) .from(verification) - .where( - and( - eq(verification.identifier, identifier), - eq(verification.value, value), - gt(verification.expiresAt, new Date()) - ) - ) + .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date()))) .limit(1); - if (!record) error(400, 'Invalid or expired verification code.'); - await db.delete(verification).where(eq(verification.id, record.id)); + const record = row ? parseTotpResetCodeRecord(row.value) : null; + if (!row || !record) error(400, 'Invalid or expired verification code.'); + + if (totpResetAttemptsExhausted(record)) { + await db.delete(verification).where(eq(verification.id, row.id)); + error(400, 'Too many incorrect attempts. Request a new verification code.'); + } + + const submittedHash = await hashTotpResetCode(userId, normalizedCode); + if (submittedHash !== record.hash) { + const failed = { hash: record.hash, attempts: record.attempts + 1 }; + if (totpResetAttemptsExhausted(failed)) { + await db.delete(verification).where(eq(verification.id, row.id)); + error(400, 'Too many incorrect attempts. Request a new verification code.'); + } + await db + .update(verification) + .set({ value: encodeTotpResetCodeRecord(failed) }) + .where(eq(verification.id, row.id)); + error(400, 'Invalid or expired verification code.'); + } + + await db.delete(verification).where(eq(verification.id, row.id)); const grantIdentifier = totpResetGrantIdentifier(userId); await db.delete(verification).where(eq(verification.identifier, grantIdentifier)); diff --git a/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts b/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts index 30fc6e1b..06e3f812 100644 --- a/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts +++ b/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts @@ -1,13 +1,26 @@ import { redirect } from '@sveltejs/kit'; +import { initDrizzle } from '$lib/server/db'; +import { getRuntimeEnv } from '$lib/server/env'; +import { requiresTotpReset, resolvePendingTwoFactorUser } from '$lib/server/totp-reset'; import type { PageServerLoad } from './$types'; const pendingPasskeyHintCookie = 'pending_passkey_2fa_hint'; -export const load: PageServerLoad = ({ cookies, url }) => { +export const load: PageServerLoad = async (event) => { + const { cookies, url } = event; if (cookies.get(pendingPasskeyHintCookie) === 'passkey') { throw redirect(303, `/login/two-factor/passkey${url.search}`); } const redirectTo = url.searchParams.get('redirectTo') ?? '/'; - return { redirectTo }; + + const db = initDrizzle(); + const pendingUser = await resolvePendingTwoFactorUser( + event, + db, + getRuntimeEnv().BETTER_AUTH_SECRET + ); + const resetRequired = pendingUser ? await requiresTotpReset(db, pendingUser.id) : false; + + return { redirectTo, resetRequired, resetEmail: resetRequired ? pendingUser?.email : null }; }; diff --git a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte index ba57e572..dde804d3 100644 --- a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte +++ b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte @@ -3,6 +3,7 @@ import { authClient } from '$lib/auth-client'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; + import TotpResetFlow from '$lib/components/totp-reset-flow.svelte'; import Loader2 from '~icons/lucide/loader-2'; import AlertCircle from '~icons/nucleo/alert-circle'; import ShieldCheck from '~icons/nucleo/shield-check'; @@ -10,6 +11,11 @@ let { data }: { data: PageData } = $props(); const redirectTo: string = $derived(data.redirectTo ?? '/'); + const passkeyHref = $derived( + redirectTo === '/' + ? '/login/two-factor/passkey' + : `/login/two-factor/passkey?redirectTo=${encodeURIComponent(redirectTo)}` + ); let code = $state(''); let error = $state(''); @@ -47,55 +53,80 @@ Stack -
-
-
- + {#if data.resetRequired} +
+
+
+ +
+

Set up your authenticator again

+

+ Codes from your existing authenticator app no longer work. Verify your email to set up a + new one or turn two-factor authentication off. +

-

Two-Factor Authentication

-

- Enter the verification code from your authenticator app. -

-
- {#if error} -
- - {error} + goto(redirectTo)} + onPasskeyChallenge={() => goto(passkeyHref)} + /> +
+ {:else} +
+
+
+ +
+

Two-Factor Authentication

+

+ Enter the verification code from your authenticator app. +

- {/if} -
{ - e.preventDefault(); - handleVerify(); - }} - class="space-y-3" - > - + {#if error} +
+ + {error} +
+ {/if} - -
+
{ + e.preventDefault(); + handleVerify(); + }} + class="space-y-3" + > + -

- Lost your device? Use a backup code from when you set up 2FA. -

-
+ + + +

+ Lost your device? Use a backup code from when you set up 2FA. +

+
+ {/if}
diff --git a/apps/dashboard/tests/totp-reset.test.ts b/apps/dashboard/tests/totp-reset.test.ts deleted file mode 100644 index 69fd5ef0..00000000 --- a/apps/dashboard/tests/totp-reset.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { expect, test } from '@playwright/test'; -import { - TOTP_RESET_CODE_LENGTH, - TOTP_RESET_CODE_TTL_MS, - TOTP_RESET_GRANT_TTL_MS, - generateTotpResetCode, - hashTotpResetCode, - normalizeTotpResetChoice, - normalizeTotpResetCode, - totpResetCodeIdentifier, - totpResetGrantIdentifier -} from '../src/lib/server/totp-reset'; -import { - TOTP_RESET_FLOW_STEP_COUNT, - TOTP_RESET_UI_CHOICES, - totpResetStepLabel, - totpResetStepNumber -} from '../src/lib/totp-reset-flow'; - -test('totp reset code length and lifetimes match the email code conventions', () => { - expect(TOTP_RESET_CODE_LENGTH).toBe(6); - expect(TOTP_RESET_CODE_TTL_MS).toBe(10 * 60 * 1000); - expect(TOTP_RESET_GRANT_TTL_MS).toBe(10 * 60 * 1000); -}); - -test('totp reset codes normalize to digits only', () => { - expect(normalizeTotpResetCode('123 456')).toBe('123456'); - expect(normalizeTotpResetCode('a1b2c3')).toBe('123'); - expect(normalizeTotpResetCode('')).toBe(''); -}); - -test('totp reset choices accept reset and disable spellings', () => { - expect(normalizeTotpResetChoice('reset')).toBe('reset'); - expect(normalizeTotpResetChoice('reset-totp')).toBe('reset'); - expect(normalizeTotpResetChoice('disable')).toBe('disable'); - expect(normalizeTotpResetChoice('disable-totp')).toBe('disable'); - expect(normalizeTotpResetChoice('totp')).toBeNull(); - expect(normalizeTotpResetChoice('')).toBeNull(); -}); - -test('totp reset storage identifiers are scoped per user and purpose', () => { - expect(totpResetCodeIdentifier('user-1')).toBe('totp-reset:user-1'); - expect(totpResetGrantIdentifier('user-1')).toBe('totp-reset-verified:user-1'); - expect(totpResetCodeIdentifier('user-1')).not.toBe(totpResetCodeIdentifier('user-2')); - expect(totpResetCodeIdentifier('user-1')).not.toBe(totpResetGrantIdentifier('user-1')); -}); - -test('totp reset code hashes are stable per user and opaque', async () => { - const first = await hashTotpResetCode('user-1', '123456'); - const second = await hashTotpResetCode('user-1', '123456'); - - expect(first).toBe(second); - expect(first).toMatch(/^[0-9a-f]{64}$/); - expect(first).not.toContain('123456'); - expect(await hashTotpResetCode('user-2', '123456')).not.toBe(first); - expect(await hashTotpResetCode('user-1', '654321')).not.toBe(first); -}); - -test('generated totp reset codes are six digits', () => { - for (let i = 0; i < 25; i += 1) { - expect(generateTotpResetCode()).toMatch(/^[0-9]{6}$/); - } -}); - -test('totp reset ui choices round-trip through server normalization', () => { - expect(TOTP_RESET_UI_CHOICES).toEqual(['reset-totp', 'disable-totp']); - expect(TOTP_RESET_UI_CHOICES.map(normalizeTotpResetChoice)).toEqual(['reset', 'disable']); -}); - -test('totp reset flow exposes email, choice, and finish steps in order', () => { - const steps = ['email', 'choice', 'reset', 'setup', 'disable'] as const; - - expect(TOTP_RESET_FLOW_STEP_COUNT).toBe(3); - expect(steps.map(totpResetStepNumber)).toEqual([1, 2, 3, 3, 3]); - - for (const step of steps) { - expect(totpResetStepLabel(step).length).toBeGreaterThan(0); - } - - expect(new Set(steps.map(totpResetStepLabel)).size).toBe(steps.length); -}); From 956d608f82b58bee270b2fe35388df61d8ed506b Mon Sep 17 00:00:00 2001 From: Addison LeClair Date: Sun, 20 Sep 2026 14:16:57 +0000 Subject: [PATCH 3/4] fix(totp): handle reset sign-in email and center flow - Return the affected user email when reset requires sign-in - Mask reset email on the TOTP page - Update migration copy and support centered flow text --- .../src/lib/components/totp-reset-flow.svelte | 18 ++++++++------ .../src/lib/remote/two-factor.remote.ts | 24 ++++++++++++++++--- apps/dashboard/src/lib/totp-reset-flow.ts | 9 +++++++ .../login/two-factor/totp/+page.server.ts | 7 +++++- .../routes/login/two-factor/totp/+page.svelte | 5 ++-- 5 files changed, 50 insertions(+), 13 deletions(-) diff --git a/apps/dashboard/src/lib/components/totp-reset-flow.svelte b/apps/dashboard/src/lib/components/totp-reset-flow.svelte index 7a894d4c..a65f4851 100644 --- a/apps/dashboard/src/lib/components/totp-reset-flow.svelte +++ b/apps/dashboard/src/lib/components/totp-reset-flow.svelte @@ -27,6 +27,7 @@ type Props = { active?: boolean; + centered?: boolean; userEmail?: string | null; onComplete?: (choice: TotpResetCompletedChoice) => void; onCancel?: () => void; @@ -35,6 +36,7 @@ let { active = true, + centered = false, userEmail = null, onComplete, onCancel, @@ -155,8 +157,8 @@ step = choice === 'reset-totp' ? 'reset' : 'disable'; } - async function signInAfterReset(): Promise<'session' | 'passkey' | null> { - const { data, error } = await authClient.signIn.email({ email: userEmail ?? '', password }); + async function signInAfterReset(email: string): Promise<'session' | 'passkey' | null> { + const { data, error } = await authClient.signIn.email({ email, password }); if (error) { confirmError = error.message ?? 'Two-factor authentication was reset, but signing in failed.'; return null; @@ -194,7 +196,7 @@ showSetup(result.totpURI, result.backupCodes); return; } - const outcome = await signInAfterReset(); + const outcome = await signInAfterReset(result.signInEmail ?? ''); if (outcome === 'passkey') { onPasskeyChallenge?.(); return; @@ -229,7 +231,7 @@ try { const result = await confirmTotpResetChoice({ password, choice: 'disable-totp' }); if (result.requiresSignIn) { - const outcome = await signInAfterReset(); + const outcome = await signInAfterReset(result.signInEmail ?? ''); if (outcome === 'passkey') { onPasskeyChallenge?.(); return; @@ -296,8 +298,8 @@
-

{description}

-

+

{description}

+

Step {stepNumber} of {TOTP_RESET_FLOW_STEP_COUNT} · {stepLabel}

@@ -321,7 +323,9 @@ maxlength={6} /> {#if codeSent} -

We sent a code to {emailTarget}.

+

+ We sent a code to {emailTarget}. +

{/if} {#if emailError}

{emailError}

diff --git a/apps/dashboard/src/lib/remote/two-factor.remote.ts b/apps/dashboard/src/lib/remote/two-factor.remote.ts index d910df74..d02f0edf 100644 --- a/apps/dashboard/src/lib/remote/two-factor.remote.ts +++ b/apps/dashboard/src/lib/remote/two-factor.remote.ts @@ -144,7 +144,13 @@ export const confirmTotpResetChoice = command(confirmTotpResetParams, async (par : 'Authenticator app two-factor authentication was disabled for your Stack account during sign-in.', actionUrl: event.url.origin }); - return { choice, requiresSignIn: true, totpURI: null, backupCodes: [] as string[] }; + return { + choice, + requiresSignIn: true, + signInEmail: user.email, + totpURI: null, + backupCodes: [] as string[] + }; } const headers = new Headers(event.request.headers); @@ -164,7 +170,13 @@ export const confirmTotpResetChoice = command(confirmTotpResetParams, async (par message: 'Authenticator app two-factor authentication was disabled for your Stack account.', actionUrl: event.url.origin }); - return { choice, requiresSignIn: false, totpURI: null, backupCodes: [] as string[] }; + return { + choice, + requiresSignIn: false, + signInEmail: null, + totpURI: null, + backupCodes: [] as string[] + }; } const setup = await auth.api.enableTwoFactor({ @@ -182,5 +194,11 @@ export const confirmTotpResetChoice = command(confirmTotpResetParams, async (par actionUrl: event.url.origin }); - return { choice, requiresSignIn: false, totpURI: setup.totpURI, backupCodes: setup.backupCodes }; + return { + choice, + requiresSignIn: false, + signInEmail: null, + totpURI: setup.totpURI, + backupCodes: setup.backupCodes + }; }); diff --git a/apps/dashboard/src/lib/totp-reset-flow.ts b/apps/dashboard/src/lib/totp-reset-flow.ts index 990a8b25..b5373eed 100644 --- a/apps/dashboard/src/lib/totp-reset-flow.ts +++ b/apps/dashboard/src/lib/totp-reset-flow.ts @@ -33,3 +33,12 @@ export function totpResetStepLabel(step: TotpResetFlowStep): string { return 'Disable two-factor authentication'; } } + +export function maskEmail(email: string): string { + const at = email.indexOf('@'); + if (at <= 0) return email; + const local = email.slice(0, at); + const domain = email.slice(at); + const visible = local.length > 2 ? local.slice(0, 2) : local.slice(0, 1); + return `${visible}${'•'.repeat(Math.max(local.length - visible.length, 3))}${domain}`; +} diff --git a/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts b/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts index 06e3f812..1f5e66e5 100644 --- a/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts +++ b/apps/dashboard/src/routes/login/two-factor/totp/+page.server.ts @@ -2,6 +2,7 @@ import { redirect } from '@sveltejs/kit'; import { initDrizzle } from '$lib/server/db'; import { getRuntimeEnv } from '$lib/server/env'; import { requiresTotpReset, resolvePendingTwoFactorUser } from '$lib/server/totp-reset'; +import { maskEmail } from '$lib/totp-reset-flow'; import type { PageServerLoad } from './$types'; const pendingPasskeyHintCookie = 'pending_passkey_2fa_hint'; @@ -22,5 +23,9 @@ export const load: PageServerLoad = async (event) => { ); const resetRequired = pendingUser ? await requiresTotpReset(db, pendingUser.id) : false; - return { redirectTo, resetRequired, resetEmail: resetRequired ? pendingUser?.email : null }; + return { + redirectTo, + resetRequired, + resetEmail: resetRequired && pendingUser ? maskEmail(pendingUser.email) : null + }; }; diff --git a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte index dde804d3..dd57c4f2 100644 --- a/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte +++ b/apps/dashboard/src/routes/login/two-factor/totp/+page.svelte @@ -63,12 +63,13 @@

Set up your authenticator again

- Codes from your existing authenticator app no longer work. Verify your email to set up a - new one or turn two-factor authentication off. + Due to a migration, you'll need to re-enroll your TOTP code. This is a one-time event, + and your existing authenticator will no longer work.

goto(redirectTo)} onPasskeyChallenge={() => goto(passkeyHref)} From 17ad4bdbe9b51c3c88c165816920353a47932815 Mon Sep 17 00:00:00 2001 From: Addison LeClair Date: Sun, 20 Sep 2026 21:47:57 +0000 Subject: [PATCH 4/4] refactor(auth): simplify TOTP reset flow - Remove the standalone TOTP reset dialog and signed-in reset path - Require legacy TOTP reset flow to run while signed out - Centralize reset and disable handling through verified password removal --- .../dialogs/totp-reset-dialog.svelte | 29 ------ .../dialogs/user-settings-dialog.svelte | 67 ++++---------- .../src/lib/components/totp-reset-flow.svelte | 60 ++----------- .../src/lib/remote/two-factor.remote.ts | 89 +++---------------- apps/dashboard/src/lib/server/totp-reset.ts | 18 ++-- 5 files changed, 42 insertions(+), 221 deletions(-) delete mode 100644 apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte diff --git a/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte deleted file mode 100644 index bc757009..00000000 --- a/apps/dashboard/src/lib/components/dialogs/totp-reset-dialog.svelte +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - Reset authenticator app - - - (open = false)} /> - - diff --git a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte index e9032c06..5843c376 100644 --- a/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte +++ b/apps/dashboard/src/lib/components/dialogs/user-settings-dialog.svelte @@ -12,7 +12,6 @@ import { Skeleton } from '$lib/components/ui/skeleton'; import { authClient } from '$lib/auth-client'; import TotpOnboardingDialog from './totp-onboarding-dialog.svelte'; - import TotpResetDialog from './totp-reset-dialog.svelte'; import PasskeyOnboardingDialog from './passkey-onboarding-dialog.svelte'; import PasswordVerificationDialog from './password-verification-dialog.svelte'; import { @@ -213,7 +212,6 @@ let totpDialogOpen = $state(false); let passkeyDialogOpen = $state(false); let totpDisableDialogOpen = $state(false); - let totpResetDialogOpen = $state(false); let totpDisablePassword = $state(''); type TotpDisableMethod = 'totp' | 'backupCode'; let totpDisableMethod = $state('totp'); @@ -690,33 +688,21 @@ {#if twoFactorEnabled} -
- - -
+ {:else} - - {/if} diff --git a/apps/dashboard/src/lib/remote/two-factor.remote.ts b/apps/dashboard/src/lib/remote/two-factor.remote.ts index d02f0edf..5aa7cb18 100644 --- a/apps/dashboard/src/lib/remote/two-factor.remote.ts +++ b/apps/dashboard/src/lib/remote/two-factor.remote.ts @@ -72,15 +72,12 @@ export const disableTwoFactorWithVerification = command(disableTwoFactorParams, async function resolveTotpResetUser( event: ReturnType, db: ReturnType -): Promise { - if (event.locals.user) { - const { id, email, name } = event.locals.user; - return { id, email, name, hasSession: true }; - } +): Promise { + if (event.locals.user) error(403, 'Two-factor authentication cannot be reset while signed in.'); const pending = await resolvePendingTwoFactorUser(event, db, getRuntimeEnv().BETTER_AUTH_SECRET); if (!pending) error(401, 'Authentication required'); - return { ...pending, hasSession: false }; + return pending; } export const sendTotpResetCode = command(async () => { @@ -124,81 +121,21 @@ export const confirmTotpResetChoice = command(confirmTotpResetParams, async (par await requireTotpResetGrant(db, user.id); const auth = initAuth(); - - if (!user.hasSession) { - const authContext = await auth.$context; - await removeTotpWithVerifiedPassword(db, user.id, params.password, (hash, password) => - authContext.password.verify({ hash, password }) - ); - await clearTotpResetGrant(db, user.id); - await sendSecurityAlertEmail({ - to: user.email, - userName: user.name, - alertType: - choice === 'reset' - ? 'Two-factor authentication reset' - : 'Two-factor authentication disabled', - message: - choice === 'reset' - ? 'Authenticator app two-factor authentication was reset for your Stack account during sign-in. The old authenticator no longer works. Finish setting up the new one to turn two-factor authentication back on.' - : 'Authenticator app two-factor authentication was disabled for your Stack account during sign-in.', - actionUrl: event.url.origin - }); - return { - choice, - requiresSignIn: true, - signInEmail: user.email, - totpURI: null, - backupCodes: [] as string[] - }; - } - - const headers = new Headers(event.request.headers); - headers.set(VERIFIED_2FA_DISABLE_HEADER, getRuntimeEnv().BETTER_AUTH_SECRET); - - await auth.api.disableTwoFactor({ - headers, - body: { password: params.password } - }); - - if (choice === 'disable') { - await clearTotpResetGrant(db, user.id); - await sendSecurityAlertEmail({ - to: user.email, - userName: user.name, - alertType: 'Two-factor authentication disabled', - message: 'Authenticator app two-factor authentication was disabled for your Stack account.', - actionUrl: event.url.origin - }); - return { - choice, - requiresSignIn: false, - signInEmail: null, - totpURI: null, - backupCodes: [] as string[] - }; - } - - const setup = await auth.api.enableTwoFactor({ - headers: event.request.headers, - body: { password: params.password, issuer: 'Fyra Stack' } - }); + const authContext = await auth.$context; + await removeTotpWithVerifiedPassword(db, user.id, params.password, (hash, password) => + authContext.password.verify({ hash, password }) + ); await clearTotpResetGrant(db, user.id); - await sendSecurityAlertEmail({ to: user.email, userName: user.name, - alertType: 'Two-factor authentication reset', + alertType: + choice === 'reset' ? 'Two-factor authentication reset' : 'Two-factor authentication disabled', message: - 'Authenticator app two-factor authentication was reset for your Stack account. The old authenticator no longer works. Finish setting up the new one in Stack to turn two-factor authentication back on.', + choice === 'reset' + ? 'Authenticator app two-factor authentication was reset for your Stack account during sign-in. The old authenticator no longer works. Finish setting up the new one to turn two-factor authentication back on.' + : 'Authenticator app two-factor authentication was disabled for your Stack account during sign-in.', actionUrl: event.url.origin }); - - return { - choice, - requiresSignIn: false, - signInEmail: null, - totpURI: setup.totpURI, - backupCodes: setup.backupCodes - }; + return { choice, signInEmail: user.email }; }); diff --git a/apps/dashboard/src/lib/server/totp-reset.ts b/apps/dashboard/src/lib/server/totp-reset.ts index 463b056c..89fee21b 100644 --- a/apps/dashboard/src/lib/server/totp-reset.ts +++ b/apps/dashboard/src/lib/server/totp-reset.ts @@ -177,19 +177,13 @@ export async function removeTotpWithVerifiedPassword( await db.update(user).set({ twoFactorEnabled: false }).where(eq(user.id, userId)); } -export async function requireExistingTotp(db: Db, userId: string) { - const [registeredTotp] = await db - .select({ id: twoFactor.id }) - .from(twoFactor) - .where(eq(twoFactor.userId, userId)) - .limit(1); - - if (!registeredTotp) - error(400, 'Authenticator app two-factor authentication is not enabled for this account.'); +export async function requireLegacyTotp(db: Db, userId: string) { + if (!(await requiresTotpReset(db, userId))) + error(403, 'Two-factor authentication cannot be reset for this account.'); } export async function beginTotpReset(db: Db, userId: string) { - await requireExistingTotp(db, userId); + await requireLegacyTotp(db, userId); const identifier = totpResetCodeIdentifier(userId); const [existing] = await db @@ -219,7 +213,7 @@ export async function beginTotpReset(db: Db, userId: string) { } export async function verifyTotpResetCode(db: Db, userId: string, code: string) { - await requireExistingTotp(db, userId); + await requireLegacyTotp(db, userId); const normalizedCode = normalizeTotpResetCode(code); if (normalizedCode.length !== TOTP_RESET_CODE_LENGTH) @@ -267,7 +261,7 @@ export async function verifyTotpResetCode(db: Db, userId: string, code: string) } export async function requireTotpResetGrant(db: Db, userId: string) { - await requireExistingTotp(db, userId); + await requireLegacyTotp(db, userId); const [record] = await db .select({ id: verification.id })