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:
Arnaud
2026-07-13 23:08:15 +02:00
parent 4e5f45a7e5
commit acc93de708
17 changed files with 5500 additions and 13 deletions
@@ -0,0 +1,10 @@
CREATE TABLE "two_factors" (
"id" text PRIMARY KEY NOT NULL,
"user_id" text NOT NULL,
"secret" text NOT NULL,
"backup_codes" text NOT NULL,
"verified" boolean DEFAULT true NOT NULL
);
--> statement-breakpoint
ALTER TABLE "users" ADD COLUMN "two_factor_enabled" boolean DEFAULT false NOT NULL;--> statement-breakpoint
ALTER TABLE "two_factors" ADD CONSTRAINT "two_factors_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
File diff suppressed because it is too large Load Diff
@@ -260,6 +260,13 @@
"when": 1783939302554,
"tag": "0036_mixed_wither",
"breakpoints": true
},
{
"idx": 37,
"version": "7",
"when": 1783976055649,
"tag": "0037_big_nehzno",
"breakpoints": true
}
]
}
+11
View File
@@ -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" }),