Groundwork for securing the web front end (vrek gol-wqf95dq). The app does not enforce login yet. - src/lib/admin-file.ts: the admin file at ADMIN_AUTH_FILE (default .data/admin.json). scrypt hashing (N=2^16, random salt, bounded parameters, constant-time compare), zod-validated reads where a malformed file is an error, and atomic 0600 writes that won't replace an existing admin without overwrite. Plain Node, so the CLI can share it (iss-mffqscg). - src/lib/admin-auth.ts: server-only app layer; failed logins always cost one hash. - scripts/create-admin.mts + `npm run admin:create`: create or reset the admin outside the app, interactive (hidden, confirmed) or piped (iss-7xmka20). The README documents it, a no-npm Node one-liner, the file format, and password reset. - src/lib/session-token.ts and session.ts: stateless HMAC-signed session cookie, keyed from the password hash so a password change ends every session, with a 12 h sliding window (iss-e27nb70, dec-f0xar8r). 281 tests, 99.8% line coverage. Refreshes the vrek export. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
46 lines
1.5 KiB
TypeScript
46 lines
1.5 KiB
TypeScript
import "server-only";
|
|
import {
|
|
adminFilePath,
|
|
createAdminRecord,
|
|
hashPassword,
|
|
readAdminFile,
|
|
verifyPassword,
|
|
writeAdminFile,
|
|
type AdminFile,
|
|
} from "./admin-file";
|
|
|
|
/**
|
|
* The app's view of the admin login. The file is re-read on each call (it's tiny), so an
|
|
* admin created or reset with the CLI takes effect without restarting the server.
|
|
*/
|
|
|
|
export function getAdmin(): Promise<AdminFile | null> {
|
|
return readAdminFile(adminFilePath());
|
|
}
|
|
|
|
export async function hasAdmin(): Promise<boolean> {
|
|
return (await getAdmin()) !== null;
|
|
}
|
|
|
|
// Hashed once, lazily: lets a failed login cost the same whether or not the username exists.
|
|
let dummyHash: Promise<string> | null = null;
|
|
|
|
/**
|
|
* True only for the admin's exact username and password. Always spends one full scrypt
|
|
* hash, so timing doesn't reveal whether an admin exists or the username was right.
|
|
*/
|
|
export async function checkLogin(username: string, password: string): Promise<boolean> {
|
|
const admin = await getAdmin();
|
|
const userMatches = admin !== null && admin.username === username;
|
|
const hash = userMatches ? admin.passwordHash : await (dummyHash ??= hashPassword("not the password"));
|
|
const passwordMatches = await verifyPassword(password, hash);
|
|
return userMatches && passwordMatches;
|
|
}
|
|
|
|
/** Creates the first admin. Fails with EEXIST if one already exists. */
|
|
export async function createAdmin(username: string, password: string): Promise<AdminFile> {
|
|
const admin = await createAdminRecord(username, password);
|
|
await writeAdminFile(adminFilePath(), admin);
|
|
return admin;
|
|
}
|