fix(core): serialise first-user registration, equalise login timing

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:56:36 +01:00
co-authored by Claude Sonnet 5.5
parent e1faa7433b
commit fe3c9e4881
2 changed files with 63 additions and 4 deletions
+17 -4
View File
@@ -2,6 +2,7 @@ import type { TokenScope } from "@sttracker/db/schema";
import type { Db } from "@sttracker/db/db";
import { newId, nowIso } from "@sttracker/db/ids";
import { fromJson, toJson } from "@sttracker/db/json";
import { sql } from "kysely";
import { AuthError } from "./errors.ts";
export type User = { id: string; username: string; displayName: string; role: "admin" | "member" };
@@ -14,6 +15,12 @@ function sha256Hex(s: string): string {
return new Bun.CryptoHasher("sha256").update(s).digest("hex");
}
let dummy: Promise<string> | undefined;
/** Fixed argon2id hash, computed once, so unknown-user logins cost the same as real ones. */
function dummyHash(): Promise<string> {
return (dummy ??= Bun.password.hash("sttracker-dummy-password", { algorithm: "argon2id" }));
}
function randomToken(bytes = 32): string {
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
}
@@ -31,15 +38,19 @@ export async function registerUser(
const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" });
return db.k.transaction().execute(async (tx) => {
// Serialise registrations on Postgres so two first-time sign-ups cannot both become admin.
// SQLite is already serialised by its single connection.
if (db.dialect === "postgres") await sql`select pg_advisory_xact_lock(727401)`.execute(tx);
const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst();
if (anyUser && !input.inviteCode) throw new AuthError("invite_required");
const existing = await tx.selectFrom("users").select("id").where("username", "=", username).executeTakeFirst();
if (existing) throw new AuthError("username_taken");
const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst();
const id = newId();
const now = nowIso();
if (anyUser && !input.inviteCode) throw new AuthError("invite_required");
const role = anyUser ? "member" : "admin";
const display_name = input.displayName ?? username;
await tx
@@ -81,7 +92,9 @@ export async function login(
.selectAll()
.where("username", "=", username.toLowerCase())
.executeTakeFirst();
if (!row || !(await Bun.password.verify(password, row.password_hash))) {
const hash = row?.password_hash ?? (await dummyHash());
const ok = await Bun.password.verify(password, hash);
if (!row || !ok) {
throw new AuthError("invalid_credentials");
}
const sessionToken = randomToken();