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;1516const FAILURES_ANYWHERE = 30;171819const failures: { at: number; address: string }[] = [];2021const addressKey = randomBytes(32);2223const nowSeconds = () => Math.floor(Date.now() / 1000);24const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");2526function sameText(a: string, b: string): boolean {27 const [x, y] = [Buffer.from(a), Buffer.from(b)];28 return x.length === y.length && timingSafeEqual(x, y);29}303132function sessionToken(ctx: PageContext, c: Context): string | null {33 const token = getCookie(c, SESSION_COOKIE);34 if (!token || !HEX64.test(token)) return null;35 const row = ctx.kepr36 .query<{ n: number }, [string, number]>(37 "SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?",38 )39 .get(sha256(token), nowSeconds());40 return row ? token : null;41}424344454647export function formToken(c: Context): string {48 return sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);49}505152535455export function registerLogin(app: Hono, ctx: PageContext, passwordHash: string): void {56 const origin = `https://${ctx.domain}`;57 const refuse = (c: Context, status: 401 | 403 | 429, text: string) => {58 c.header("Cache-Control", "no-store");59 const body = html`<h1 class="file-page-title">${text}</h1>`;60 return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);61 };6263 app.use("/admin/*", async (c, next) => {64 const token = sessionToken(ctx, c);65 if (!token) return refuse(c, 401, "Sign in first.");66 if (c.req.method !== "GET") {67 if (c.req.header("origin") !== origin) return refuse(c, 403, "Refused: wrong origin.");68 const form = (await c.req.parseBody()).form;69 if (typeof form !== "string" || !sameText(form, formToken(c))) {70 return refuse(c, 403, "Refused: this form expired. Reload and try again.");71 }72 }73 await next();74 c.header("Cache-Control", "no-store");75 });7677 app.get("/sign-in", (c) => signInPage(c, ctx, "", 200));7879 app.post("/sign-in", async (c) => {80 const fields = await c.req.parseBody();81 const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";82 const form = typeof fields.form === "string" ? fields.form : "";83 if (c.req.header("origin") !== origin || !HEX64.test(cookie) || !sameText(form, cookie)) {84 return refuse(c, 403, "Refused: this form expired. Reload and try again.");85 }86 const now = Date.now();87 while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();88 const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");89 const mine = failures.filter((failure) => failure.address === address).length;90 if (failures.length >= FAILURES_ANYWHERE || mine >= FAILURES_PER_ADDRESS) {91 c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));92 return signInPage(c, ctx, "Too many attempts. Try again later.", 429);93 }94 const password = typeof fields.password === "string" ? fields.password : "";95 if (!(await Bun.password.verify(password, passwordHash))) {96 failures.push({ at: now, address });97 return signInPage(c, ctx, "That password is not right.", 401);98 }99 const token = randomBytes(32).toString("hex");100 const at = nowSeconds();101 ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);102 ctx.kepr103 .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")104 .run(sha256(token), at + SESSION_SECONDS);105 setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });106 deleteCookie(c, SIGN_IN_COOKIE, COOKIE);107 return c.redirect("/admin", 303);108 });109110 app.post("/admin/sign-out", (c) => {111 ctx.kepr112 .query("DELETE FROM sessions WHERE token_hash = ?")113 .run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));114 deleteCookie(c, SESSION_COOKIE, COOKIE);115 return c.redirect("/", 303);116 });117}118119120function signInPage(c: Context, ctx: PageContext, error: string, status: 200 | 401 | 429) {121 let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";122 if (!HEX64.test(cookie)) {123 cookie = randomBytes(32).toString("hex");124 setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);125 }126 c.header("Cache-Control", "no-store");127 const body = html`<h1 class="file-page-title">sign in</h1>128${error && html`<p class="issues-error mono" role="alert">${error}</p>`}129<form class="issues-form" method="post" action="/sign-in">130<input type="hidden" name="form" value="${cookie}">131<label class="issues-label" for="password">password</label>132<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>133<button class="issues-submit" type="submit">sign in</button>134</form>`;135 return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);136}