Vectrum
Sign In

Add two-factor authentication with an authenticator app

TOTP (RFC 6238) set up under Settings → Two-factor auth by scanning a QR code, with ten
one-time recovery codes. With it on, signing in in the browser asks for a code after the
password; the session token waits on the server until then. Codes work once, recovery
codes are stored as hashes, and wrong codes are limited per account.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
JjohannSocommitted 1 parent aebe5facommitf3633f7

Showing 20 changed files with 777 additions and 14 deletions.

+15−4app/actions/auth.tsView File
@@ -1,7 +1,7 @@
11 "use server";
22
33 import { redirect } from "next/navigation";
4- import { endSession, safeNext, signIn } from "@/lib/auth";
4+ import { completeSignIn, endSession, safeNext, signIn } from "@/lib/auth";
55 import { SIGNUPS_ENABLED } from "@/lib/signups";
66 import { createUser, UserError } from "@/lib/users";
77 import type { FormState } from "./types";
@@ -25,9 +25,20 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis
2525 }
2626
2727 export async function loginAction(_prev: FormState, formData: FormData): Promise<FormState> {
28- const user = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
29- if (!user) return { error: "Incorrect username or password." };
30- redirect(safeNext(formData.get("next")));
28+ const result = await signIn(String(formData.get("login") ?? ""), String(formData.get("password") ?? ""));
29+ if (!result) return { error: "Incorrect username or password." };
30+ const next = safeNext(formData.get("next"));
31+ if (result.needsCode) redirect(next === "/" ? "/login/two-factor" : `/login/two-factor?next=${encodeURIComponent(next)}`);
32+ redirect(next);
33+ }
34+
35+ export async function twoFactorLoginAction(_prev: FormState, formData: FormData): Promise<FormState> {
36+ const next = safeNext(formData.get("next"));
37+ const result = await completeSignIn(String(formData.get("code") ?? ""));
38+ if (result === "invalid") return { error: "That code didn't match. Try the newest code from your app." };
39+ if (result === "locked") return { error: "Too many wrong codes. Wait 15 minutes, then try again." };
40+ if (result === "expired") redirect(`/login?expired=1${next !== "/" ? `&next=${encodeURIComponent(next)}` : ""}`);
41+ redirect(next);
3142 }
3243
3344 export async function logoutAction() {
+55−2app/actions/settings.tsView File
@@ -1,9 +1,17 @@
11 "use server";
22
33 import { revalidatePath } from "next/cache";
4- import { renewSession, signIn } from "@/lib/auth";
4+ import { renewSession } from "@/lib/auth";
55 import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys";
66 import { createToken, deleteToken, TokenError, type TokenPermission } from "@/lib/tokens";
7+ import {
8+ cancelTwoFactorSetup,
9+ disableTwoFactor,
10+ enableTwoFactor,
11+ regenerateRecoveryCodes,
12+ startTwoFactorSetup,
13+ TwoFactorError,
14+ } from "@/lib/two-factor";
715 import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users";
816 import { grantableRepoIds, viewerOrThrow } from "./guard";
917 import type { FormState } from "./types";
@@ -36,10 +44,55 @@ export async function changePasswordAction(_prev: FormState, formData: FormData)
3644 if (problem) return { error: problem };
3745 // This signs the user out everywhere, so sign this browser back in.
3846 await setPassword(viewer.id, next);
39- await signIn(viewer.email, next);
47+ await renewSession(viewer.id);
4048 return { success: "Password changed." };
4149 }
4250
51+ export async function startTwoFactorSetupAction() {
52+ const viewer = await viewerOrThrow();
53+ await startTwoFactorSetup(viewer.id);
54+ revalidatePath("/settings/security");
55+ }
56+
57+ export async function cancelTwoFactorSetupAction() {
58+ const viewer = await viewerOrThrow();
59+ await cancelTwoFactorSetup(viewer.id);
60+ revalidatePath("/settings/security");
61+ }
62+
63+ // These show recovery codes once, so they don't refresh the page; its Done button does.
64+ export async function enableTwoFactorAction(_prev: FormState, formData: FormData): Promise<FormState> {
65+ const viewer = await viewerOrThrow();
66+ try {
67+ return { codes: await enableTwoFactor(viewer.id, String(formData.get("code") ?? "")) };
68+ } catch (e) {
69+ if (e instanceof TwoFactorError) return { error: e.message };
70+ throw e;
71+ }
72+ }
73+
74+ export async function regenerateRecoveryCodesAction(_prev: FormState, formData: FormData): Promise<FormState> {
75+ const viewer = await viewerOrThrow();
76+ try {
77+ return { codes: await regenerateRecoveryCodes(viewer.id, String(formData.get("code") ?? "")) };
78+ } catch (e) {
79+ if (e instanceof TwoFactorError) return { error: e.message };
80+ throw e;
81+ }
82+ }
83+
84+ export async function disableTwoFactorAction(_prev: FormState, formData: FormData): Promise<FormState> {
85+ const viewer = await viewerOrThrow();
86+ try {
87+ await disableTwoFactor(viewer.id, String(formData.get("code") ?? ""));
88+ } catch (e) {
89+ if (e instanceof TwoFactorError) return { error: e.message };
90+ throw e;
91+ }
92+ revalidatePath("/settings/security");
93+ return { success: "Two-factor authentication is off." };
94+ }
95+
4396 export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> {
4497 const viewer = await viewerOrThrow();
4598 try {
+1−1app/actions/types.tsView File
@@ -1 +1 @@
1- export type FormState = { error?: string; success?: string; token?: string } | undefined;
1+ export type FormState = { error?: string; success?: string; token?: string; codes?: string[] } | undefined;
+8−2app/login/page.tsxView File
@@ -4,14 +4,15 @@ import { redirect } from "next/navigation";
44 import { Link } from "@unlocalhosted/metalui";
55 import { LoginForm } from "@/components/auth-forms";
66 import { BRAND, LogoMark } from "@/components/brand";
7- import { Box } from "@/components/ui";
7+ import { Alert, Box } from "@/components/ui";
88 import { getViewer, safeNext } from "@/lib/auth";
99 import { SIGNUPS_ENABLED } from "@/lib/signups";
1010
1111 export const metadata: Metadata = { title: "Sign in" };
1212
1313 export default async function LoginPage({ searchParams }: PageProps<"/login">) {
14- const next = safeNext((await searchParams).next);
14+ const params = await searchParams;
15+ const next = safeNext(params.next);
1516 if (await getViewer()) redirect(next);
1617
1718 return (
@@ -26,6 +27,11 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) {
2627 <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Welcome back</h1>
2728 <p className="mt-1 type-lead text-ink2">Sign in to continue to {BRAND.name}</p>
2829 </div>
30+ {params.expired && (
31+ <div className="mt-8">
32+ <Alert kind="info">Your sign-in timed out. Enter your password again.</Alert>
33+ </div>
34+ )}
2935 <Box className="mt-8 p-6">
3036 <LoginForm next={next} />
3137 </Box>
+39−0app/login/two-factor/page.tsxaddedView File
@@ -0,0 +1,39 @@
1+ import type { Metadata } from "next";
2+ import NextLink from "next/link";
3+ import { redirect } from "next/navigation";
4+ import { Link } from "@unlocalhosted/metalui";
5+ import { TwoFactorLoginForm } from "@/components/auth-forms";
6+ import { ShieldLockIcon } from "@/components/icons";
7+ import { Box } from "@/components/ui";
8+ import { getViewer, hasSignInChallenge, safeNext } from "@/lib/auth";
9+
10+ export const metadata: Metadata = { title: "Two-factor authentication" };
11+
12+ export default async function TwoFactorLoginPage({ searchParams }: PageProps<"/login/two-factor">) {
13+ const next = safeNext((await searchParams).next);
14+ if (await getViewer()) redirect(next);
15+ const login = `/login${next !== "/" ? `?next=${encodeURIComponent(next)}` : ""}`;
16+ if (!(await hasSignInChallenge())) redirect(login);
17+
18+ return (
19+ <main className="relative flex flex-1 flex-col items-center justify-center overflow-hidden px-4 py-16">
20+ <div className="aurora pointer-events-none absolute inset-0" aria-hidden />
21+ <div className="dot-grid pointer-events-none absolute inset-0" aria-hidden />
22+ <div className="animate-fade-up relative w-full max-w-[380px]">
23+ <div className="text-center">
24+ <span className="mx-auto flex size-16 items-center justify-center rounded-hero material-cap [&_svg]:size-8">
25+ <ShieldLockIcon />
26+ </span>
27+ <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Two-factor authentication</h1>
28+ <p className="mt-1 type-lead text-ink2">Enter the code from your authenticator app</p>
29+ </div>
30+ <Box className="mt-8 p-6">
31+ <TwoFactorLoginForm next={next} />
32+ </Box>
33+ <p className="mt-6 text-center type-body text-ink2">
34+ Not you? <Link render={<NextLink href={login} />}>Sign in with another account</Link>
35+ </p>
36+ </div>
37+ </main>
38+ );
39+ }
+2−1app/settings/layout.tsxView File
@@ -1,5 +1,5 @@
11 import { SettingsNav } from "@/components/settings-nav";
2- import { ActivityIcon, KeyIcon, PersonIcon, ServerIcon, ShieldLockIcon, SparkleIcon } from "@/components/icons";
2+ import { ActivityIcon, KeyIcon, LockIcon, PersonIcon, ServerIcon, ShieldLockIcon, SparkleIcon } from "@/components/icons";
33 import { Avatar } from "@/components/ui";
44 import { requireViewer } from "@/lib/auth";
55
@@ -19,6 +19,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting
1919 title="Account"
2020 items={[
2121 { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> },
22+ { href: "/settings/security", label: "Two-factor auth", icon: <LockIcon /> },
2223 { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> },
2324 { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> },
2425 { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> },
+141−0app/settings/security/forms.tsxaddedView File
@@ -0,0 +1,141 @@
1+ "use client";
2+
3+ import { useActionState, type ReactNode } from "react";
4+ import {
5+ cancelTwoFactorSetupAction,
6+ disableTwoFactorAction,
7+ enableTwoFactorAction,
8+ regenerateRecoveryCodesAction,
9+ } from "@/app/actions/settings";
10+ import { DownloadIcon } from "@/components/icons";
11+ import { CopyIconButton, TextField } from "@/components/settings-fields";
12+ import { PlateBody, PlateFooter } from "@/components/settings-plate";
13+ import { Alert, Button } from "@/components/ui";
14+
15+ function CodeField({ label = "Authentication code", description }: { label?: string; description?: ReactNode }) {
16+ return (
17+ <TextField
18+ label={label}
19+ description={description}
20+ name="code"
21+ required
22+ autoComplete="one-time-code"
23+ autoCapitalize="none"
24+ spellCheck={false}
25+ maxLength={20}
26+ className="max-w-xs"
27+ mono
28+ />
29+ );
30+ }
31+
32+ /** Recovery codes, shown once: copy, download, then Done reloads the page. */
33+ function RecoveryCodes({ codes, notice }: { codes: string[]; notice: string }) {
34+ const text = codes.join("\n");
35+ const download = () => {
36+ const url = URL.createObjectURL(new Blob([`${text}\n`], { type: "text/plain" }));
37+ const a = Object.assign(document.createElement("a"), { href: url, download: "recovery-codes.txt" });
38+ a.click();
39+ URL.revokeObjectURL(url);
40+ };
41+ return (
42+ <>
43+ <PlateBody className="max-w-2xl">
44+ <Alert kind="success">{notice}</Alert>
45+ <p className="type-body text-ink2">
46+ Save these recovery codes somewhere safe, like a password manager. If you lose your device, each one signs you in once.
47+ You won’t be able to see them again.
48+ </p>
49+ <div className="flex items-start gap-2 rounded-row recipe-well-field py-3 pr-1.5 pl-4">
50+ <ul className="grid flex-1 grid-cols-2 gap-x-8 gap-y-1.5 font-mono text-[13px] leading-5 text-ink">
51+ {codes.map((code) => (
52+ <li key={code}>{code}</li>
53+ ))}
54+ </ul>
55+ <CopyIconButton text={text} label="Copy codes" toast="Copied recovery codes" />
56+ </div>
57+ </PlateBody>
58+ <PlateFooter>
59+ <Button type="button" icon={<DownloadIcon />} onClick={download}>
60+ Download
61+ </Button>
62+ <Button type="button" variant="primary" onClick={() => window.location.reload()}>
63+ Done
64+ </Button>
65+ </PlateFooter>
66+ </>
67+ );
68+ }
69+
70+ export function EnableTwoFactorForm({ qr, secret }: { qr: ReactNode; secret: string }) {
71+ const [state, action, pending] = useActionState(enableTwoFactorAction, undefined);
72+ if (state?.codes) return <RecoveryCodes codes={state.codes} notice="Two-factor authentication is on." />;
73+ return (
74+ <form action={action}>
75+ <PlateBody className="max-w-2xl gap-6">
76+ <div className="flex flex-col gap-4 sm:flex-row sm:items-start">
77+ <div className="shrink-0 self-start rounded-row bg-white p-1.5">{qr}</div>
78+ <div className="flex min-w-0 flex-col gap-3">
79+ <div className="flex flex-col gap-1">
80+ <p className="type-title text-ink">1. Scan the QR code</p>
81+ <p className="type-body text-ink2">Use an authenticator app like 1Password, Google Authenticator, Microsoft Authenticator, Authy or Bitwarden.</p>
82+ </div>
83+ <div className="flex flex-col gap-1.5">
84+ <p className="type-body text-ink2">Can’t scan it? Enter this setup key instead:</p>
85+ <div className="flex items-center gap-2 rounded-row recipe-well-field py-1.5 pr-1.5 pl-3.5">
86+ <code className="flex-1 font-mono text-[12.5px] leading-5 break-all text-ink">{secret}</code>
87+ <CopyIconButton text={secret.replace(/\s/g, "")} label="Copy setup key" toast="Copied setup key" />
88+ </div>
89+ </div>
90+ </div>
91+ </div>
92+ <div className="flex flex-col gap-3">
93+ <p className="type-title text-ink">2. Enter the 6-digit code from the app</p>
94+ <CodeField label="Code" />
95+ </div>
96+ {state?.error && <Alert>{state.error}</Alert>}
97+ </PlateBody>
98+ <PlateFooter>
99+ <Button type="submit" formAction={cancelTwoFactorSetupAction} formNoValidate disabled={pending}>
100+ Cancel
101+ </Button>
102+ <Button variant="primary" disabled={pending}>
103+ {pending ? "Verifying…" : "Verify and Turn On"}
104+ </Button>
105+ </PlateFooter>
106+ </form>
107+ );
108+ }
109+
110+ export function RegenerateRecoveryCodesForm() {
111+ const [state, action, pending] = useActionState(regenerateRecoveryCodesAction, undefined);
112+ if (state?.codes) return <RecoveryCodes codes={state.codes} notice="New recovery codes generated. The old ones no longer work." />;
113+ return (
114+ <form action={action}>
115+ <PlateBody className="max-w-2xl">
116+ <CodeField description="A code from your authenticator app, to generate new recovery codes. The old ones stop working." />
117+ {state?.error && <Alert>{state.error}</Alert>}
118+ </PlateBody>
119+ <PlateFooter>
120+ <Button disabled={pending}>{pending ? "Generating…" : "Generate New Codes"}</Button>
121+ </PlateFooter>
122+ </form>
123+ );
124+ }
125+
126+ export function DisableTwoFactorForm() {
127+ const [state, action, pending] = useActionState(disableTwoFactorAction, undefined);
128+ return (
129+ <form action={action}>
130+ <PlateBody className="max-w-2xl">
131+ <CodeField description="A code from your authenticator app, or a recovery code. Signing in will take only your password again." />
132+ {state?.error && <Alert>{state.error}</Alert>}
133+ </PlateBody>
134+ <PlateFooter>
135+ <Button variant="danger" disabled={pending}>
136+ {pending ? "Turning off…" : "Turn Off Two-Factor Authentication"}
137+ </Button>
138+ </PlateFooter>
139+ </form>
140+ );
141+ }
+81−0app/settings/security/page.tsxaddedView File
@@ -0,0 +1,81 @@
1+ import type { Metadata } from "next";
2+ import { startTwoFactorSetupAction } from "@/app/actions/settings";
3+ import { BRAND } from "@/components/brand";
4+ import { LockIcon } from "@/components/icons";
5+ import { SettingsRow, SettingsSection } from "@/components/metal";
6+ import { QrCode } from "@/components/qr-code";
7+ import { PlateBody, PlateFooter, PlateSection } from "@/components/settings-plate";
8+ import { Button, Label, PageTitle } from "@/components/ui";
9+ import { requireViewer } from "@/lib/auth";
10+ import { formatDate } from "@/lib/format";
11+ import { groupSecret, otpauthUri } from "@/lib/totp";
12+ import { twoFactorStatus } from "@/lib/two-factor";
13+ import { DisableTwoFactorForm, EnableTwoFactorForm, RegenerateRecoveryCodesForm } from "./forms";
14+
15+ export const metadata: Metadata = { title: "Two-factor authentication" };
16+
17+ export default async function SecuritySettingsPage() {
18+ const viewer = await requireViewer("/settings/security");
19+ const status = await twoFactorStatus(viewer.id);
20+
21+ return (
22+ <div>
23+ <PageTitle
24+ title="Two-factor authentication"
25+ description="Ask for a code from an authenticator app on your phone when you sign in, besides your password."
26+ />
27+ <div className="flex flex-col gap-7">
28+ {status.enabled ? (
29+ <>
30+ <SettingsSection title="Authenticator app">
31+ <SettingsRow
32+ name={
33+ <span className="flex items-center gap-2">
34+ Two-factor authentication <Label tone="success">On</Label>
35+ </span>
36+ }
37+ detail={status.enabledAt ? `Turned on ${formatDate(status.enabledAt)}` : undefined}
38+ />
39+ <SettingsRow
40+ name="Recovery codes"
41+ detail={`${status.recoveryCodesLeft} unused. Each one signs you in once if you can’t use your app.`}
42+ />
43+ </SettingsSection>
44+ <PlateSection title="New recovery codes">
45+ <RegenerateRecoveryCodesForm />
46+ </PlateSection>
47+ <PlateSection title="Turn off two-factor authentication">
48+ <DisableTwoFactorForm />
49+ </PlateSection>
50+ </>
51+ ) : status.pendingSecret ? (
52+ <PlateSection title="Set up your authenticator app">
53+ <EnableTwoFactorForm
54+ secret={groupSecret(status.pendingSecret)}
55+ qr={<QrCode value={otpauthUri(status.pendingSecret, BRAND.name, viewer.username)} label="QR code for your authenticator app" />}
56+ />
57+ </PlateSection>
58+ ) : (
59+ <PlateSection title="Authenticator app">
60+ <form action={startTwoFactorSetupAction}>
61+ <PlateBody className="max-w-2xl">
62+ <p className="flex items-center gap-2 type-title text-ink">
63+ Two-factor authentication <Label>Off</Label>
64+ </p>
65+ <p className="type-body text-ink2">
66+ With it on, someone who learns your password still can’t sign in without your phone. Git over SSH and HTTPS, API
67+ tokens and connected apps keep working as they are.
68+ </p>
69+ </PlateBody>
70+ <PlateFooter>
71+ <Button variant="primary" icon={<LockIcon />}>
72+ Set Up Authenticator App
73+ </Button>
74+ </PlateFooter>
75+ </form>
76+ </PlateSection>
77+ )}
78+ </div>
79+ </div>
80+ );
81+ }
+3−0bun.lockView File
@@ -23,6 +23,7 @@
2323 "server-only": "^0.0.1",
2424 "shiki": "^4.4.3",
2525 "ssh2": "^1.17.0",
26+ "uqr": "^0.1.3",
2627 "yaml": "^2.9.1",
2728 },
2829 "devDependencies": {
@@ -1248,6 +1249,8 @@
12481249
12491250 "update-browserslist-db": ["update-browserslist-db@1.3.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-pJ2sYawQS0R/WI928Gj5GlPhTGzbMelq0+4INtSYNDV9ErKJcX6xjGWkoG/VnB3dpUm00zALaqkrUD77pO5TDQ=="],
12501251
1252+ "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="],
1253+
12511254 "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
12521255
12531256 "use-sync-external-store": ["use-sync-external-store@1.7.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A=="],
+26−1components/auth-forms.tsxView File
@@ -3,7 +3,7 @@
33 import NextLink from "next/link";
44 import { useActionState } from "react";
55 import { Link } from "@unlocalhosted/metalui";
6- import { loginAction, signupAction } from "@/app/actions/auth";
6+ import { loginAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth";
77 import { TextField } from "./settings-fields";
88 import { Alert, Button } from "./ui";
99
@@ -22,6 +22,31 @@ export function LoginForm({ next }: { next: string }) {
2222 );
2323 }
2424
25+ export function TwoFactorLoginForm({ next }: { next: string }) {
26+ const [state, action, pending] = useActionState(twoFactorLoginAction, undefined);
27+ return (
28+ <form action={action} className="flex flex-col gap-4">
29+ <input type="hidden" name="next" value={next} />
30+ {state?.error && <Alert>{state.error}</Alert>}
31+ <TextField
32+ label="Authentication code"
33+ description="From your authenticator app. Lost your device? Enter one of your recovery codes."
34+ name="code"
35+ required
36+ autoFocus
37+ autoComplete="one-time-code"
38+ autoCapitalize="none"
39+ spellCheck={false}
40+ maxLength={20}
41+ mono
42+ />
43+ <Button variant="primary" className="mt-1 w-full" disabled={pending}>
44+ {pending ? "Verifying…" : "Verify"}
45+ </Button>
46+ </form>
47+ );
48+ }
49+
2550 export function SignupForm({ next }: { next: string }) {
2651 const [state, action, pending] = useActionState(signupAction, undefined);
2752 return (
+14−0components/qr-code.tsxaddedView File
@@ -0,0 +1,14 @@
1+ import { encode } from "uqr";
2+
3+ /** A QR code as an SVG, dark on white in both themes so every camera reads it. Server-safe. */
4+ export function QrCode({ value, size = 176, label }: { value: string; size?: number; label: string }) {
5+ const { data, size: modules } = encode(value, { ecc: "M", border: 2 });
6+ let d = "";
7+ data.forEach((row, y) => row.forEach((dark, x) => dark && (d += `M${x} ${y}h1v1h-1z`)));
8+ return (
9+ <svg viewBox={`0 0 ${modules} ${modules}`} width={size} height={size} role="img" aria-label={label} shapeRendering="crispEdges" className="rounded-row">
10+ <rect width={modules} height={modules} fill="#fff" />
11+ <path d={d} fill="#000" />
12+ </svg>
13+ );
14+ }
+1−0docs/features.mdView File
@@ -1,6 +1,7 @@
11 # Features
22
33 - **Accounts**: sign up, sign in, profiles, password change (PocketBase auth: bcrypt-hashed passwords, token sessions)
4+ - **Two-factor authentication** with an authenticator app (TOTP: 1Password, Google Authenticator, Authy…): set up under *Settings → Two-factor auth* by scanning a QR code, with ten one-time recovery codes for a lost device. It guards signing in with a password; SSH keys, API tokens and connected apps work as before
45 - **Repositories**: public or private, rename, delete, optional README on creation
56 - **Collaborators**: give other users push access to a repository
67 - **Forks**: anyone who can read a repository can fork it (the *Fork* button) into a copy of their own, of its default branch or all branches. Forks carry a *Fork* label and a "forked from" link everywhere they're listed, and the original lists them under *Forks*. A fork's front page says how far it is ahead of and behind the original, with *Sync Fork* (a fast-forward, or a merge commit if the fork has commits of its own) and *Contribute* (a pull request back). Forks of private repositories stay private
+1−1docs/layout.mdView File
@@ -1,7 +1,7 @@
11 # Code layout
22
33 - `lib/db.ts`: starting and connecting to PocketBase; `pb_migrations/`: the schema
4- - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens)
4+ - `lib/users.ts`, `lib/auth.ts`: accounts and sessions (PocketBase auth records and tokens); `lib/totp.ts`, `lib/two-factor.ts`: two-factor authentication with an authenticator app
55 - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators
66 - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/`
77 - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens
+1−0docs/security.mdView File
@@ -8,3 +8,4 @@
88 - OAuth for agents follows the MCP authorization spec: public clients only, PKCE (S256) required, exact redirect URIs (any port for `localhost`), one-time codes that expire in 5 minutes, access tokens that last an hour, and refresh tokens that rotate on every use (presenting an old one revokes the grant). Only hashes of codes and tokens are stored. The consent page can't be framed, and it shows where it will send you, because anyone can register an application under any name.
99 - Release files are always served as `application/octet-stream` attachments with `nosniff` and a sandboxing CSP. Uploads signed in with the browser session must come from the site's own origin; scripts use tokens.
1010 - Raw files are served with `nosniff` and a sandboxing CSP, so repository HTML/SVG can't run scripts in the app's origin.
11+ - Two-factor authentication (TOTP, RFC 6238) is checked after the password, before any session exists: the password's session token waits on the server for 5 minutes and 5 codes. Wrong codes also count per account (10 in 15 minutes locks codes out for that long). A code works once (also no older one after it), recovery codes are stored as hashes and deleted when used, and the secret is a hidden field only the server reads. Turning 2FA off takes a code.
+97−2lib/auth.tsView File
@@ -1,10 +1,12 @@
11 import "server-only";
22 import { cache } from "react";
3+ import { createHash, randomBytes } from "node:crypto";
34 import { cookies } from "next/headers";
45 import { redirect } from "next/navigation";
56 import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase";
67 import { client, db } from "./db";
78 import { SECURE_COOKIES } from "./request-origin";
9+ import { hasTwoFactor, verifySecondFactor } from "./two-factor";
810 import { authenticateUser, toUser } from "./users";
911
1012 // Sessions are PocketBase auth tokens, kept in an httpOnly cookie. They expire after 30 days (the
@@ -23,12 +25,105 @@ async function setSessionCookie(token: string) {
2325 });
2426 }
2527
26- /** Checks a username or email and password and starts a session. Returns the user, or null. */
28+ /**
29+ * Checks a username or email and password. Starts a session, or with two-factor authentication on,
30+ * a sign-in challenge that `completeSignIn` finishes with a code. Returns null for a wrong password.
31+ */
2732 export async function signIn(login: string, password: string) {
2833 const auth = await authenticateUser(login, password);
2934 if (!auth) return null;
35+ if (await hasTwoFactor(auth.user.id)) {
36+ await startChallenge(auth.user.id, auth.token);
37+ return { user: auth.user, needsCode: true };
38+ }
3039 await setSessionCookie(auth.token);
31- return auth.user;
40+ return { user: auth.user, needsCode: false };
41+ }
42+
43+ /*
44+ * Sign-in challenges: after the password, the session token waits on the server for a code, for 5
45+ * minutes and 5 tries. The browser holds a random id for it in a cookie. They live in memory, as
46+ * one Node.js process serves all requests (see withLock); a restart only means signing in again.
47+ * Wrong codes also count per user, so new challenges don't give more guesses: after 10 in 15
48+ * minutes, codes for that account are refused until the 15 minutes are over.
49+ */
50+
51+ const CHALLENGE_COOKIE = "signin_challenge";
52+ const CHALLENGE_MS = 5 * 60_000;
53+ const CHALLENGE_TRIES = 5;
54+ const FAILURE_WINDOW_MS = 15 * 60_000;
55+ const FAILURE_LIMIT = 10;
56+
57+ type Challenge = { userId: string; token: string; expires: number; tries: number };
58+ const g = globalThis as unknown as {
59+ __signinChallenges?: Map<string, Challenge>;
60+ __signinFailures?: Map<string, { count: number; since: number }>;
61+ };
62+ const challenges = (g.__signinChallenges ??= new Map());
63+ const failures = (g.__signinFailures ??= new Map());
64+
65+ const challengeKey = (id: string) => createHash("sha256").update(id).digest("hex");
66+
67+ async function startChallenge(userId: string, token: string) {
68+ const now = Date.now();
69+ for (const [key, c] of challenges) if (c.expires < now) challenges.delete(key);
70+ const id = randomBytes(32).toString("base64url");
71+ challenges.set(challengeKey(id), { userId, token, expires: now + CHALLENGE_MS, tries: 0 });
72+ (await cookies()).set(CHALLENGE_COOKIE, id, {
73+ httpOnly: true,
74+ sameSite: "lax",
75+ secure: SECURE_COOKIES,
76+ path: "/login",
77+ maxAge: CHALLENGE_MS / 1000,
78+ });
79+ }
80+
81+ async function currentChallenge() {
82+ const id = (await cookies()).get(CHALLENGE_COOKIE)?.value;
83+ if (!id) return null;
84+ const key = challengeKey(id);
85+ const challenge = challenges.get(key);
86+ if (!challenge || challenge.expires < Date.now()) {
87+ challenges.delete(key);
88+ return null;
89+ }
90+ return { key, challenge };
91+ }
92+
93+ /** Whether this browser has entered a password and still needs to enter a code. */
94+ export async function hasSignInChallenge() {
95+ return !!(await currentChallenge());
96+ }
97+
98+ /** Finishes signing in with a code from the authenticator app or a recovery code. */
99+ export async function completeSignIn(code: string): Promise<"ok" | "invalid" | "expired" | "locked"> {
100+ const current = await currentChallenge();
101+ if (!current) return "expired";
102+ const { key, challenge } = current;
103+ const now = Date.now();
104+ const failed = failures.get(challenge.userId);
105+ if (failed && now - failed.since > FAILURE_WINDOW_MS) failures.delete(challenge.userId);
106+ if ((failures.get(challenge.userId)?.count ?? 0) >= FAILURE_LIMIT) return "locked";
107+
108+ if (!(await verifySecondFactor(challenge.userId, code))) {
109+ const f = failures.get(challenge.userId) ?? { count: 0, since: now };
110+ failures.set(challenge.userId, { ...f, count: f.count + 1 });
111+ if (++challenge.tries >= CHALLENGE_TRIES) {
112+ challenges.delete(key);
113+ await endChallenge();
114+ return "expired";
115+ }
116+ return "invalid";
117+ }
118+ challenges.delete(key);
119+ failures.delete(challenge.userId);
120+ await endChallenge();
121+ await setSessionCookie(challenge.token);
122+ return "ok";
123+ }
124+
125+ async function endChallenge() {
126+ (await cookies()).delete({ name: CHALLENGE_COOKIE, path: "/login" });
32127 }
33128
34129 /** A new session for a signed-in user whose old token stopped working (after an email change). */
+61−0lib/totp.test.tsaddedView File
@@ -0,0 +1,61 @@
1+ // Run with `bun test`.
2+ import assert from "node:assert/strict";
3+ import { test } from "node:test";
4+ import { base32Decode, base32Encode, hashRecoveryCode, hotp, matchTotp, newRecoveryCodes, newTotpSecret, otpauthUri, totpStep } from "./totp";
5+
6+ // The RFC 6238 test key for SHA-1, and its 8-digit codes from Appendix B.
7+ const RFC_KEY = Buffer.from("12345678901234567890");
8+ const RFC_SECRET = base32Encode(RFC_KEY);
9+
10+ test("base32 round-trips and matches RFC 4648", () => {
11+ assert.equal(base32Encode(Buffer.from("foobar")), "MZXW6YTBOI");
12+ assert.equal(base32Decode("MZXW6YTBOI").toString(), "foobar");
13+ assert.equal(base32Decode("mzxw 6ytb-oi======").toString(), "foobar");
14+ const secret = newTotpSecret();
15+ assert.equal(secret.length, 32);
16+ assert.equal(base32Encode(base32Decode(secret)), secret);
17+ assert.throws(() => base32Decode("not base32!"));
18+ });
19+
20+ test("HOTP and TOTP match the RFC test vectors", () => {
21+ // RFC 4226 Appendix D
22+ assert.deepEqual([0, 1, 2, 9].map((c) => hotp(RFC_KEY, c)), ["755224", "287082", "359152", "520489"]);
23+ // RFC 6238 Appendix B (SHA-1)
24+ const vectors: [number, string][] = [
25+ [59, "94287082"],
26+ [1111111109, "07081804"],
27+ [1234567890, "89005924"],
28+ [2000000000, "69279037"],
29+ [20000000000, "65353130"],
30+ ];
31+ for (const [seconds, code] of vectors) assert.equal(hotp(RFC_KEY, totpStep(seconds * 1000), 8), code, String(seconds));
32+ });
33+
34+ test("codes count for their own time step and the ones next to it", () => {
35+ const now = 1_111_111_109_000;
36+ const step = totpStep(now);
37+ assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step), now), step);
38+ assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step - 1), now), step - 1);
39+ assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step + 1), now), step + 1);
40+ assert.equal(matchTotp(RFC_SECRET, hotp(RFC_KEY, step - 2), now), null);
41+ assert.equal(matchTotp(RFC_SECRET, "12345", now), null);
42+ assert.equal(matchTotp(RFC_SECRET, "abcdef", now), null);
43+ });
44+
45+ test("otpauth URIs name the issuer and account", () => {
46+ const uri = new URL(otpauthUri("JBSWY3DPEHPK3PXP", "Vectrum", "alice"));
47+ assert.equal(uri.protocol, "otpauth:");
48+ assert.equal(uri.host, "totp");
49+ assert.equal(decodeURIComponent(uri.pathname), "/Vectrum:alice");
50+ assert.equal(uri.searchParams.get("secret"), "JBSWY3DPEHPK3PXP");
51+ assert.equal(uri.searchParams.get("issuer"), "Vectrum");
52+ });
53+
54+ test("recovery codes are unique, and their hashes ignore case, spaces and hyphens", () => {
55+ const codes = newRecoveryCodes();
56+ assert.equal(codes.length, 10);
57+ assert.equal(new Set(codes).size, 10);
58+ for (const code of codes) assert.match(code, /^[a-z2-7]{5}-[a-z2-7]{5}$/);
59+ assert.equal(hashRecoveryCode(codes[0]), hashRecoveryCode(` ${codes[0].toUpperCase().replace("-", "")} `));
60+ assert.notEqual(hashRecoveryCode(codes[0]), hashRecoveryCode(codes[1]));
61+ });
+96−0lib/totp.tsaddedView File
@@ -0,0 +1,96 @@
1+ import { createHash, createHmac, randomBytes } from "node:crypto";
2+
3+ // Time-based one-time passwords (RFC 6238) for authenticator apps: HMAC-SHA1, 6 digits, a new code
4+ // every 30 seconds. The secret is shared once, as a QR code of an otpauth:// URI.
5+
6+ const BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
7+ const PERIOD = 30;
8+ const DIGITS = 6;
9+
10+ export function base32Encode(bytes: Uint8Array) {
11+ let out = "";
12+ let bits = 0;
13+ let value = 0;
14+ for (const byte of bytes) {
15+ value = (value << 8) | byte;
16+ bits += 8;
17+ while (bits >= 5) {
18+ out += BASE32[(value >>> (bits - 5)) & 31];
19+ bits -= 5;
20+ }
21+ }
22+ if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
23+ return out;
24+ }
25+
26+ export function base32Decode(text: string) {
27+ const clean = text.toUpperCase().replace(/[\s=-]/g, "");
28+ const bytes: number[] = [];
29+ let bits = 0;
30+ let value = 0;
31+ for (const char of clean) {
32+ const index = BASE32.indexOf(char);
33+ if (index < 0) throw new Error("Invalid base32.");
34+ value = (value << 5) | index;
35+ bits += 5;
36+ if (bits >= 8) {
37+ bytes.push((value >>> (bits - 8)) & 255);
38+ bits -= 8;
39+ }
40+ }
41+ return Buffer.from(bytes);
42+ }
43+
44+ /** A new secret: 160 random bits, the size RFC 4226 recommends for SHA-1. */
45+ export const newTotpSecret = () => base32Encode(randomBytes(20));
46+
47+ /** The HOTP code (RFC 4226) for a counter. */
48+ export function hotp(key: Uint8Array, counter: number, digits = DIGITS) {
49+ const message = Buffer.alloc(8);
50+ message.writeUInt32BE(Math.floor(counter / 2 ** 32), 0);
51+ message.writeUInt32BE(counter >>> 0, 4);
52+ const mac = createHmac("sha1", key).update(message).digest();
53+ const offset = mac[mac.length - 1] & 15;
54+ const number = mac.readUInt32BE(offset) & 0x7fffffff;
55+ return String(number % 10 ** digits).padStart(digits, "0");
56+ }
57+
58+ export const totpStep = (now = Date.now()) => Math.floor(now / 1000 / PERIOD);
59+
60+ /**
61+ * The time step a code belongs to, or null if it's wrong. Codes from the step before and after
62+ * count too, for clocks that are a little off and codes typed just as they changed.
63+ */
64+ export function matchTotp(secret: string, code: string, now = Date.now()) {
65+ if (!/^\d{6}$/.test(code)) return null;
66+ const key = base32Decode(secret);
67+ const current = totpStep(now);
68+ for (const step of [current, current - 1, current + 1]) {
69+ if (hotp(key, step) === code) return step;
70+ }
71+ return null;
72+ }
73+
74+ /** What authenticator apps scan: otpauth://totp/Issuer:account?secret=…&issuer=Issuer */
75+ export function otpauthUri(secret: string, issuer: string, account: string) {
76+ const label = encodeURIComponent(`${issuer}:${account}`);
77+ const params = new URLSearchParams({ secret, issuer, algorithm: "SHA1", digits: String(DIGITS), period: String(PERIOD) });
78+ return `otpauth://totp/${label}?${params}`;
79+ }
80+
81+ /** The secret in groups of four, easier to type into an app by hand. */
82+ export const groupSecret = (secret: string) => secret.match(/.{1,4}/g)?.join(" ") ?? secret;
83+
84+ /** What the user typed, without the spaces and hyphens people add, lowercased. */
85+ export const normalizeCode = (input: string) => input.replace(/[\s-]/g, "").toLowerCase();
86+
87+ /** One-time recovery codes, like "k3m9q-7xw2p": 50 random bits each. */
88+ export function newRecoveryCodes(count = 10) {
89+ return Array.from({ length: count }, () => {
90+ const code = base32Encode(randomBytes(7)).slice(0, 10).toLowerCase();
91+ return `${code.slice(0, 5)}-${code.slice(5)}`;
92+ });
93+ }
94+
95+ /** Recovery codes are stored as hashes of their normalized form. */
96+ export const hashRecoveryCode = (code: string) => createHash("sha256").update(normalizeCode(code)).digest("hex");
+111−0lib/two-factor.tsaddedView File
@@ -0,0 +1,111 @@
1+ import "server-only";
2+ import { db, findById, withLock, type RecordModel } from "./db";
3+ import { hashRecoveryCode, matchTotp, newRecoveryCodes, newTotpSecret, normalizeCode } from "./totp";
4+
5+ // Two-factor authentication with an authenticator app. With it on, signing in in the browser takes
6+ // a code from the app (or a recovery code) after the password. Git, the registry and agents don't
7+ // use passwords (they use SSH keys, API tokens and OAuth), so they aren't affected.
8+
9+ export type TwoFactorStatus = {
10+ enabled: boolean;
11+ enabledAt: number | null;
12+ recoveryCodesLeft: number;
13+ /** A secret being set up, not confirmed with a code yet. */
14+ pendingSecret: string | null;
15+ };
16+
17+ export class TwoFactorError extends Error {}
18+
19+ const recoveryHashes = (r: RecordModel) => (Array.isArray(r.recovery_codes) ? (r.recovery_codes as string[]) : []);
20+
21+ async function userRecord(userId: string) {
22+ const record = await findById("users", userId);
23+ if (!record) throw new TwoFactorError("Account not found.");
24+ return record;
25+ }
26+
27+ export async function twoFactorStatus(userId: string): Promise<TwoFactorStatus> {
28+ const r = await userRecord(userId);
29+ return {
30+ enabled: !!r.totp_secret,
31+ enabledAt: r.totp_enabled_at || null,
32+ recoveryCodesLeft: recoveryHashes(r).length,
33+ pendingSecret: (!r.totp_secret && r.totp_pending) || null,
34+ };
35+ }
36+
37+ export async function hasTwoFactor(userId: string) {
38+ return !!(await findById("users", userId))?.totp_secret;
39+ }
40+
41+ const update = async (userId: string, data: Record<string, unknown>) => (await db()).collection("users").update(userId, data);
42+ const lock = <T>(userId: string, fn: () => Promise<T>) => withLock(`2fa:${userId}`, fn);
43+
44+ /** Starts setting up an app: a new secret, kept aside until a code from the app confirms it. */
45+ export function startTwoFactorSetup(userId: string) {
46+ return lock(userId, async () => {
47+ if ((await userRecord(userId)).totp_secret) throw new TwoFactorError("Two-factor authentication is already on.");
48+ await update(userId, { totp_pending: newTotpSecret() });
49+ });
50+ }
51+
52+ export function cancelTwoFactorSetup(userId: string) {
53+ return lock(userId, () => update(userId, { totp_pending: "" }));
54+ }
55+
56+ /** Turns 2FA on once a code from the app matches the secret being set up. Returns the recovery codes. */
57+ export function enableTwoFactor(userId: string, code: string) {
58+ return lock(userId, async () => {
59+ const r = await userRecord(userId);
60+ if (r.totp_secret) throw new TwoFactorError("Two-factor authentication is already on.");
61+ if (!r.totp_pending) throw new TwoFactorError("Start the setup again.");
62+ const step = matchTotp(r.totp_pending, normalizeCode(code));
63+ if (step === null) throw new TwoFactorError("That code didn't match. Check your device's time and try the newest code.");
64+ const codes = newRecoveryCodes();
65+ await update(userId, {
66+ totp_secret: r.totp_pending,
67+ totp_pending: "",
68+ totp_enabled_at: Date.now(),
69+ totp_last_step: step,
70+ recovery_codes: codes.map(hashRecoveryCode),
71+ });
72+ return codes;
73+ });
74+ }
75+
76+ /**
77+ * Checks a code from the app or a recovery code, and uses it up: an app code can't be used again
78+ * (nor an older one), a recovery code is deleted.
79+ */
80+ export function verifySecondFactor(userId: string, input: string) {
81+ return lock(userId, async () => {
82+ const r = await findById("users", userId);
83+ if (!r?.totp_secret) return false;
84+ const code = normalizeCode(input);
85+ if (/^\d{6}$/.test(code)) {
86+ const step = matchTotp(r.totp_secret, code);
87+ if (step === null || step <= (r.totp_last_step || 0)) return false;
88+ await update(userId, { totp_last_step: step });
89+ return true;
90+ }
91+ const hashes = recoveryHashes(r);
92+ const hash = hashRecoveryCode(code);
93+ if (!code || !hashes.includes(hash)) return false;
94+ await update(userId, { recovery_codes: hashes.filter((h) => h !== hash) });
95+ return true;
96+ });
97+ }
98+
99+ export async function disableTwoFactor(userId: string, code: string) {
100+ if (!(await verifySecondFactor(userId, code))) throw new TwoFactorError("That code didn't match.");
101+ await lock(userId, () => update(userId, { totp_secret: "", totp_pending: "", totp_enabled_at: 0, totp_last_step: 0, recovery_codes: [] }));
102+ }
103+
104+ /** New recovery codes in place of the old ones, after a code from the app. */
105+ export async function regenerateRecoveryCodes(userId: string, code: string) {
106+ if (!/^\d{6}$/.test(normalizeCode(code))) throw new TwoFactorError("Enter a code from your authenticator app.");
107+ if (!(await verifySecondFactor(userId, code))) throw new TwoFactorError("That code didn't match.");
108+ const codes = newRecoveryCodes();
109+ await lock(userId, () => update(userId, { recovery_codes: codes.map(hashRecoveryCode) }));
110+ return codes;
111+ }
+1−0package.jsonView File
@@ -30,6 +30,7 @@
3030 "server-only": "^0.0.1",
3131 "shiki": "^4.4.3",
3232 "ssh2": "^1.17.0",
33+ "uqr": "^0.1.3",
3334 "yaml": "^2.9.1"
3435 },
3536 "devDependencies": {
+23−0pb_migrations/1791300000_two_factor.jsaddedView File
@@ -0,0 +1,23 @@
1+ // Two-factor authentication with an authenticator app (TOTP). See lib/two-factor.ts. The fields
2+ // are hidden, so only the server (a superuser) reads them, never a user's own token.
3+ //
4+ // totp_secret: the shared secret once 2FA is on; totp_pending: one being set up, until the first
5+ // code confirms it; totp_last_step: the time step of the last code used, so no code works twice;
6+ // recovery_codes: hashes of the unused one-time recovery codes.
7+
8+ migrate(
9+ (app) => {
10+ const users = app.findCollectionByNameOrId("users");
11+ users.fields.add(new TextField({ name: "totp_secret", max: 64, hidden: true }));
12+ users.fields.add(new TextField({ name: "totp_pending", max: 64, hidden: true }));
13+ users.fields.add(new NumberField({ name: "totp_enabled_at", onlyInt: true, min: 0, hidden: true }));
14+ users.fields.add(new NumberField({ name: "totp_last_step", onlyInt: true, min: 0, hidden: true }));
15+ users.fields.add(new JSONField({ name: "recovery_codes", maxSize: 4000, hidden: true }));
16+ app.save(users);
17+ },
18+ (app) => {
19+ const users = app.findCollectionByNameOrId("users");
20+ for (const name of ["totp_secret", "totp_pending", "totp_enabled_at", "totp_last_step", "recovery_codes"]) users.fields.removeByName(name);
21+ app.save(users);
22+ },
23+ );