account.ts

7.0 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 paste the address under it, then86type the code the app shows. The key is shown this once; from then on sign-in asks for the password87and a code.</p>88<p class="issue-meta mono">key: ${offered}</p>89<p class="issue-meta mono">${otpauthUri(offered, ctx.domain)}</p>90<form class="issues-form" method="post" action="/admin/account">91${formField(c)}<input type="hidden" name="action" value="totp-confirm">92<input type="hidden" name="secret" value="${offered}">93<label class="issues-label" for="account-code">code from the app</label>94<input class="issues-input mono" id="account-code" name="code" inputmode="numeric"95autocomplete="one-time-code" required autofocus>96<button class="issues-submit" type="submit">turn two-step sign-in on</button>97</form>`98			: ctx.config.totpSecret === undefined99				? html`<p>Off. Sign-in asks for the password alone.</p>100<form method="post" action="/admin/account">101${formField(c)}<input type="hidden" name="action" value="totp-start">102<button class="issues-submit" type="submit">set up two-step sign-in</button>103</form>`104				: html`<p>On. Sign-in asks for the password and the code from your authenticator app.</p>105<form class="issues-form" method="post" action="/admin/account">106${formField(c)}<input type="hidden" name="action" value="totp-off">107${password("current_password", "password, to turn it off", "current-password")}108<p class="issue-meta mono">The password is also the way back in when the phone is lost.</p>109<button class="issues-submit" type="submit">turn it off</button>110</form>`;111	const body = html`${notice(error)}112<section class="repo-section"><h2 class="section-label mono">password</h2>113<form class="issues-form" method="post" action="/admin/account">114${formField(c)}<input type="hidden" name="action" value="password">115${password("current_password", "current password", "current-password")}116${password("password", `new password (at least ${MIN_PASSWORD} characters)`, "new-password")}117${password("confirm", "new password again", "new-password")}118<p class="issue-meta mono">Changing it signs out every other session.</p>119<button class="issues-submit" type="submit">change password</button>120</form></section>121<section class="repo-section"><h2 class="section-label mono">two-step sign-in</h2>122${twoStep}</section>123<section class="repo-section"><h2 class="section-label mono">sessions</h2>124<p>${plural(liveSessions(ctx), "session")} signed in, this one included. A session lasts 30 days.</p>125<form method="post" action="/admin/account">126${formField(c)}<input type="hidden" name="action" value="sessions">127<button class="issues-submit" type="submit">sign out everywhere else</button>128</form></section>129<section class="repo-section"><h2 class="section-label mono">domain</h2>130<form class="issues-form" method="post" action="/admin/account">131${formField(c)}<input type="hidden" name="action" value="domain">132<label class="issues-label" for="account-domain">domain</label>133<input class="issues-input mono" id="account-domain" name="domain" value="${ctx.domain}"134autocomplete="off" spellcheck="false" required>135<p class="issue-meta mono">Where Kepr is reached: pages, feeds and install.sh use it, and the console136accepts forms only from it. Point the new name at this server first, then save and sign in there.</p>137<button class="issues-submit" type="submit">save domain</button>138</form></section>`;139	return adminPage(c, ctx, "account", body);140}