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";7import { keepState, setting } from "./db.ts";8import { DIGITS, stepFor } from "./totp.ts";910const SESSION_SECONDS = 30 * 86400;11const SESSION_COOKIE = "kepr_session";12const SIGN_IN_COOKIE = "kepr_sign_in";131415const EDITOR_COOKIE = "kepr_editor";16const COOKIE = { httpOnly: true, secure: true, sameSite: "Strict", path: "/" } as const;17const HEX64 = /^[0-9a-f]{64}$/;18const FAILURE_WINDOW_MS = 15 * 60 * 1000;19const FAILURES_PER_ADDRESS = 5;202122const failures: { at: number; address: string }[] = [];2324const addressKey = randomBytes(32);2526const nowSeconds = () => Math.floor(Date.now() / 1000);27const sha256 = (text: string) => createHash("sha256").update(text).digest("hex");2829function sameText(a: string, b: string): boolean {30 const [x, y] = [Buffer.from(a), Buffer.from(b)];31 return x.length === y.length && timingSafeEqual(x, y);32}333435function sessionToken(ctx: PageContext, c: Context): string | null {36 const token = getCookie(c, SESSION_COOKIE);37 if (!token || !HEX64.test(token)) return null;38 const row = ctx.kepr39 .query<{ n: number }, [string, number]>(40 "SELECT 1 AS n FROM sessions WHERE token_hash = ? AND expires_at > ?",41 )42 .get(sha256(token), nowSeconds());43 return row ? token : null;44}4546export function signedIn(ctx: PageContext, c: Context): boolean {47 return sessionToken(ctx, c) !== null;48}495051export function liveSessions(ctx: PageContext): number {52 return (53 ctx.kepr54 .query<{ n: number }, [number]>("SELECT COUNT(*) AS n FROM sessions WHERE expires_at > ?")55 .get(nowSeconds())?.n ?? 056 );57}585960export function endOtherSessions(ctx: PageContext, c: Context): void {61 const token = sessionToken(ctx, c) ?? "";62 ctx.kepr.query("DELETE FROM sessions WHERE token_hash != ?").run(sha256(token));63}646566676869function fromSite(c: Context, domain: string): boolean {70 const origin = c.req.header("origin");71 if (origin === `https://${domain}`) return true;72 return origin === `http://${domain}` && /^localhost(:\d+)?$/.test(domain);73}7475export async function adminPostRefusal(ctx: PageContext, c: Context): Promise<string> {76 if (!fromSite(c, ctx.domain)) return "Refused: wrong origin.";77 const form = (await c.req.parseBody()).form;78 return typeof form !== "string" || !sameText(form, formToken(c))79 ? "Refused: this form expired. Reload and try again."80 : "";81}828384858687export function formToken(c: Context): string {88 return formTokenOf(getCookie(c, SESSION_COOKIE) ?? "");89}9091function formTokenOf(session: string): string {92 return sha256(`form:${session}`);93}94959697function setEditorCookie(c: Context, session: string): void {98 setCookie(c, EDITOR_COOKIE, formTokenOf(session), {99 ...COOKIE,100 httpOnly: false,101 maxAge: SESSION_SECONDS,102 });103}104105106107108109export function adminRefusal(110 c: Context,111 ctx: PageContext,112 status: 401 | 403 | 429,113 text: string,114): Response {115 c.header("Cache-Control", "no-store");116 const body = html`<h1 class="file-page-title">${text}</h1>`;117 return c.html(page(ctx, { title: "admin", description: "", path: c.req.path, body }), status);118}119120121122123124export function registerLogin(125 app: Hono,126 ctx: PageContext,127 128 config: { adminPasswordHash: string; totpSecret?: string },129): void {130 const refuse = (c: Context, status: 401 | 403 | 429, text: string) =>131 adminRefusal(c, ctx, status, text);132 const asksCode = () => (config.totpSecret ?? "") !== "";133134 app.use("/admin/*", async (c, next) => {135 const token = sessionToken(ctx, c);136 if (!token) return refuse(c, 401, "Sign in first.");137 138 if (getCookie(c, EDITOR_COOKIE) !== formTokenOf(token)) setEditorCookie(c, token);139 if (c.req.method !== "GET") {140 const error = await adminPostRefusal(ctx, c);141 if (error) return refuse(c, 403, error);142 }143 await next();144 c.header("Cache-Control", "no-store");145 });146147 app.get("/sign-in", (c) =>148 signInPage(c, ctx, "", 200, nextPath(c.req.query("next")), asksCode()),149 );150151 app.post("/sign-in", async (c) => {152 const fields = await c.req.parseBody();153 const next = nextPath(fields.next);154 const cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";155 const form = typeof fields.form === "string" ? fields.form : "";156 if (!fromSite(c, ctx.domain) || !HEX64.test(cookie) || !sameText(form, cookie)) {157 return refuse(c, 403, "Refused: this form expired. Reload and try again.");158 }159 const now = Date.now();160 while (failures[0] && failures[0].at <= now - FAILURE_WINDOW_MS) failures.shift();161 const address = createHash("sha256").update(addressKey).update(clientIp(c)).digest("hex");162 const mine = failures.filter((failure) => failure.address === address).length;163 if (mine >= FAILURES_PER_ADDRESS) {164 c.header("Retry-After", String(FAILURE_WINDOW_MS / 1000));165 return signInPage(c, ctx, "Too many attempts. Try again later.", 429, next, asksCode());166 }167 const password = typeof fields.password === "string" ? fields.password : "";168 const passwordOk = await Bun.password.verify(password, config.adminPasswordHash);169 170 const wrong = asksCode()171 ? "That password and code did not match."172 : "That password is not right.";173 const refused = () => {174 failures.push({ at: now, address });175 return signInPage(c, ctx, wrong, 401, next, asksCode());176 };177 if (!passwordOk) return refused();178 if (asksCode()) {179 const code = typeof fields.code === "string" ? fields.code.trim() : "";180 const step = stepFor(config.totpSecret ?? "", code, nowSeconds());181 const used = Number(setting(ctx.kepr, "totp_step") ?? -1);182 if (step === null || step <= used) return refused();183 184 keepState(ctx.kepr, "totp_step", String(step));185 }186 const token = randomBytes(32).toString("hex");187 const at = nowSeconds();188 ctx.kepr.query("DELETE FROM sessions WHERE expires_at <= ?").run(at);189 ctx.kepr190 .query("INSERT INTO sessions (token_hash, expires_at) VALUES (?, ?)")191 .run(sha256(token), at + SESSION_SECONDS);192 setCookie(c, SESSION_COOKIE, token, { ...COOKIE, maxAge: SESSION_SECONDS });193 setEditorCookie(c, token);194 deleteCookie(c, SIGN_IN_COOKIE, COOKIE);195 return c.redirect(next, 303);196 });197198 app.post("/admin/sign-out", (c) => {199 ctx.kepr200 .query("DELETE FROM sessions WHERE token_hash = ?")201 .run(sha256(getCookie(c, SESSION_COOKIE) ?? ""));202 deleteCookie(c, SESSION_COOKIE, COOKIE);203 deleteCookie(c, EDITOR_COOKIE, { ...COOKIE, httpOnly: false });204 return c.redirect("/", 303);205 });206}207208209210211212function nextPath(value: unknown): string {213 if (typeof value !== "string" || !value.startsWith("/")) return "/admin";214 if (value.startsWith("//") || value.startsWith("/\\")) return "/admin";215 if ([...value].some((char) => char.charCodeAt(0) <= 31 || char.charCodeAt(0) === 127))216 return "/admin";217 return value;218}219220221function signInPage(222 c: Context,223 ctx: PageContext,224 error: string,225 status: 200 | 401 | 429,226 next: string,227 code: boolean,228) {229 let cookie = getCookie(c, SIGN_IN_COOKIE) ?? "";230 if (!HEX64.test(cookie)) {231 cookie = randomBytes(32).toString("hex");232 setCookie(c, SIGN_IN_COOKIE, cookie, COOKIE);233 }234 c.header("Cache-Control", "no-store");235 const body = html`<h1 class="file-page-title">sign in</h1>236${error && html`<p class="issues-error mono" role="alert">${error}</p>`}237<form class="issues-form" method="post" action="/sign-in">238<input type="hidden" name="form" value="${cookie}">239<input type="hidden" name="next" value="${next}">240<label class="issues-label" for="password">password</label>241<input class="issues-input" id="password" name="password" type="password" required autocomplete="current-password"${code ? "" : html` autofocus`}>242${243 code &&244 html`<label class="issues-label" for="code">code from your authenticator app</label>245<input class="issues-input mono" id="code" name="code" inputmode="numeric" autocomplete="one-time-code"246pattern="[0-9]{${DIGITS}}" required autofocus>`247}248<button class="issues-submit" type="submit">sign in</button>249</form>`;250 return c.html(page(ctx, { title: "sign in", description: "", path: "/sign-in", body }), status);251}