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>
Showing 20 changed files with 777 additions and 14 deletions.
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { redirect } from "next/navigation"; |
| 4 | - import { endSession, safeNext, signIn } from "@/lib/auth"; | |
| 4 | + import { completeSignIn, endSession, safeNext, signIn } from "@/lib/auth"; | |
| 5 | 5 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| 6 | 6 | import { createUser, UserError } from "@/lib/users"; |
| 7 | 7 | import type { FormState } from "./types"; |
| @@ -25,9 +25,20 @@ export async function signupAction(_prev: FormState, formData: FormData): Promis | ||
| 25 | 25 | } |
| 26 | 26 | |
| 27 | 27 | 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); | |
| 31 | 42 | } |
| 32 | 43 | |
| 33 | 44 | export async function logoutAction() { |
| @@ -1,9 +1,17 @@ | ||
| 1 | 1 | "use server"; |
| 2 | 2 | |
| 3 | 3 | import { revalidatePath } from "next/cache"; |
| 4 | - import { renewSession, signIn } from "@/lib/auth"; | |
| 4 | + import { renewSession } from "@/lib/auth"; | |
| 5 | 5 | import { addSshKey, deleteSshKey, KeyError } from "@/lib/keys"; |
| 6 | 6 | 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"; | |
| 7 | 15 | import { setPassword, updateProfile, UserError, validatePassword, verifyUserPassword } from "@/lib/users"; |
| 8 | 16 | import { grantableRepoIds, viewerOrThrow } from "./guard"; |
| 9 | 17 | import type { FormState } from "./types"; |
| @@ -36,10 +44,55 @@ export async function changePasswordAction(_prev: FormState, formData: FormData) | ||
| 36 | 44 | if (problem) return { error: problem }; |
| 37 | 45 | // This signs the user out everywhere, so sign this browser back in. |
| 38 | 46 | await setPassword(viewer.id, next); |
| 39 | - await signIn(viewer.email, next); | |
| 47 | + await renewSession(viewer.id); | |
| 40 | 48 | return { success: "Password changed." }; |
| 41 | 49 | } |
| 42 | 50 | |
| 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 | + | |
| 43 | 96 | export async function addSshKeyAction(_prev: FormState, formData: FormData): Promise<FormState> { |
| 44 | 97 | const viewer = await viewerOrThrow(); |
| 45 | 98 | try { |
| @@ -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; | |
| @@ -4,14 +4,15 @@ import { redirect } from "next/navigation"; | ||
| 4 | 4 | import { Link } from "@unlocalhosted/metalui"; |
| 5 | 5 | import { LoginForm } from "@/components/auth-forms"; |
| 6 | 6 | import { BRAND, LogoMark } from "@/components/brand"; |
| 7 | - import { Box } from "@/components/ui"; | |
| 7 | + import { Alert, Box } from "@/components/ui"; | |
| 8 | 8 | import { getViewer, safeNext } from "@/lib/auth"; |
| 9 | 9 | import { SIGNUPS_ENABLED } from "@/lib/signups"; |
| 10 | 10 | |
| 11 | 11 | export const metadata: Metadata = { title: "Sign in" }; |
| 12 | 12 | |
| 13 | 13 | 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); | |
| 15 | 16 | if (await getViewer()) redirect(next); |
| 16 | 17 | |
| 17 | 18 | return ( |
| @@ -26,6 +27,11 @@ export default async function LoginPage({ searchParams }: PageProps<"/login">) { | ||
| 26 | 27 | <h1 className="mt-6 text-[22px] leading-7 font-semibold tracking-[-0.025em] text-ink">Welcome back</h1> |
| 27 | 28 | <p className="mt-1 type-lead text-ink2">Sign in to continue to {BRAND.name}</p> |
| 28 | 29 | </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 | + )} | |
| 29 | 35 | <Box className="mt-8 p-6"> |
| 30 | 36 | <LoginForm next={next} /> |
| 31 | 37 | </Box> |
| @@ -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 | + } | |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | 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"; | |
| 3 | 3 | import { Avatar } from "@/components/ui"; |
| 4 | 4 | import { requireViewer } from "@/lib/auth"; |
| 5 | 5 | |
| @@ -19,6 +19,7 @@ export default async function SettingsLayout({ children }: LayoutProps<"/setting | ||
| 19 | 19 | title="Account" |
| 20 | 20 | items={[ |
| 21 | 21 | { href: "/settings/profile", label: "Public profile", icon: <PersonIcon /> }, |
| 22 | + { href: "/settings/security", label: "Two-factor auth", icon: <LockIcon /> }, | |
| 22 | 23 | { href: "/settings/keys", label: "SSH keys", icon: <KeyIcon /> }, |
| 23 | 24 | { href: "/settings/tokens", label: "API tokens", icon: <ShieldLockIcon /> }, |
| 24 | 25 | { href: "/settings/runners", label: "Runners", icon: <ServerIcon /> }, |
| @@ -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 | + } | |
| @@ -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 | + } | |
| @@ -23,6 +23,7 @@ | ||
| 23 | 23 | "server-only": "^0.0.1", |
| 24 | 24 | "shiki": "^4.4.3", |
| 25 | 25 | "ssh2": "^1.17.0", |
| 26 | + "uqr": "^0.1.3", | |
| 26 | 27 | "yaml": "^2.9.1", |
| 27 | 28 | }, |
| 28 | 29 | "devDependencies": { |
| @@ -1248,6 +1249,8 @@ | ||
| 1248 | 1249 | |
| 1249 | 1250 | "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=="], |
| 1250 | 1251 | |
| 1252 | + "uqr": ["uqr@0.1.3", "", {}, "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA=="], | |
| 1253 | + | |
| 1251 | 1254 | "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], |
| 1252 | 1255 | |
| 1253 | 1256 | "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=="], |
| @@ -3,7 +3,7 @@ | ||
| 3 | 3 | import NextLink from "next/link"; |
| 4 | 4 | import { useActionState } from "react"; |
| 5 | 5 | import { Link } from "@unlocalhosted/metalui"; |
| 6 | - import { loginAction, signupAction } from "@/app/actions/auth"; | |
| 6 | + import { loginAction, signupAction, twoFactorLoginAction } from "@/app/actions/auth"; | |
| 7 | 7 | import { TextField } from "./settings-fields"; |
| 8 | 8 | import { Alert, Button } from "./ui"; |
| 9 | 9 | |
| @@ -22,6 +22,31 @@ export function LoginForm({ next }: { next: string }) { | ||
| 22 | 22 | ); |
| 23 | 23 | } |
| 24 | 24 | |
| 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 | + | |
| 25 | 50 | export function SignupForm({ next }: { next: string }) { |
| 26 | 51 | const [state, action, pending] = useActionState(signupAction, undefined); |
| 27 | 52 | return ( |
| @@ -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,6 +1,7 @@ | ||
| 1 | 1 | # Features |
| 2 | 2 | |
| 3 | 3 | - **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 | |
| 4 | 5 | - **Repositories**: public or private, rename, delete, optional README on creation |
| 5 | 6 | - **Collaborators**: give other users push access to a repository |
| 6 | 7 | - **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,7 +1,7 @@ | ||
| 1 | 1 | # Code layout |
| 2 | 2 | |
| 3 | 3 | - `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 | |
| 5 | 5 | - `lib/repos.ts`: repository records, permissions (`repoAccess`), collaborators |
| 6 | 6 | - `lib/issues.ts`, `lib/issue-meta.ts`: issue tracker data and its shared vocabulary (statuses, priorities); UI in `components/issues/` |
| 7 | 7 | - `lib/keys.ts`, `lib/tokens.ts`: SSH keys and API tokens |
| @@ -8,3 +8,4 @@ | ||
| 8 | 8 | - 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. |
| 9 | 9 | - 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. |
| 10 | 10 | - 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. | |
| @@ -1,10 +1,12 @@ | ||
| 1 | 1 | import "server-only"; |
| 2 | 2 | import { cache } from "react"; |
| 3 | + import { createHash, randomBytes } from "node:crypto"; | |
| 3 | 4 | import { cookies } from "next/headers"; |
| 4 | 5 | import { redirect } from "next/navigation"; |
| 5 | 6 | import { ClientResponseError, getTokenPayload, isTokenExpired } from "pocketbase"; |
| 6 | 7 | import { client, db } from "./db"; |
| 7 | 8 | import { SECURE_COOKIES } from "./request-origin"; |
| 9 | + import { hasTwoFactor, verifySecondFactor } from "./two-factor"; | |
| 8 | 10 | import { authenticateUser, toUser } from "./users"; |
| 9 | 11 | |
| 10 | 12 | // 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) { | ||
| 23 | 25 | }); |
| 24 | 26 | } |
| 25 | 27 | |
| 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 | + */ | |
| 27 | 32 | export async function signIn(login: string, password: string) { |
| 28 | 33 | const auth = await authenticateUser(login, password); |
| 29 | 34 | 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 | + } | |
| 30 | 39 | 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" }); | |
| 32 | 127 | } |
| 33 | 128 | |
| 34 | 129 | /** A new session for a signed-in user whose old token stopped working (after an email change). */ |
| @@ -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 | + }); | |
| @@ -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"); | |
| @@ -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 | + } | |
| @@ -30,6 +30,7 @@ | ||
| 30 | 30 | "server-only": "^0.0.1", |
| 31 | 31 | "shiki": "^4.4.3", |
| 32 | 32 | "ssh2": "^1.17.0", |
| 33 | + "uqr": "^0.1.3", | |
| 33 | 34 | "yaml": "^2.9.1" |
| 34 | 35 | }, |
| 35 | 36 | "devDependencies": { |
| @@ -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 | + ); | |