login.ts

8.9 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";11// Readable by kepr.js, and saying only that someone signed in here: public pages stay the same12// cacheable HTML for everyone, and the script asks the admin routes for the editing controls.13const EDITOR_COOKIE = "kepr_editor";14const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const;15const HEX64 = /^[0-9a-f]{64}$/;16const FAILURE_WINDOW_MS = 15 * 60 * 1000;17const FAILURES_PER_ADDRESS = 5;1819// Failed sign-ins, oldest first, in memory: a restart forgets them, but an attacker can't cause one.20const failures: { at: number; address: string }[] = [];21// Addresses are keyed hashes even in memory, as issue filings store them (§23).22const addressKey = randomBytes(32);2324const nowSeconds = () => Math.floor(Date.now() / 1000);25const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");2627function sameText(a: string, b: string): boolean {28	const [x, y] = [Buffer.from(a), Buffer.from(b)];29	return x.length === y.length && timingSafeEqual(x, y);30}3132/** The session cookie's token when it names a live session in kepr.db, else null. */33function sessionToken(ctx: PageContext, c: Context): string | null {34	const token = getCookie(c, SESSION_COOKIE);35	if (!token || !HEX64.test(token)) return null;36	const row = ctx.kepr37		.query<{ n: number }, [string, number]>(38			"SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?",39		)40		.get(sha256(token), nowSeconds());41	return row ? token : null;42}4344export function signedIn(ctx: PageContext, c: Context): boolean {45	return sessionToken(ctx, c) !== null;46}4748/** How many sessions are live, this one included. */49export function liveSessions(ctx: PageContext): number {50	return (51		ctx.kepr52			.query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM sessions WHERE expires_at > ?")53			.get(nowSeconds())?.n ?? 054	);55}5657/** Revokes every session but the one making this request. */58export function endOtherSessions(ctx: PageContext, c: Context): void {59	const token = sessionToken(ctx, c) ?? "";60	ctx.kepr.query("DELETE FROM sessions WHERE token_hash != ?").run(sha256(token));61}6263/**64 * Whether a request came from this site. A localhost domain also accepts plain http, since a65 * local run has no TLS in front of it; browsers keep Secure cookies on http://localhost.66 */67function fromSite(c: Context, domain: string): boolean {68	const origin = c.req.header("origin");69	if (origin === `https://${domain}`) return true;70	return origin === `http://${domain}` && /^localhost(:\d+)?$/.test(domain);71}7273export async function adminPostRefusal(ctx: PageContext, c: Context): Promise<string> {74	if (!fromSite(c, ctx.domain)) return "Refused: wrong origin.";75	const form = (await c.req.parseBody()).form;76	return typeof form !== "string" || !sameText(form, formToken(c))77		? "Refused: this form expired. Reload and try again."78		: "";79}8081/**82 * The same-site token every admin form carries (§25): derived from the session's secret token, so83 * a page on another site can neither read nor guess it, and it dies with the session.84 */85export function formToken(c: Context): string {86	return formTokenOf(getCookie(c, SESSION_COOKIE) ?? "");87}8889function formTokenOf(session: string): string {90	return sha256(`form:${session}`);91}9293// The readable mark beside the session: its form token, so the navbar can offer log out without94// asking the server (nav.js). Readable by the site's own scripts only, as any page's form is.95function setEditorCookie(c: Context, session: string): void {96	setCookie(c, EDITOR_COOKIE, formTokenOf(session), {97		...COOKIE,98		httpOnly: false,99		maxAge: SESSION_SECONDS,100	});101}102103/**104 * The refusal page behind every admin entry point, `/connect` included: one answer for a missing105 * session, a foreign origin, an expired form token or too many attempts, wherever it arrives.106 */107export function adminRefusal(108	c: Context,109	ctx: PageContext,110	status: 401 | 403 | 429,111	text: string,112): Response {113	c.header("Cache-Control", "no-store");114	const body = html`<h1 class="file-page-title">${text}</h1>`;115	return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);116}117118/**119 * §24's login at `/sign-in`, sessions, sign-out, and the guard in front of every120 * `/admin` route: a live session, and for anything but GET a matching `Origin` and form token.121 */122export function registerLogin(123	app: Hono,124	ctx: PageContext,125	// The live config, so a password changed in the console works at once.126	config: { adminPasswordHash: string },127): void {128	const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>129		adminRefusal(c, ctx, status, text);130131	app.use("/admin/*", async (c, next) => {132		const token = sessionToken(ctx, c);133		if (!token) return refuse(c, 401, "Sign in first.");134		// A mark set before it carried the token is brought up to date.135		if (getCookie(c, EDITOR_COOKIE) !== formTokenOf(token)) setEditorCookie(c, token);136		if (c.req.method !== "GET") {137			const error = await adminPostRefusal(ctx, c);138			if (error) return refuse(c, 403, error);139		}140		await next();141		c.header("Cache-Control", "no-store");142	});143144	app.get("/sign-in", (c) => signInPage(c, ctx, "", 200, nextPath(c.req.query("next"))));145146	app.post("/sign-in", async (c) => {147		const fields = await c.req.parseBody();148		const next = nextPath(fields.next);149		const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";150		const form = typeof fields.form === "string" ? fields.form : "";151		if (!fromSite(c, ctx.domain) || !HEX64.test(cookie) || !sameText(form, cookie)) {152			return refuse(c, 403, "Refused: this form expired. Reload and try again.");153		}154		const now = Date.now();155		while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();156		const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");157		const mine = failures.filter((failure) => failure.address === address).length;158		if (mine >= FAILURES_PER_ADDRESS) {159			c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));160			return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next);161		}162		const password = typeof fields.password === "string" ? fields.password : "";163		if (!(await Bun.password.verify(password, config.adminPasswordHash))) {164			failures.push({ at: now, address });165			return signInPage(c, ctx, "That password is not right.", 401, next);166		}167		const token = randomBytes(32).toString("hex");168		const at = nowSeconds();169		ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);170		ctx.kepr171			.query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")172			.run(sha256(token), at + SESSION_SECONDS);173		setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });174		setEditorCookie(c, token);175		deleteCookie(c, SIGN_IN_COOKIE, COOKIE);176		return c.redirect(next, 303);177	});178179	app.post("/admin/sign-out", (c) => {180		ctx.kepr181			.query("DELETE FROM sessions WHERE token_hash = ?")182			.run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));183		deleteCookie(c, SESSION_COOKIE, COOKIE);184		deleteCookie(c, EDITOR_COOKIE, { ...COOKIE, httpOnly: false });185		return c.redirect("/", 303);186	});187}188189/**190 * Where a sign-in goes on to: a path on this site, or `/admin`. `//host` and `/\host` are other191 * sites to a browser, and a control character could split the Location header.192 */193function nextPath(value: unknown): string {194	if (typeof value !== "string" || !value.startsWith("/")) return "/admin";195	if (value.startsWith("//") || value.startsWith("/\\")) return "/admin";196	if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127))197		return "/admin";198	return value;199}200201// A double-submit token repeats a SameSite=Strict cookie that a cross-site post never has.202function signInPage(203	c: Context,204	ctx: PageContext,205	error: string,206	status: 200 | 401 | 429,207	next: string,208) {209	let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";210	if (!HEX64.test(cookie)) {211		cookie = randomBytes(32).toString("hex");212		setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);213	}214	c.header("Cache-Control", "no-store");215	const body = html`<h1 class="file-page-title">sign in</h1>216${error && html`<p class="issues-error mono" role="alert">${error}</p>`}217<form class="issues-form" method="post" action="/sign-in">218<input type="hidden" name="form" value="${cookie}">219<input type="hidden" name="next" value="${next}">220<label class="issues-label" for="password">password</label>221<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>222<button class="issues-submit" type="submit">sign in</button>223</form>`;224	return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);225}