account.ts

7.3 KB · raw

1import type { Context, Hono } from "hono";2import { html } from "../pages/html.ts";3import { plural } from "../pages/layout.ts";4import { isDomain, saveConfig } from "../server/config.ts";5import { keepState } from "../server/db.ts";6import { endOtherSessions, liveSessions } from "../server/login.ts";7import { isSecret, newSecret, otpauthUri, stepFor } from "../server/totp.ts";8import { type AdminContext, adminPage, formField, notice, textFields } from "./console.ts";910export const MIN_PASSWORD = 12;1112/**13 * The Account section (June, 2026-09-30): the admin password, the sessions signed in, and the14 * domain, each written to kepr.toml by Kepr and applied without a restart.15 */16export function registerAccount(app: Hono, ctx: AdminContext): void {17	app.get("/admin/account", (c) => c.html(accountPage(c, ctx, "")));1819	app.post("/admin/account", async (c) => {20		const fields = await textFields(c);21		const refuse = (error: string) => c.html(accountPage(c, ctx, error), 400);22		const save = async (change: Partial<typeof ctx.config>) => {23			await saveConfig(ctx.configPath, { ...ctx.config, ...change });24			Object.assign(ctx.config, change);25		};26		if (fields.action === "password") {27			const password = fields.password ?? "";28			const current = fields.current_password ?? "";29			if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {30				return refuse("The current password is not right.");31			}32			if (password.length < MIN_PASSWORD) {33				return refuse(`The new password needs at least ${MIN_PASSWORD} characters.`);34			}35			if (password !== fields.confirm) return refuse("The two new passwords differ.");36			await save({ adminPasswordHash: await Bun.password.hash(password, "argon2id") });37			// Whoever knew the old password is signed out with it.38			endOtherSessions(ctx, c);39		} else if (fields.action === "totp-start") {40			return c.html(accountPage(c, ctx, "", newSecret()));41		} else if (fields.action === "totp-confirm") {42			const secret = fields.secret ?? "";43			const step = isSecret(secret)44				? stepFor(secret, (fields.code ?? "").trim(), Math.floor(Date.now() / 1000))45				: null;46			if (step === null) {47				return c.html(accountPage(c, ctx, "That code did not match. Set it up again.", ""), 400);48			}49			// The code that set it up is spent, so the same one cannot also sign in (§24).50			keepState(ctx.kepr, "totp_step", String(step));51			await save({ totpSecret: secret });52		} else if (fields.action === "totp-off") {53			const current = fields.current_password ?? "";54			if (!(await Bun.password.verify(current, ctx.config.adminPasswordHash))) {55				return refuse("The current password is not right.");56			}57			keepState(ctx.kepr, "totp_step", null);58			await save({ totpSecret: undefined });59		} else if (fields.action === "sessions") {60			endOtherSessions(ctx, c);61		} else if (fields.action === "domain") {62			const domain = (fields.domain ?? "").trim().toLowerCase();63			if (!isDomain(domain)) {64				return refuse(65					"The domain is a bare hostname, such as code.example.com, with an optional :port.",66				);67			}68			await save({ domain });69		} else {70			return refuse("Unknown action.");71		}72		return c.redirect("/admin/account?saved=1", 303);73	});74}7576function accountPage(c: Context, ctx: AdminContext, error: string, offered = ""): string {77	const password = (name: string, label: string, autocomplete: string) =>78		html`<label class="issues-label" for="account-${name}">${label}</label>79<input class="issues-input" id="account-${name}" name="${name}" type="password"80autocomplete="${autocomplete}" required>`;81	// Setting up keeps the offered secret in the form: the code that confirms it proves whoever82	// typed it holds it, so a tampered field can only fail.83	const twoStep =84		offered !== ""85			? html`<p>Add this key to an authenticator app by hand, or open the address below on a phone86that has one, then type the code the app shows. The key is shown this once; from then on sign-in87asks for the password and a code.</p>88<p class="issue-meta mono">key: ${offered}89<button type="button" class="copy-btn" data-copy="${offered}" hidden>copy key</button></p>90<p class="issue-meta mono"><a href="${otpauthUri(offered, ctx.domain)}">open in an authenticator app</a>91<button type="button" class="copy-btn" data-copy="${otpauthUri(offered, ctx.domain)}" hidden>copy the92setup address</button></p>93<form class="issues-form" method="post" action="/admin/account">94${formField(c)}<input type="hidden" name="action" value="totp-confirm">95<input type="hidden" name="secret" value="${offered}">96<label class="issues-label" for="account-code">code from the app</label>97<input class="issues-input mono" id="account-code" name="code" inputmode="numeric"98autocomplete="one-time-code" required autofocus>99<button class="issues-submit" type="submit">turn two-step sign-in on</button>100</form>`101			: ctx.config.totpSecret === undefined102				? html`<p>Off. Sign-in asks for the password alone.</p>103<form method="post" action="/admin/account">104${formField(c)}<input type="hidden" name="action" value="totp-start">105<button class="issues-submit" type="submit">set up two-step sign-in</button>106</form>`107				: html`<p>On. Sign-in asks for the password and the code from your authenticator app.</p>108<form class="issues-form" method="post" action="/admin/account">109${formField(c)}<input type="hidden" name="action" value="totp-off">110${password("current_password", "password, to turn it off", "current-password")}111<p class="issue-meta mono">The password is also the way back in when the phone is lost.</p>112<button class="issues-submit" type="submit">turn it off</button>113</form>`;114	const body = html`${notice(error)}115<section class="repo-section"><h2 class="section-label mono">password</h2>116<form class="issues-form" method="post" action="/admin/account">117${formField(c)}<input type="hidden" name="action" value="password">118${password("current_password", "current password", "current-password")}119${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")}120${password("confirm", "new password again", "new-password")}121<p class="issue-meta mono">Changing it signs out every other session.</p>122<button class="issues-submit" type="submit">change password</button>123</form></section>124<section class="repo-section"><h2 class="section-label mono">two-step sign-in</h2>125${twoStep}</section>126<section class="repo-section"><h2 class="section-label mono">sessions</h2>127<p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>128<form method="post" action="/admin/account">129${formField(c)}<input type="hidden" name="action" value="sessions">130<button class="issues-submit" type="submit">sign out everywhere else</button>131</form></section>132<section class="repo-section"><h2 class="section-label mono">domain</h2>133<form class="issues-form" method="post" action="/admin/account">134${formField(c)}<input type="hidden" name="action" value="domain">135<label class="issues-label" for="account-domain">domain</label>136<input class="issues-input mono" id="account-domain" name="domain" value="${ctx.domain}"137autocomplete="off" spellcheck="false" required>138<p class="issue-meta mono">Where Kepr is reached: pages, feeds and install.sh use it, and the console139accepts forms only from it. Point the new name at this server first, then save and sign in there.</p>140<button class="issues-submit" type="submit">save domain</button>141</form></section>`;142	return adminPage(c, ctx, "account", body);143}