login.ts

7.1 KB · raw

1import { createHash, randomBytes, timingSafeEqual } from "node:crypto";2import type { Context, Hono } from "hono";3import { deleteCookie, getCookie, setCookie } from "hono/cookie";4import { html } from "../pages/html.ts";5import { type PageContext, page } from "../pages/layout.ts";6import { clientIp } from "./client.ts";78const SESSION_SECONDS = 30 * 86400;9const SESSION_COOKIE = "kepr_session";10const SIGN_IN_COOKIE = "kepr_sign_in";11const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const;12const HEX64 = /^[0-9a-f]{64}$/;13const FAILURE_WINDOW_MS = 15 * 60 * 1000;14const FAILURES_PER_ADDRESS = 5;1516// Failed sign-ins, oldest first, in memory: a restart forgets them, but an attacker can't cause one.17const failures: { at: number; address: string }[] = [];18// Addresses are keyed hashes even in memory, as issue filings store them (§23).19const addressKey = randomBytes(32);2021const nowSeconds = () => Math.floor(Date.now() / 1000);22const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");2324function sameText(a: string, b: string): boolean {25	const [x, y] = [Buffer.from(a), Buffer.from(b)];26	return x.length === y.length && timingSafeEqual(x, y);27}2829/** The session cookie's token when it names a live session in kepr.db, else null. */30function sessionToken(ctx: PageContext, c: Context): string | null {31	const token = getCookie(c, SESSION_COOKIE);32	if (!token || !HEX64.test(token)) return null;33	const row = ctx.kepr34		.query<{ n: number }, [string, number]>(35			"SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?",36		)37		.get(sha256(token), nowSeconds());38	return row ? token : null;39}4041export function signedIn(ctx: PageContext, c: Context): boolean {42	return sessionToken(ctx, c) !== null;43}4445export async function adminPostRefusal(ctx: PageContext, c: Context): Promise<string> {46	if (c.req.header("origin") !== `https://${ctx.domain}`) return "Refused: wrong origin.";47	const form = (await c.req.parseBody()).form;48	return typeof form !== "string" || !sameText(form, formToken(c))49		? "Refused: this form expired. Reload and try again."50		: "";51}5253/**54 * The same-site token every admin form carries (§25): derived from the session's secret token, so55 * a page on another site can neither read nor guess it, and it dies with the session.56 */57export function formToken(c: Context): string {58	return sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);59}6061/**62 * The refusal page behind every admin entry point, `/connect` included: one answer for a missing63 * session, a foreign origin, an expired form token or too many attempts, wherever it arrives.64 */65export function adminRefusal(66	c: Context,67	ctx: PageContext,68	status: 401 | 403 | 429,69	text: string,70): Response {71	c.header("Cache-Control", "no-store");72	const body = html`<h1 class="file-page-title">${text}</h1>`;73	return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);74}7576/**77 * §24's login at the unlinked `/sign-in`, sessions, sign-out, and the guard in front of every78 * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.79 */80export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {81	const origin = `https://${ctx.domain}`;82	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>83		adminRefusal(c, ctx, status, text);8485	app.use("/admin/*", async (c, next) => {86		const token = sessionToken(ctx, c);87		if (!token) return refuse(c, 401, "Sign in first.");88		if (c.req.method !== "GET") {89			const error = await adminPostRefusal(ctx, c);90			if (error) return refuse(c, 403, error);91		}92		await next();93		c.header("Cache-Control", "no-store");94	});9596	app.get("/sign-in", (c) =>97		signInPage(c, ctx, "", 200, validNext(c.req.query("next") ?? "") ?? "/admin"),98	);99100	app.post("/sign-in", async (c) => {101		const fields = await c.req.parseBody();102		const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";103		const form = typeof fields.form === "string" ? fields.form : "";104		if (c.req.header("origin") !== origin || !HEX64.test(cookie) || !sameText(form, cookie)) {105			return refuse(c, 403, "Refused: this form expired. Reload and try again.");106		}107		const now = Date.now();108		while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();109		const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");110		const mine = failures.filter((failure) => failure.address === address).length;111		if (mine >= FAILURES_PER_ADDRESS) {112			c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));113			return signInPage(114				c,115				ctx,116				"Too many attempts. Try again later.",117				429,118				validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",119			);120		}121		const password = typeof fields.password === "string" ? fields.password : "";122		if (!(await Bun.password.verify(password, passwordHash))) {123			failures.push({ at: now, address });124			return signInPage(125				c,126				ctx,127				"That password is not right.",128				401,129				validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",130			);131		}132		const token = randomBytes(32).toString("hex");133		const at = nowSeconds();134		ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);135		ctx.kepr136			.query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")137			.run(sha256(token), at + SESSION_SECONDS);138		setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });139		deleteCookie(c, SIGN_IN_COOKIE, COOKIE);140		return c.redirect(141			validNext(typeof fields.next === "string" ? fields.next : "") ?? "/admin",142			303,143		);144	});145146	app.post("/admin/sign-out", (c) => {147		ctx.kepr148			.query("DELETE FROM sessions WHERE token_hash = ?")149			.run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));150		deleteCookie(c, SESSION_COOKIE, COOKIE);151		return c.redirect("/", 303);152	});153}154155function validNext(value: string): string | null {156	if (!value.startsWith("/") || value.startsWith("//") || value.startsWith("/\\")) return null;157	if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127))158		return null;159	return value;160}161// A double-submit token repeats a SameSite=Strict cookie that a cross-site post never has.162function signInPage(163	c: Context,164	ctx: PageContext,165	error: string,166	status: 200 | 401 | 429,167	next = "/admin",168) {169	let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";170	if (!HEX64.test(cookie)) {171		cookie = randomBytes(32).toString("hex");172		setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);173	}174	c.header("Cache-Control", "no-store");175	const body = html`<h1 class="file-page-title">sign in</h1>176${error && html`<p class="issues-error mono" role="alert">${error}</p>`}177<form class="issues-form" method="post" action="/sign-in">178<input type="hidden" name="form" value="${cookie}">179<input type="hidden" name="next" value="${next}">180<label class="issues-label" for="password">password</label>181<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>182<button class="issues-submit" type="submit">sign in</button>183</form>`;184	return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);185}