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";111213const 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;181920const failures: { at: number; address: string }[] = [];2122const 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}313233function 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}474849export 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}565758export 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}626364656667function 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}808182838485export function formToken(c: Context): string {86 return sha256(`form:${getCookie(c, SESSION_COOKIE) ?? ""}`);87}888990919293export function adminRefusal(94 c: Context,95 ctx: PageContext,96 status: 401 | 403 | 429,97 text: string,98): Response {99 c.header("Cache-Control", "no-store");100 const body = html`<h1 class="file-page-title">${text}</h1>`;101 return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);102}103104105106107108export function registerLogin(109 app: Hono,110 ctx: PageContext,111 112 config: { adminPasswordHash: string },113): void {114 const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>115 adminRefusal(c, ctx, status, text);116117 app.use("/admin/*", async (c, next) => {118 const token = sessionToken(ctx, c);119 if (!token) return refuse(c, 401, "Sign in first.");120 if (c.req.method !== "GET") {121 const error = await adminPostRefusal(ctx, c);122 if (error) return refuse(c, 403, error);123 }124 await next();125 c.header("Cache-Control", "no-store");126 });127128 app.get("/sign-in", (c) => signInPage(c, ctx, "", 200, nextPath(c.req.query("next"))));129130 app.post("/sign-in", async (c) => {131 const fields = await c.req.parseBody();132 const next = nextPath(fields.next);133 const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";134 const form = typeof fields.form === "string" ? fields.form : "";135 if (!fromSite(c, ctx.domain) || !HEX64.test(cookie) || !sameText(form, cookie)) {136 return refuse(c, 403, "Refused: this form expired. Reload and try again.");137 }138 const now = Date.now();139 while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();140 const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");141 const mine = failures.filter((failure) => failure.address === address).length;142 if (mine >= FAILURES_PER_ADDRESS) {143 c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));144 return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next);145 }146 const password = typeof fields.password === "string" ? fields.password : "";147 if (!(await Bun.password.verify(password, config.adminPasswordHash))) {148 failures.push({ at: now, address });149 return signInPage(c, ctx, "That password is not right.", 401, next);150 }151 const token = randomBytes(32).toString("hex");152 const at = nowSeconds();153 ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);154 ctx.kepr155 .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")156 .run(sha256(token), at + SESSION_SECONDS);157 setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });158 setCookie(c, EDITOR_COOKIE, "1", { ...COOKIE, httpOnly: false, maxAge: SESSION_SECONDS });159 deleteCookie(c, SIGN_IN_COOKIE, COOKIE);160 return c.redirect(next, 303);161 });162163 app.post("/admin/sign-out", (c) => {164 ctx.kepr165 .query("DELETE FROM sessions WHERE token_hash = ?")166 .run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));167 deleteCookie(c, SESSION_COOKIE, COOKIE);168 deleteCookie(c, EDITOR_COOKIE, { ...COOKIE, httpOnly: false });169 return c.redirect("/", 303);170 });171}172173174175176177function nextPath(value: unknown): string {178 if (typeof value !== "string" || !value.startsWith("/")) return "/admin";179 if (value.startsWith("//") || value.startsWith("/\\")) return "/admin";180 if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127))181 return "/admin";182 return value;183}184185186function signInPage(187 c: Context,188 ctx: PageContext,189 error: string,190 status: 200 | 401 | 429,191 next: string,192) {193 let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";194 if (!HEX64.test(cookie)) {195 cookie = randomBytes(32).toString("hex");196 setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);197 }198 c.header("Cache-Control", "no-store");199 const body = html`<h1 class="file-page-title">sign in</h1>200${error && html`<p class="issues-error mono" role="alert">${error}</p>`}201<form class="issues-form" method="post" action="/sign-in">202<input type="hidden" name="form" value="${cookie}">203<input type="hidden" name="next" value="${next}">204<label class="issues-label" for="password">password</label>205<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password" autofocus>206<button class="issues-submit" type="submit">sign in</button>207</form>`;208 return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);209}