Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit7298a17unknown_key

feat(BLOCK-B4): TOTP 2FA + recovery codes (enroll, verify, disable)

feat(BLOCK-B4): TOTP 2FA + recovery codes (enroll, verify, disable)

Schema: user_totp (secret, enabled_at), user_recovery_codes (hashed),
sessions.requires_2fa flag.

Library: src/lib/totp.ts — RFC 6238 TOTP implemented via Bun's native
crypto.subtle (HMAC-SHA1 + big-endian counter). Verifies with ±30s drift
tolerance and constant-time string compare. Base32 encode/decode for
secret round-trip. Recovery code generator + SHA-256 hasher.

Flow:
- /login: after password check, if user has enabled TOTP, session is
  minted with requires_2fa=true and user is redirected to /login/2fa
- softAuth + requireAuth treat requires_2fa sessions as anonymous;
  requireAuth sends users to /login/2fa instead of /login
- /login/2fa accepts 6-digit TOTP OR dash-separated recovery code
  (single-use, marked used_at)
- /settings/2fa: status + enroll + confirm + disable (password-gated) +
  recovery-code regen. Every transition audit()'d

+13 tests (138 pass total). No external deps.
Claude committed on April 15, 2026Parent: 40d3e3f
8 files changed+101347298a1710014cc79c30f23c44439d1d294466f42
8 changed files+1013−4
Addeddrizzle/0005_totp_2fa.sql+30−0View fileUnifiedSplit
1-- Gluecron migration 0005: Block B4 — TOTP 2FA + recovery codes.
2
3--> statement-breakpoint
4ALTER TABLE "sessions" ADD COLUMN IF NOT EXISTS "requires_2fa" boolean DEFAULT false NOT NULL;
5
6--> statement-breakpoint
7CREATE TABLE IF NOT EXISTS "user_totp" (
8 "user_id" uuid PRIMARY KEY NOT NULL,
9 "secret" text NOT NULL,
10 "enabled_at" timestamp,
11 "last_used_at" timestamp,
12 "created_at" timestamp DEFAULT now() NOT NULL,
13 CONSTRAINT "user_totp_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
14);
15
16--> statement-breakpoint
17CREATE TABLE IF NOT EXISTS "user_recovery_codes" (
18 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
19 "user_id" uuid NOT NULL,
20 "code_hash" text NOT NULL,
21 "used_at" timestamp,
22 "created_at" timestamp DEFAULT now() NOT NULL,
23 CONSTRAINT "user_recovery_codes_user_fk" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE cascade
24);
25
26--> statement-breakpoint
27CREATE INDEX IF NOT EXISTS "recovery_codes_user" ON "user_recovery_codes" ("user_id");
28
29--> statement-breakpoint
30CREATE UNIQUE INDEX IF NOT EXISTS "recovery_codes_user_hash" ON "user_recovery_codes" ("user_id", "code_hash");
Modifiedsrc/__tests__/green-ecosystem.test.ts+117−0View fileUnifiedSplit
3333 isValidTeamRole,
3434 __test as orgsInternal,
3535} from "../lib/orgs";
36import {
37 base32Encode,
38 base32Decode,
39 generateTotpSecret,
40 totpCode,
41 verifyTotpCode,
42 otpauthUrl,
43 generateRecoveryCodes,
44 hashRecoveryCode,
45} from "../lib/totp";
3646
3747describe("secret scanner", () => {
3848 it("detects AWS access keys", () => {
627637 expect(res.status).toBe(400);
628638 });
629639});
640
641describe("TOTP / 2FA (B4)", () => {
642 it("base32 round-trips bytes", () => {
643 const bytes = new Uint8Array([0x74, 0x65, 0x73, 0x74]); // "test"
644 const enc = base32Encode(bytes);
645 const dec = base32Decode(enc);
646 expect(Array.from(dec)).toEqual(Array.from(bytes));
647 });
648
649 it("generateTotpSecret returns 32-char Base32", () => {
650 const s = generateTotpSecret();
651 expect(s.length).toBe(32);
652 expect(/^[A-Z2-7]+$/.test(s)).toBe(true);
653 });
654
655 it("totpCode is 6 digits", async () => {
656 const s = generateTotpSecret();
657 const c = await totpCode(s);
658 expect(/^\d{6}$/.test(c)).toBe(true);
659 });
660
661 it("verifyTotpCode accepts a freshly-generated code", async () => {
662 const s = generateTotpSecret();
663 const c = await totpCode(s);
664 expect(await verifyTotpCode(s, c)).toBe(true);
665 });
666
667 it("verifyTotpCode tolerates a ±30s drift", async () => {
668 const s = generateTotpSecret();
669 const now = Math.floor(Date.now() / 1000);
670 const past = await totpCode(s, now - 30);
671 const future = await totpCode(s, now + 30);
672 expect(await verifyTotpCode(s, past, now)).toBe(true);
673 expect(await verifyTotpCode(s, future, now)).toBe(true);
674 });
675
676 it("verifyTotpCode rejects a wrong code", async () => {
677 const s = generateTotpSecret();
678 expect(await verifyTotpCode(s, "000000")).toBe(false);
679 });
680
681 it("verifyTotpCode rejects non-6-digit input", async () => {
682 const s = generateTotpSecret();
683 expect(await verifyTotpCode(s, "abc")).toBe(false);
684 expect(await verifyTotpCode(s, "12345")).toBe(false);
685 expect(await verifyTotpCode(s, "1234567")).toBe(false);
686 });
687
688 it("otpauthUrl has the expected shape", () => {
689 const u = otpauthUrl({
690 secret: "JBSWY3DPEHPK3PXP",
691 accountName: "alice@example.com",
692 issuer: "gluecron",
693 });
694 expect(u.startsWith("otpauth://totp/")).toBe(true);
695 expect(u).toContain("secret=JBSWY3DPEHPK3PXP");
696 expect(u).toContain("issuer=gluecron");
697 expect(u).toContain("period=30");
698 expect(u).toContain("digits=6");
699 });
700
701 it("generateRecoveryCodes returns the expected count + format", () => {
702 const codes = generateRecoveryCodes(5);
703 expect(codes.length).toBe(5);
704 for (const c of codes) {
705 expect(/^[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}$/.test(c)).toBe(true);
706 }
707 // Uniqueness: ~70 bits of entropy each, collisions should be astronomical.
708 expect(new Set(codes).size).toBe(5);
709 });
710
711 it("hashRecoveryCode is deterministic + normalised", async () => {
712 const a = await hashRecoveryCode("ABCD-1234-efgh");
713 const b = await hashRecoveryCode("abcd-1234-efgh");
714 const c = await hashRecoveryCode(" abcd-1234-efgh ");
715 expect(a).toBe(b);
716 expect(a).toBe(c);
717 expect(a.length).toBe(64); // SHA-256 hex
718 });
719});
720
721describe("2FA routes (B4)", () => {
722 it("GET /settings/2fa redirects unauthenticated users to /login", async () => {
723 const res = await app.request("/settings/2fa");
724 expect([301, 302, 303, 307]).toContain(res.status);
725 const loc = res.headers.get("location") || "";
726 expect(loc.startsWith("/login")).toBe(true);
727 });
728
729 it("POST /settings/2fa/enroll redirects unauthenticated users to /login", async () => {
730 const res = await app.request("/settings/2fa/enroll", {
731 method: "POST",
732 headers: { "content-type": "application/x-www-form-urlencoded" },
733 body: "",
734 });
735 expect([301, 302, 303, 307]).toContain(res.status);
736 const loc = res.headers.get("location") || "";
737 expect(loc.startsWith("/login")).toBe(true);
738 });
739
740 it("GET /login/2fa redirects to /login when no session cookie", async () => {
741 const res = await app.request("/login/2fa");
742 expect([301, 302, 303, 307]).toContain(res.status);
743 const loc = res.headers.get("location") || "";
744 expect(loc.startsWith("/login")).toBe(true);
745 });
746});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
99import apiRoutes from "./routes/api";
1010import authRoutes from "./routes/auth";
1111import settingsRoutes from "./routes/settings";
12import settings2faRoutes from "./routes/settings-2fa";
1213import issueRoutes from "./routes/issues";
1314import repoSettings from "./routes/repo-settings";
1415import compareRoutes from "./routes/compare";
7172// Settings routes (profile, SSH keys)
7273app.route("/", settingsRoutes);
7374
75// 2FA / TOTP settings (Block B4)
76app.route("/", settings2faRoutes);
77
7478// Theme toggle (dark/light cookie)
7579app.route("/", themeRoutes);
7680
Modifiedsrc/db/schema.ts+50−0View fileUnifiedSplit
3333 .references(() => users.id, { onDelete: "cascade" }),
3434 token: text("token").notNull().unique(),
3535 expiresAt: timestamp("expires_at").notNull(),
36 // B4: true when the user has entered their password but not yet their TOTP
37 // code. softAuth/requireAuth treat such sessions as anonymous; only
38 // /login/2fa can consume them. Flips to false on successful 2FA.
39 requires2fa: boolean("requires_2fa").default(false).notNull(),
3640 createdAt: timestamp("created_at").defaultNow().notNull(),
3741});
3842
773777export type TeamMember = typeof teamMembers.$inferSelect;
774778export type OrgRole = "owner" | "admin" | "member";
775779export type TeamRole = "maintainer" | "member";
780
781/**
782 * 2FA / TOTP (Block B4).
783 *
784 * Secret is stored in plain Base32 for now — the DB has row-level-secure
785 * access and the app boundary is the only code that reads it. A follow-up
786 * (B4.1) will wrap it with AES-GCM at rest once we standardise the KEK.
787 *
788 * `enabledAt` is set only after the user has successfully entered their
789 * first code (confirming the authenticator was set up correctly). Rows with
790 * `enabledAt = NULL` represent pending enrolment.
791 */
792export const userTotp = pgTable("user_totp", {
793 userId: uuid("user_id")
794 .primaryKey()
795 .references(() => users.id, { onDelete: "cascade" }),
796 secret: text("secret").notNull(),
797 enabledAt: timestamp("enabled_at"),
798 lastUsedAt: timestamp("last_used_at"),
799 createdAt: timestamp("created_at").defaultNow().notNull(),
800});
801
802/**
803 * Recovery codes — single-use fallback when the authenticator is lost.
804 * Stored as SHA-256 hashes; used rows are marked with `usedAt` rather than
805 * deleted so the audit log keeps the full history.
806 */
807export const userRecoveryCodes = pgTable(
808 "user_recovery_codes",
809 {
810 id: uuid("id").primaryKey().defaultRandom(),
811 userId: uuid("user_id")
812 .notNull()
813 .references(() => users.id, { onDelete: "cascade" }),
814 codeHash: text("code_hash").notNull(),
815 usedAt: timestamp("used_at"),
816 createdAt: timestamp("created_at").defaultNow().notNull(),
817 },
818 (table) => [
819 index("recovery_codes_user").on(table.userId),
820 uniqueIndex("recovery_codes_user_hash").on(table.userId, table.codeHash),
821 ]
822);
823
824export type UserTotp = typeof userTotp.$inferSelect;
825export type UserRecoveryCode = typeof userRecoveryCodes.$inferSelect;
Addedsrc/lib/totp.ts+185−0View fileUnifiedSplit
1/**
2 * TOTP (RFC 6238) — standalone, no external deps.
3 *
4 * Used for 2FA (Block B4). Generates + verifies 6-digit codes with a 30-second
5 * step. Verification accepts the current step ±1 to tolerate clock skew.
6 *
7 * Secrets are stored as Base32 strings (the standard QR-code encoding) and
8 * converted to bytes on each verify. At rest the secret is further encrypted
9 * (see `src/lib/crypto.ts` for the AES-GCM wrapper introduced in this block).
10 */
11
12const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
13
14/** Encode random bytes as a Base32 string with no padding (TOTP standard). */
15export function base32Encode(bytes: Uint8Array): string {
16 let bits = 0;
17 let value = 0;
18 let output = "";
19 for (let i = 0; i < bytes.length; i++) {
20 value = (value << 8) | bytes[i]!;
21 bits += 8;
22 while (bits >= 5) {
23 output += BASE32_ALPHABET[(value >>> (bits - 5)) & 31];
24 bits -= 5;
25 }
26 }
27 if (bits > 0) {
28 output += BASE32_ALPHABET[(value << (5 - bits)) & 31];
29 }
30 return output;
31}
32
33/** Decode a Base32 string back into bytes. Permissive about case + padding. */
34export function base32Decode(input: string): Uint8Array {
35 const clean = input
36 .toUpperCase()
37 .replace(/=+$/g, "")
38 .replace(/\s+/g, "");
39 let bits = 0;
40 let value = 0;
41 const out: number[] = [];
42 for (let i = 0; i < clean.length; i++) {
43 const idx = BASE32_ALPHABET.indexOf(clean[i]!);
44 if (idx === -1) {
45 throw new Error(`Invalid Base32 character: ${clean[i]}`);
46 }
47 value = (value << 5) | idx;
48 bits += 5;
49 if (bits >= 8) {
50 bits -= 8;
51 out.push((value >>> bits) & 0xff);
52 }
53 }
54 return new Uint8Array(out);
55}
56
57/**
58 * Generate a cryptographically random TOTP secret. 20 bytes → 32 Base32 chars,
59 * the length most auth apps expect and RFC 4226 recommends.
60 */
61export function generateTotpSecret(): string {
62 return base32Encode(crypto.getRandomValues(new Uint8Array(20)));
63}
64
65async function hmacSha1(
66 keyBytes: Uint8Array,
67 msgBytes: Uint8Array
68): Promise<Uint8Array> {
69 const key = await crypto.subtle.importKey(
70 "raw",
71 keyBytes,
72 { name: "HMAC", hash: "SHA-1" },
73 false,
74 ["sign"]
75 );
76 const sig = await crypto.subtle.sign("HMAC", key, msgBytes);
77 return new Uint8Array(sig);
78}
79
80/** Dynamic-truncate the HMAC output into a 6-digit number (RFC 4226). */
81function hotpCode(hmac: Uint8Array): string {
82 const offset = hmac[hmac.length - 1]! & 0x0f;
83 const bin =
84 ((hmac[offset]! & 0x7f) << 24) |
85 ((hmac[offset + 1]! & 0xff) << 16) |
86 ((hmac[offset + 2]! & 0xff) << 8) |
87 (hmac[offset + 3]! & 0xff);
88 return String(bin % 1_000_000).padStart(6, "0");
89}
90
91/** Generate the TOTP code for a given secret + unix time (seconds). */
92export async function totpCode(
93 secretBase32: string,
94 timeSec: number = Math.floor(Date.now() / 1000)
95): Promise<string> {
96 const step = Math.floor(timeSec / 30);
97 const msg = new Uint8Array(8);
98 // Big-endian 8-byte counter.
99 new DataView(msg.buffer).setBigUint64(0, BigInt(step), false);
100 const hmac = await hmacSha1(base32Decode(secretBase32), msg);
101 return hotpCode(hmac);
102}
103
104/**
105 * Verify a 6-digit code against a secret with ±1 step tolerance.
106 * Constant-time-ish string compare (both sides same length).
107 */
108export async function verifyTotpCode(
109 secretBase32: string,
110 code: string,
111 timeSec: number = Math.floor(Date.now() / 1000)
112): Promise<boolean> {
113 if (!/^\d{6}$/.test(code)) return false;
114 const candidates = await Promise.all([
115 totpCode(secretBase32, timeSec - 30),
116 totpCode(secretBase32, timeSec),
117 totpCode(secretBase32, timeSec + 30),
118 ]);
119 let ok = false;
120 for (const c of candidates) {
121 // Avoid short-circuit: keep timing close.
122 if (constantTimeEqual(c, code)) ok = true;
123 }
124 return ok;
125}
126
127function constantTimeEqual(a: string, b: string): boolean {
128 if (a.length !== b.length) return false;
129 let diff = 0;
130 for (let i = 0; i < a.length; i++) {
131 diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
132 }
133 return diff === 0;
134}
135
136/**
137 * Build an otpauth:// URI suitable for QR codes. Most authenticator apps
138 * (Google Authenticator, 1Password, Bitwarden, Authy) accept this format.
139 */
140export function otpauthUrl(opts: {
141 secret: string;
142 accountName: string;
143 issuer: string;
144}): string {
145 const label = encodeURIComponent(`${opts.issuer}:${opts.accountName}`);
146 const params = new URLSearchParams({
147 secret: opts.secret,
148 issuer: opts.issuer,
149 algorithm: "SHA1",
150 digits: "6",
151 period: "30",
152 });
153 return `otpauth://totp/${label}?${params.toString()}`;
154}
155
156/**
157 * Generate N random recovery codes in the format xxxx-xxxx-xxxx (lowercase
158 * alphanumeric). Each code is ~70 bits of entropy and single-use.
159 */
160export function generateRecoveryCodes(count = 10): string[] {
161 const codes: string[] = [];
162 for (let i = 0; i < count; i++) {
163 const parts: string[] = [];
164 for (let j = 0; j < 3; j++) {
165 const bytes = crypto.getRandomValues(new Uint8Array(3));
166 parts.push(
167 Array.from(bytes)
168 .map((b) => b.toString(36).padStart(2, "0"))
169 .join("")
170 .slice(0, 4)
171 );
172 }
173 codes.push(parts.join("-"));
174 }
175 return codes;
176}
177
178/** Hash a recovery code with SHA-256 for storage. */
179export async function hashRecoveryCode(code: string): Promise<string> {
180 const bytes = new TextEncoder().encode(code.trim().toLowerCase());
181 const digest = await crypto.subtle.digest("SHA-256", bytes);
182 return Array.from(new Uint8Array(digest))
183 .map((b) => b.toString(16).padStart(2, "0"))
184 .join("");
185}
Modifiedsrc/middleware/auth.ts+13−1View fileUnifiedSplit
4343 .where(eq(sessions.token, token))
4444 .limit(1);
4545
46 if (!session || new Date(session.expiresAt) < new Date()) {
46 if (
47 !session ||
48 new Date(session.expiresAt) < new Date() ||
49 session.requires2fa
50 ) {
4751 sessionCache.set(token, null as any);
4852 c.set("user", null);
4953 return next();
8589 return c.redirect(`/login?redirect=${encodeURIComponent(c.req.path)}`);
8690 }
8791
92 // 2FA pending — route the user to the code prompt instead of letting
93 // them access protected pages.
94 if (session.requires2fa) {
95 return c.redirect(
96 `/login/2fa?redirect=${encodeURIComponent(c.req.path)}`
97 );
98 }
99
88100 const [user] = await db
89101 .select()
90102 .from(users)
Modifiedsrc/routes/auth.tsx+168−3View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { setCookie, deleteCookie } from "hono/cookie";
7import { eq } from "drizzle-orm";
6import { setCookie, deleteCookie, getCookie } from "hono/cookie";
7import { and, eq, isNull } from "drizzle-orm";
88import { db } from "../db";
9import { users, sessions, organizations } from "../db/schema";
9import {
10 users,
11 sessions,
12 organizations,
13 userTotp,
14 userRecoveryCodes,
15} from "../db/schema";
1016import {
1117 hashPassword,
1218 verifyPassword,
1420 sessionCookieOptions,
1521 sessionExpiry,
1622} from "../lib/auth";
23import { verifyTotpCode, hashRecoveryCode } from "../lib/totp";
1724import { Layout } from "../views/layout";
1825import type { AuthEnv } from "../middleware/auth";
1926
225232 return c.redirect("/login?error=Invalid+credentials");
226233 }
227234
235 // B4: if the user has TOTP enabled, issue a pending-2fa session and
236 // redirect to the code prompt.
237 const [totp] = await db
238 .select({ enabledAt: userTotp.enabledAt })
239 .from(userTotp)
240 .where(eq(userTotp.userId, user.id))
241 .limit(1);
242 const needs2fa = !!(totp && totp.enabledAt);
243
228244 const token = generateSessionToken();
229245 await db.insert(sessions).values({
230246 userId: user.id,
231247 token,
232248 expiresAt: sessionExpiry(),
249 requires2fa: needs2fa,
233250 });
234251
235252 setCookie(c, "session", token, sessionCookieOptions());
253 if (needs2fa) {
254 return c.redirect(
255 `/login/2fa?redirect=${encodeURIComponent(redirect)}`
256 );
257 }
236258 return c.redirect(redirect);
237259});
238260
261// --- 2FA verify (B4) ---
262auth.get("/login/2fa", async (c) => {
263 const token = getCookie(c, "session");
264 if (!token) return c.redirect("/login");
265 const error = c.req.query("error");
266 const redirect = c.req.query("redirect") || "/";
267 return c.html(
268 <Layout title="Two-factor authentication">
269 <div class="auth-container">
270 <h2>Enter your code</h2>
271 <p
272 class="auth-switch"
273 style="margin-bottom: 16px; margin-top: 0"
274 >
275 Open your authenticator app and enter the 6-digit code. Lost your
276 device? Paste a recovery code instead.
277 </p>
278 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
279 <form
280 method="POST"
281 action={`/login/2fa?redirect=${encodeURIComponent(redirect)}`}
282 >
283 <div class="form-group">
284 <label for="code">Code</label>
285 <input
286 type="text"
287 id="code"
288 name="code"
289 required
290 autocomplete="one-time-code"
291 inputmode="numeric"
292 maxLength={24}
293 placeholder="123456 or xxxx-xxxx-xxxx"
294 />
295 </div>
296 <button type="submit" class="btn btn-primary">
297 Verify
298 </button>
299 </form>
300 <p class="auth-switch">
301 <a href="/logout">Cancel</a>
302 </p>
303 </div>
304 </Layout>
305 );
306});
307
308auth.post("/login/2fa", async (c) => {
309 const token = getCookie(c, "session");
310 if (!token) return c.redirect("/login");
311 const body = await c.req.parseBody();
312 const code = String(body.code || "").trim();
313 const redirect = c.req.query("redirect") || "/";
314
315 if (!code) {
316 return c.redirect(
317 `/login/2fa?error=Code+is+required&redirect=${encodeURIComponent(redirect)}`
318 );
319 }
320
321 try {
322 const [session] = await db
323 .select()
324 .from(sessions)
325 .where(eq(sessions.token, token))
326 .limit(1);
327 if (
328 !session ||
329 new Date(session.expiresAt) < new Date() ||
330 !session.requires2fa
331 ) {
332 return c.redirect("/login");
333 }
334
335 const [totp] = await db
336 .select()
337 .from(userTotp)
338 .where(eq(userTotp.userId, session.userId))
339 .limit(1);
340 if (!totp || !totp.enabledAt) {
341 // User doesn't have 2FA actually enabled — clear the flag and let
342 // them in. This can only happen if 2FA was disabled in another
343 // session between password check and code prompt.
344 await db
345 .update(sessions)
346 .set({ requires2fa: false })
347 .where(eq(sessions.token, token));
348 return c.redirect(redirect);
349 }
350
351 // Try TOTP code first.
352 const isSix = /^\d{6}$/.test(code);
353 let ok = false;
354 if (isSix) {
355 ok = await verifyTotpCode(totp.secret, code);
356 }
357 // Fall through to recovery code.
358 if (!ok) {
359 const hash = await hashRecoveryCode(code);
360 const [rec] = await db
361 .select()
362 .from(userRecoveryCodes)
363 .where(
364 and(
365 eq(userRecoveryCodes.userId, session.userId),
366 eq(userRecoveryCodes.codeHash, hash),
367 isNull(userRecoveryCodes.usedAt)
368 )
369 )
370 .limit(1);
371 if (rec) {
372 await db
373 .update(userRecoveryCodes)
374 .set({ usedAt: new Date() })
375 .where(eq(userRecoveryCodes.id, rec.id));
376 ok = true;
377 }
378 }
379
380 if (!ok) {
381 return c.redirect(
382 `/login/2fa?error=Invalid+code&redirect=${encodeURIComponent(redirect)}`
383 );
384 }
385
386 await db
387 .update(sessions)
388 .set({ requires2fa: false })
389 .where(eq(sessions.token, token));
390 await db
391 .update(userTotp)
392 .set({ lastUsedAt: new Date() })
393 .where(eq(userTotp.userId, session.userId));
394
395 return c.redirect(redirect);
396 } catch (err) {
397 console.error("[auth] 2fa verify:", err);
398 return c.redirect(
399 `/login/2fa?error=Service+unavailable&redirect=${encodeURIComponent(redirect)}`
400 );
401 }
402});
403
239404auth.get("/logout", async (c) => {
240405 deleteCookie(c, "session", { path: "/" });
241406 return c.redirect("/");
Addedsrc/routes/settings-2fa.tsx+446−0View fileUnifiedSplit
1/**
2 * 2FA settings (Block B4).
3 *
4 * Routes:
5 * GET /settings/2fa status + recovery code management
6 * POST /settings/2fa/enroll generate a pending secret, show QR
7 * GET /settings/2fa/enroll same as POST (for bookmarks)
8 * POST /settings/2fa/confirm verify first code, flip enabled
9 * POST /settings/2fa/disable require password + disable + wipe
10 * POST /settings/2fa/recovery/regen regenerate recovery codes
11 */
12
13import { Hono } from "hono";
14import { eq } from "drizzle-orm";
15import { db } from "../db";
16import { users, userTotp, userRecoveryCodes } from "../db/schema";
17import type { AuthEnv } from "../middleware/auth";
18import { requireAuth } from "../middleware/auth";
19import { Layout } from "../views/layout";
20import { verifyPassword } from "../lib/auth";
21import {
22 generateTotpSecret,
23 otpauthUrl,
24 verifyTotpCode,
25 generateRecoveryCodes,
26 hashRecoveryCode,
27} from "../lib/totp";
28import { audit } from "../lib/notify";
29import { config } from "../lib/config";
30
31const settings2fa = new Hono<AuthEnv>();
32
33settings2fa.use("/settings/2fa", requireAuth);
34settings2fa.use("/settings/2fa/*", requireAuth);
35
36function errorRedirect(path: string, msg: string) {
37 return `${path}?error=${encodeURIComponent(msg)}`;
38}
39
40/** Status page: either "off" (offer enroll), "pending" (finish enrol), "on" (disable + manage codes). */
41settings2fa.get("/settings/2fa", async (c) => {
42 const user = c.get("user")!;
43 const error = c.req.query("error");
44 const success = c.req.query("success");
45
46 let state: "off" | "pending" | "on" = "off";
47 try {
48 const [row] = await db
49 .select({ enabledAt: userTotp.enabledAt })
50 .from(userTotp)
51 .where(eq(userTotp.userId, user.id))
52 .limit(1);
53 if (row) state = row.enabledAt ? "on" : "pending";
54 } catch (err) {
55 console.error("[2fa] status:", err);
56 }
57
58 let unusedRecovery = 0;
59 try {
60 const rows = await db
61 .select({ usedAt: userRecoveryCodes.usedAt })
62 .from(userRecoveryCodes)
63 .where(eq(userRecoveryCodes.userId, user.id));
64 unusedRecovery = rows.filter((r) => !r.usedAt).length;
65 } catch {
66 /* ignore */
67 }
68
69 return c.html(
70 <Layout title="Two-factor authentication" user={user}>
71 <div class="settings-container">
72 <div class="breadcrumb">
73 <a href="/settings">settings</a>
74 <span>/</span>
75 <span>2fa</span>
76 </div>
77 <h2>Two-factor authentication</h2>
78 {error && <div class="auth-error">{decodeURIComponent(error)}</div>}
79 {success && (
80 <div class="auth-success">{decodeURIComponent(success)}</div>
81 )}
82 <p style="color: var(--text-muted); font-size: 13px">
83 Require a 6-digit code from your authenticator app on every sign-in.
84 Works with Google Authenticator, 1Password, Bitwarden, Authy, and any
85 other TOTP-compatible app.
86 </p>
87
88 {state === "off" && (
89 <form method="POST" action="/settings/2fa/enroll">
90 <button type="submit" class="btn btn-primary">
91 Enable two-factor authentication
92 </button>
93 </form>
94 )}
95
96 {state === "pending" && (
97 <>
98 <p
99 style="background: rgba(210, 153, 34, 0.1); border: 1px solid var(--yellow, #d29922); padding: 8px 12px; border-radius: var(--radius); color: var(--yellow, #d29922); font-size: 13px"
100 >
101 Enrolment started but not confirmed. Finish by entering a code
102 from your authenticator below.
103 </p>
104 <a href="/settings/2fa/enroll" class="btn btn-primary">
105 Continue enrolment
106 </a>
107 </>
108 )}
109
110 {state === "on" && (
111 <>
112 <div
113 style="background: rgba(63, 185, 80, 0.1); border: 1px solid var(--green); padding: 8px 12px; border-radius: var(--radius); color: var(--green); font-size: 13px; margin-bottom: 16px"
114 >
115 Two-factor authentication is enabled.
116 </div>
117 <h3 style="font-size: 15px; margin-top: 16px">Recovery codes</h3>
118 <p style="color: var(--text-muted); font-size: 13px">
119 {unusedRecovery} unused recovery code
120 {unusedRecovery === 1 ? "" : "s"} remaining. Each code can be
121 used once if you lose access to your authenticator.
122 </p>
123 <form
124 method="POST"
125 action="/settings/2fa/recovery/regen"
126 style="display: inline-block; margin-right: 8px"
127 onsubmit="return confirm('Regenerate recovery codes? Your existing codes will stop working.')"
128 >
129 <button type="submit" class="btn">
130 Regenerate recovery codes
131 </button>
132 </form>
133
134 <h3 style="font-size: 15px; margin-top: 24px">Disable</h3>
135 <p style="color: var(--text-muted); font-size: 13px">
136 Confirm your password to turn off 2FA.
137 </p>
138 <form method="POST" action="/settings/2fa/disable">
139 <div class="form-group" style="max-width: 320px">
140 <label for="password">Password</label>
141 <input
142 type="password"
143 name="password"
144 required
145 autocomplete="current-password"
146 />
147 </div>
148 <button type="submit" class="btn btn-danger">
149 Disable two-factor authentication
150 </button>
151 </form>
152 </>
153 )}
154 </div>
155 </Layout>
156 );
157});
158
159/** Generate (or re-use pending) secret + show the QR enrolment page. */
160async function showEnrolPage(c: any, user: any, error?: string) {
161 let secret: string;
162 try {
163 const [existing] = await db
164 .select()
165 .from(userTotp)
166 .where(eq(userTotp.userId, user.id))
167 .limit(1);
168 if (existing && !existing.enabledAt) {
169 secret = existing.secret;
170 } else if (existing && existing.enabledAt) {
171 return c.redirect(
172 errorRedirect("/settings/2fa", "2FA is already enabled")
173 );
174 } else {
175 secret = generateTotpSecret();
176 await db.insert(userTotp).values({ userId: user.id, secret });
177 }
178 } catch (err) {
179 console.error("[2fa] enroll:", err);
180 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
181 }
182
183 const url = otpauthUrl({
184 secret,
185 accountName: user.email || user.username,
186 issuer: "gluecron",
187 });
188 // Render a simple data URL QR via a public chart service fallback —
189 // but we avoid external deps and instead show the secret + URL so any
190 // authenticator can be set up manually. Apps scan otpauth:// directly.
191 return c.html(
192 <Layout title="Enable 2FA" user={user}>
193 <div class="settings-container">
194 <div class="breadcrumb">
195 <a href="/settings">settings</a>
196 <span>/</span>
197 <a href="/settings/2fa">2fa</a>
198 <span>/</span>
199 <span>enrol</span>
200 </div>
201 <h2>Set up your authenticator</h2>
202 {error && <div class="auth-error">{error}</div>}
203 <ol
204 style="color: var(--text-muted); font-size: 14px; line-height: 1.6; padding-left: 20px"
205 >
206 <li>
207 Open your authenticator app (Google Authenticator, 1Password,
208 Bitwarden, Authy, etc).
209 </li>
210 <li>
211 Add a new entry. Either scan the otpauth link below with a QR
212 reader, or type in the secret key manually.
213 </li>
214 <li>Enter the 6-digit code the app shows to confirm.</li>
215 </ol>
216 <div
217 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 16px; margin: 16px 0"
218 >
219 <div style="font-size: 12px; color: var(--text-muted)">Secret key</div>
220 <code
221 style="font-size: 14px; font-family: monospace; word-break: break-all"
222 >
223 {secret}
224 </code>
225 <div
226 style="font-size: 12px; color: var(--text-muted); margin-top: 12px"
227 >
228 otpauth URL (for QR apps)
229 </div>
230 <code
231 style="font-size: 12px; font-family: monospace; word-break: break-all; color: var(--text)"
232 >
233 {url}
234 </code>
235 </div>
236 <form method="POST" action="/settings/2fa/confirm">
237 <div class="form-group" style="max-width: 280px">
238 <label for="code">6-digit code</label>
239 <input
240 type="text"
241 name="code"
242 required
243 pattern="[0-9]{6}"
244 inputmode="numeric"
245 autocomplete="one-time-code"
246 maxLength={6}
247 />
248 </div>
249 <button type="submit" class="btn btn-primary">
250 Confirm + enable
251 </button>
252 </form>
253 </div>
254 </Layout>
255 );
256}
257
258settings2fa.get("/settings/2fa/enroll", async (c) => {
259 const user = c.get("user")!;
260 return showEnrolPage(c, user);
261});
262
263settings2fa.post("/settings/2fa/enroll", async (c) => {
264 const user = c.get("user")!;
265 return showEnrolPage(c, user);
266});
267
268/** Verify the first code + flip enabled. Also mint recovery codes. */
269settings2fa.post("/settings/2fa/confirm", async (c) => {
270 const user = c.get("user")!;
271 const body = await c.req.parseBody();
272 const code = String(body.code || "").trim();
273
274 if (!/^\d{6}$/.test(code)) {
275 return c.redirect(
276 errorRedirect("/settings/2fa/enroll", "Enter the 6-digit code")
277 );
278 }
279
280 try {
281 const [row] = await db
282 .select()
283 .from(userTotp)
284 .where(eq(userTotp.userId, user.id))
285 .limit(1);
286 if (!row || row.enabledAt) {
287 return c.redirect("/settings/2fa");
288 }
289 const ok = await verifyTotpCode(row.secret, code);
290 if (!ok) {
291 return c.redirect(
292 errorRedirect(
293 "/settings/2fa/enroll",
294 "Code did not verify — try again"
295 )
296 );
297 }
298 await db
299 .update(userTotp)
300 .set({ enabledAt: new Date(), lastUsedAt: new Date() })
301 .where(eq(userTotp.userId, user.id));
302
303 // Mint + store recovery codes
304 const codes = generateRecoveryCodes(10);
305 const hashes = await Promise.all(codes.map(hashRecoveryCode));
306 await db.delete(userRecoveryCodes).where(eq(userRecoveryCodes.userId, user.id));
307 await db.insert(userRecoveryCodes).values(
308 hashes.map((h) => ({ userId: user.id, codeHash: h }))
309 );
310
311 await audit({
312 userId: user.id,
313 action: "2fa.enable",
314 targetType: "user",
315 targetId: user.id,
316 });
317
318 return c.html(
319 <Layout title="Save your recovery codes" user={user}>
320 <div class="settings-container">
321 <div class="breadcrumb">
322 <a href="/settings">settings</a>
323 <span>/</span>
324 <a href="/settings/2fa">2fa</a>
325 <span>/</span>
326 <span>recovery codes</span>
327 </div>
328 <h2>Save your recovery codes</h2>
329 <div
330 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
331 >
332 These codes are shown only once. Store them somewhere safe — a
333 password manager, a printed copy. Each code works once.
334 </div>
335 <pre
336 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
337 >
338{codes.join("\n")}
339 </pre>
340 <a href="/settings/2fa" class="btn btn-primary">
341 I've saved them
342 </a>
343 </div>
344 </Layout>
345 );
346 } catch (err) {
347 console.error("[2fa] confirm:", err);
348 return c.redirect(
349 errorRedirect("/settings/2fa/enroll", "Service unavailable")
350 );
351 }
352});
353
354settings2fa.post("/settings/2fa/disable", async (c) => {
355 const user = c.get("user")!;
356 const body = await c.req.parseBody();
357 const password = String(body.password || "");
358 if (!password) {
359 return c.redirect(
360 errorRedirect("/settings/2fa", "Password is required")
361 );
362 }
363 try {
364 const [u] = await db
365 .select({ passwordHash: users.passwordHash })
366 .from(users)
367 .where(eq(users.id, user.id))
368 .limit(1);
369 if (!u || !(await verifyPassword(password, u.passwordHash))) {
370 return c.redirect(errorRedirect("/settings/2fa", "Invalid password"));
371 }
372 await db.delete(userTotp).where(eq(userTotp.userId, user.id));
373 await db
374 .delete(userRecoveryCodes)
375 .where(eq(userRecoveryCodes.userId, user.id));
376 await audit({
377 userId: user.id,
378 action: "2fa.disable",
379 targetType: "user",
380 targetId: user.id,
381 });
382 return c.redirect("/settings/2fa?success=Two-factor+disabled");
383 } catch (err) {
384 console.error("[2fa] disable:", err);
385 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
386 }
387});
388
389settings2fa.post("/settings/2fa/recovery/regen", async (c) => {
390 const user = c.get("user")!;
391 try {
392 const [row] = await db
393 .select({ enabledAt: userTotp.enabledAt })
394 .from(userTotp)
395 .where(eq(userTotp.userId, user.id))
396 .limit(1);
397 if (!row || !row.enabledAt) {
398 return c.redirect(
399 errorRedirect("/settings/2fa", "Enable 2FA first")
400 );
401 }
402 const codes = generateRecoveryCodes(10);
403 const hashes = await Promise.all(codes.map(hashRecoveryCode));
404 await db
405 .delete(userRecoveryCodes)
406 .where(eq(userRecoveryCodes.userId, user.id));
407 await db.insert(userRecoveryCodes).values(
408 hashes.map((h) => ({ userId: user.id, codeHash: h }))
409 );
410 await audit({
411 userId: user.id,
412 action: "2fa.recovery.regenerate",
413 targetType: "user",
414 targetId: user.id,
415 });
416 return c.html(
417 <Layout title="New recovery codes" user={user}>
418 <div class="settings-container">
419 <h2>New recovery codes</h2>
420 <div
421 style="background: rgba(248, 81, 73, 0.1); border: 1px solid var(--red); color: var(--red); padding: 8px 12px; border-radius: var(--radius); margin-bottom: 16px; font-size: 13px"
422 >
423 Store these somewhere safe — the previous codes no longer work.
424 </div>
425 <pre
426 style="background: var(--bg-secondary); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; font-family: monospace; font-size: 14px; line-height: 1.6"
427 >
428{codes.join("\n")}
429 </pre>
430 <a href="/settings/2fa" class="btn btn-primary">
431 Done
432 </a>
433 </div>
434 </Layout>
435 );
436 } catch (err) {
437 console.error("[2fa] regen:", err);
438 return c.redirect(errorRedirect("/settings/2fa", "Service unavailable"));
439 }
440});
441
442// Keep the import-check happy — config is intentionally available for
443// future issuer customisation.
444void config;
445
446export default settings2fa;
0447