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'}
+
+ {:else if step === 'choice'}
+
+
+
+
+
+
+
+
+ {:else if step === 'reset'}
+
+ {:else if step === 'setup' && totpUri}
+
+
+
+

+
+
+
+
+
+
+
+ {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'}
+
+ {/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.
+
+
+
+
+ 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}
-
+
+
+
+
+
+ Lost your device? Use a backup code from when you set up 2FA.
+
+
+ {/if}