diff --git a/server/auth/index.ts b/server/auth/index.ts index 3c27f282..de648e89 100644 --- a/server/auth/index.ts +++ b/server/auth/index.ts @@ -49,7 +49,7 @@ interface DatabaseUserAttributes { email: string; passwordHash: string; twoFactorEnabled: boolean; - twoFactorSecret: string | null; + twoFactorSecret?: string; emailVerified: boolean; } diff --git a/server/db/schema.ts b/server/db/schema.ts index 250662b6..322f7afe 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -83,6 +83,14 @@ export const users = sqliteTable("user", { .default(false), }); +export const twoFactorBackupCodes = sqliteTable("twoFactorBackupCodes", { + id: integer("id").primaryKey({ autoIncrement: true }), + userId: text("userId") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + codeHash: text("codeHash").notNull(), +}); + // Sessions table export const sessions = sqliteTable("session", { id: text("id").primaryKey(), // has to be id not sessionId for lucia @@ -124,3 +132,4 @@ export type Session = InferSelectModel; export type EmailVerificationCode = InferSelectModel< typeof emailVerificationCodes >; +export type TwoFactorBackupCode = InferSelectModel; diff --git a/server/routers/auth/disable2fa.ts b/server/routers/auth/disable2fa.ts index 437ba5af..6c4221a2 100644 --- a/server/routers/auth/disable2fa.ts +++ b/server/routers/auth/disable2fa.ts @@ -5,7 +5,7 @@ import { fromError } from "zod-validation-error"; import { unauthorized } from "@server/auth"; import { z } from "zod"; import { db } from "@server/db"; -import { User, users } from "@server/db/schema"; +import { twoFactorBackupCodes, User, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; import { response } from "@server/utils"; import { verifyPassword } from "./password"; @@ -82,6 +82,10 @@ export async function disable2fa( .set({ twoFactorEnabled: false }) .where(eq(users.id, user.id)); + await db + .delete(twoFactorBackupCodes) + .where(eq(twoFactorBackupCodes.userId, user.id)); + return response(res, { data: null, success: true, diff --git a/server/routers/auth/verifyTotp.ts b/server/routers/auth/verifyTotp.ts index 1f484847..cd9a52bd 100644 --- a/server/routers/auth/verifyTotp.ts +++ b/server/routers/auth/verifyTotp.ts @@ -7,8 +7,10 @@ import { TOTPController } from "oslo/otp"; import HttpCode from "@server/types/HttpCode"; import { response } from "@server/utils"; import { db } from "@server/db"; -import { User, users } from "@server/db/schema"; +import { twoFactorBackupCodes, User, users } from "@server/db/schema"; import { eq } from "drizzle-orm"; +import { alphabet, generateRandomString } from "oslo/crypto"; +import { hashPassword } from "./password"; export const verifyTotpBody = z.object({ code: z.string(), @@ -18,6 +20,7 @@ export type VerifyTotpBody = z.infer; export type VerifyTotpResponse = { valid: boolean; + backupCodes: string[]; }; export async function verifyTotp( @@ -65,6 +68,16 @@ export async function verifyTotp( decodeHex(user.twoFactorSecret), ); + const backupCodes = await generateBackupCodes(); + for (const code of backupCodes) { + const hash = await hashPassword(code); + + await db.insert(twoFactorBackupCodes).values({ + userId: user.id, + codeHash: hash, + }); + } + if (valid) { // if valid, enable two-factor authentication; the totp secret is no longer temporary await db @@ -73,8 +86,8 @@ export async function verifyTotp( .where(eq(users.id, user.id)); } - return response<{ valid: boolean }>(res, { - data: { valid }, + return response(res, { + data: { valid, backupCodes }, success: true, error: false, message: valid @@ -91,3 +104,12 @@ export async function verifyTotp( ); } } + +async function generateBackupCodes(): Promise { + const codes = []; + for (let i = 0; i < 10; i++) { + const code = generateRandomString(8, alphabet("0-9", "A-Z", "a-z")); + codes.push(code); + } + return codes; +}