diff --git a/apps/dashboard/src/hooks.server.ts b/apps/dashboard/src/hooks.server.ts index 80b7b2f9..44041f9b 100644 --- a/apps/dashboard/src/hooks.server.ts +++ b/apps/dashboard/src/hooks.server.ts @@ -17,7 +17,8 @@ const publicRoutes = [ '/forgot-password', '/reset-password', '/accept-invitation', - '/api/' + '/api/', + '/_app/remote/' ]; const authPages = ['/login', '/register', '/signup', '/forgot-password']; 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..729ca198 --- /dev/null +++ b/apps/dashboard/src/lib/components/totp-reset-flow.svelte @@ -0,0 +1,517 @@ + + +
+

{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/emails/totp-reset-code.svelte b/apps/dashboard/src/lib/emails/totp-reset-code.svelte new file mode 100644 index 00000000..3225a197 --- /dev/null +++ b/apps/dashboard/src/lib/emails/totp-reset-code.svelte @@ -0,0 +1,53 @@ + + + + + + + + +
+ + Reset two-factor authentication + + + {#if userName}Hi {userName},{:else}Hi there,{/if} + + + Enter this code in Stack to reset the authenticator app on your account. + +
+ + {code} + +
+
+ + This code expires in {expiresInMinutes} minutes. If you did not request this change, contact + support@fyrastack.com and secure your account immediately. + +
+
+ + diff --git a/apps/dashboard/src/lib/remote/two-factor.remote.ts b/apps/dashboard/src/lib/remote/two-factor.remote.ts index b41ac0ee..5aa7cb18 100644 --- a/apps/dashboard/src/lib/remote/two-factor.remote.ts +++ b/apps/dashboard/src/lib/remote/two-factor.remote.ts @@ -1,9 +1,23 @@ import { command, getRequestEvent } from '$app/server'; import { error } from '@sveltejs/kit'; import { type } from 'arktype'; +import TotpResetCodeEmail from '$lib/emails/totp-reset-code.svelte'; import { initAuth, VERIFIED_2FA_DISABLE_HEADER } from '$lib/server/auth'; +import { initDrizzle } from '$lib/server/db'; +import { sendRenderedEmail } from '$lib/server/email'; import { sendSecurityAlertEmail } from '$lib/server/email-notifications'; import { getRuntimeEnv } from '$lib/server/env'; +import { + TOTP_RESET_CODE_TTL_MS, + beginTotpReset, + clearTotpResetGrant, + normalizeTotpResetChoice, + removeTotpWithVerifiedPassword, + requireTotpResetGrant, + resolvePendingTwoFactorUser, + verifyTotpResetCode as verifyTotpResetEmailCode, + type TotpResetUser +} from '$lib/server/totp-reset'; const CODE_LENGTH = 6; @@ -54,3 +68,74 @@ export const disableTwoFactorWithVerification = command(disableTwoFactorParams, actionUrl: event.url.origin }); }); + +async function resolveTotpResetUser( + event: ReturnType, + db: ReturnType +): 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; +} + +export const sendTotpResetCode = command(async () => { + const event = getRequestEvent(); + const db = initDrizzle(); + const user = await resolveTotpResetUser(event, db); + + const code = await beginTotpReset(db, user.id); + + await sendRenderedEmail({ + component: TotpResetCodeEmail, + props: { userName: user.name, code, expiresInMinutes: TOTP_RESET_CODE_TTL_MS / 60_000 }, + subject: 'Reset your Stack two-factor authentication', + to: user.email + }); +}); + +const verifyTotpResetParams = type({ code: 'string' }); + +export const verifyTotpResetCode = command(verifyTotpResetParams, async (params) => { + const event = getRequestEvent(); + const db = initDrizzle(); + const user = await resolveTotpResetUser(event, db); + + await verifyTotpResetEmailCode(db, user.id, params.code); + + return { verified: true }; +}); + +const confirmTotpResetParams = type({ password: 'string', choice: 'string' }); + +export const confirmTotpResetChoice = command(confirmTotpResetParams, async (params) => { + const event = getRequestEvent(); + 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.'); + + await requireTotpResetGrant(db, user.id); + + const auth = initAuth(); + 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, signInEmail: user.email }; +}); 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 new file mode 100644 index 00000000..89fee21b --- /dev/null +++ b/apps/dashboard/src/lib/server/totp-reset.ts @@ -0,0 +1,284 @@ +import { error, type RequestEvent } from '@sveltejs/kit'; +import { and, eq, gt } from 'drizzle-orm'; +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 { + if (value === 'reset' || value === 'reset-totp') return 'reset'; + if (value === 'disable' || value === 'disable-totp') return 'disable'; + return null; +} + +export function normalizeTotpResetCode(code: string) { + return code.replace(/\D/g, ''); +} + +export function totpResetCodeIdentifier(userId: string) { + return `totp-reset:${userId}`; +} + +export function totpResetGrantIdentifier(userId: string) { + return `totp-reset-verified:${userId}`; +} + +export function generateTotpResetCode() { + const max = 10 ** TOTP_RESET_CODE_LENGTH; + const range = 2 ** 32; + const limit = range - (range % max); + const values = new Uint32Array(1); + + do { + crypto.getRandomValues(values); + } while (values[0] >= limit); + + return (values[0] % max).toString().padStart(TOTP_RESET_CODE_LENGTH, '0'); +} + +export async function hashTotpResetCode(userId: string, code: string) { + const data = new TextEncoder().encode(`${userId}:${code}`); + const hash = await crypto.subtle.digest('SHA-256', data); + 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 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 requireLegacyTotp(db, userId); + + const identifier = totpResetCodeIdentifier(userId); + 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({ + id: ulid(), + identifier, + value, + expiresAt: new Date(Date.now() + TOTP_RESET_CODE_TTL_MS) + }); + + return code; +} + +export async function verifyTotpResetCode(db: Db, userId: string, code: string) { + await requireLegacyTotp(db, userId); + + const normalizedCode = normalizeTotpResetCode(code); + if (normalizedCode.length !== TOTP_RESET_CODE_LENGTH) + error(400, 'Enter the verification code from your email.'); + + const identifier = totpResetCodeIdentifier(userId); + const [row] = await db + .select({ id: verification.id, value: verification.value }) + .from(verification) + .where(and(eq(verification.identifier, identifier), gt(verification.expiresAt, new Date()))) + .limit(1); + + 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)); + await db.insert(verification).values({ + id: ulid(), + identifier: grantIdentifier, + value: 'verified', + expiresAt: new Date(Date.now() + TOTP_RESET_GRANT_TTL_MS) + }); +} + +export async function requireTotpResetGrant(db: Db, userId: string) { + await requireLegacyTotp(db, userId); + + const [record] = await db + .select({ id: verification.id }) + .from(verification) + .where( + and( + eq(verification.identifier, totpResetGrantIdentifier(userId)), + gt(verification.expiresAt, new Date()) + ) + ) + .limit(1); + + if (!record) error(400, 'Verify your email before changing two-factor authentication.'); +} + +export async function clearTotpResetGrant(db: Db, userId: string) { + await db + .delete(verification) + .where(eq(verification.identifier, totpResetGrantIdentifier(userId))); +} diff --git a/apps/dashboard/src/lib/totp-reset-flow.ts b/apps/dashboard/src/lib/totp-reset-flow.ts new file mode 100644 index 00000000..b5373eed --- /dev/null +++ b/apps/dashboard/src/lib/totp-reset-flow.ts @@ -0,0 +1,44 @@ +export type TotpResetFlowStep = 'email' | 'choice' | 'reset' | 'setup' | 'disable'; + +export type TotpResetUiChoice = 'reset-totp' | 'disable-totp'; + +export const TOTP_RESET_UI_CHOICES: TotpResetUiChoice[] = ['reset-totp', 'disable-totp']; + +export const TOTP_RESET_FLOW_STEP_COUNT = 3; + +export function totpResetStepNumber(step: TotpResetFlowStep): number { + switch (step) { + case 'email': + return 1; + case 'choice': + return 2; + case 'reset': + case 'setup': + case 'disable': + return 3; + } +} + +export function totpResetStepLabel(step: TotpResetFlowStep): string { + switch (step) { + case 'email': + return 'Verify your email'; + case 'choice': + return 'Choose what to do'; + case 'reset': + return 'Confirm your password'; + case 'setup': + return 'Set up your authenticator'; + case 'disable': + 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 30fc6e1b..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 @@ -1,13 +1,31 @@ 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'; -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 ? 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 ba57e572..dd57c4f2 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,81 @@ Stack -
-
-
- + {#if data.resetRequired} +
+
+
+ +
+

Set up your authenticator again

+

+ 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. +

-

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}