feat: two-factor authentication (TOTP + backup codes)
Uses better-auth's built-in twoFactor plugin rather than hand-rolling TOTP — adds the two_factors table and users.twoFactorEnabled, wires the server/client plugins (allowPasswordless: true so OAuth-only accounts aren't locked out of managing 2FA), and adds a custom rate limit for the verify endpoints (5/min — far stricter than the default 100/10s, since a 6-digit code has a much smaller keyspace than a password). New Settings → Security section walks through enable (password confirm -> QR + backup codes -> confirm a live code before it's actually turned on, per the plugin's default flow) and disable. New /verify-2fa page handles the post-password mid-login step, with a backup-code fallback. Verified live end-to-end: enable, confirm, forced re-auth on next sign-in, TOTP accepted, backup code accepted (single-use), disable. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ export const users = pgTable("users", {
|
||||
stripeCustomerId: text("stripe_customer_id").unique(),
|
||||
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
|
||||
locale: text("locale").notNull().default("en"),
|
||||
twoFactorEnabled: boolean("two_factor_enabled").notNull().default(false),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
@@ -71,6 +72,16 @@ export const verifications = pgTable("verifications", {
|
||||
updatedAt: timestamp("updated_at").notNull().defaultNow(),
|
||||
});
|
||||
|
||||
// Better Auth's twoFactor plugin table — secret/backupCodes are encrypted by
|
||||
// the plugin before storage, not plaintext.
|
||||
export const twoFactors = pgTable("two_factors", {
|
||||
id: text("id").primaryKey(),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
secret: text("secret").notNull(),
|
||||
backupCodes: text("backup_codes").notNull(),
|
||||
verified: boolean("verified").notNull().default(true),
|
||||
});
|
||||
|
||||
export const userFollows = pgTable("user_follows", {
|
||||
followerId: text("follower_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
followingId: text("following_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
|
||||
Reference in New Issue
Block a user