fix(core): serialise first-user registration, equalise login timing
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
e1faa7433b
commit
fe3c9e4881
@@ -2,6 +2,7 @@ import type { TokenScope } from "@sttracker/db/schema";
|
|||||||
import type { Db } from "@sttracker/db/db";
|
import type { Db } from "@sttracker/db/db";
|
||||||
import { newId, nowIso } from "@sttracker/db/ids";
|
import { newId, nowIso } from "@sttracker/db/ids";
|
||||||
import { fromJson, toJson } from "@sttracker/db/json";
|
import { fromJson, toJson } from "@sttracker/db/json";
|
||||||
|
import { sql } from "kysely";
|
||||||
import { AuthError } from "./errors.ts";
|
import { AuthError } from "./errors.ts";
|
||||||
|
|
||||||
export type User = { id: string; username: string; displayName: string; role: "admin" | "member" };
|
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");
|
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 {
|
function randomToken(bytes = 32): string {
|
||||||
return crypto.getRandomValues(new Uint8Array(bytes)).toBase64({ alphabet: "base64url", omitPadding: true });
|
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" });
|
const passwordHash = await Bun.password.hash(input.password, { algorithm: "argon2id" });
|
||||||
|
|
||||||
return db.k.transaction().execute(async (tx) => {
|
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();
|
const existing = await tx.selectFrom("users").select("id").where("username", "=", username).executeTakeFirst();
|
||||||
if (existing) throw new AuthError("username_taken");
|
if (existing) throw new AuthError("username_taken");
|
||||||
|
|
||||||
const anyUser = await tx.selectFrom("users").select("id").limit(1).executeTakeFirst();
|
|
||||||
const id = newId();
|
const id = newId();
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
|
|
||||||
if (anyUser && !input.inviteCode) throw new AuthError("invite_required");
|
|
||||||
|
|
||||||
const role = anyUser ? "member" : "admin";
|
const role = anyUser ? "member" : "admin";
|
||||||
const display_name = input.displayName ?? username;
|
const display_name = input.displayName ?? username;
|
||||||
await tx
|
await tx
|
||||||
@@ -81,7 +92,9 @@ export async function login(
|
|||||||
.selectAll()
|
.selectAll()
|
||||||
.where("username", "=", username.toLowerCase())
|
.where("username", "=", username.toLowerCase())
|
||||||
.executeTakeFirst();
|
.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");
|
throw new AuthError("invalid_credentials");
|
||||||
}
|
}
|
||||||
const sessionToken = randomToken();
|
const sessionToken = randomToken();
|
||||||
|
|||||||
@@ -106,4 +106,50 @@ forEachDialect("auth", (getDb) => {
|
|||||||
await revokeAppToken(db, u.id, id);
|
await revokeAppToken(db, u.id, id);
|
||||||
expect(await verifyAppToken(db, token, "read")).toBeNull();
|
expect(await verifyAppToken(db, token, "read")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("concurrent first registrations yield exactly one admin", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
registerUser(db, { username: "alice", password: "x" }),
|
||||||
|
registerUser(db, { username: "bob", password: "x" }),
|
||||||
|
]);
|
||||||
|
const ok = results.filter((r) => r.status === "fulfilled");
|
||||||
|
const bad = results.filter((r) => r.status === "rejected");
|
||||||
|
expect(ok.length).toBe(1);
|
||||||
|
expect((ok[0] as PromiseFulfilledResult<{ role: string }>).value.role).toBe("admin");
|
||||||
|
expect(((bad[0] as PromiseRejectedResult).reason as AuthError).code).toBe("invite_required");
|
||||||
|
const admins = await db.k.selectFrom("users").select("id").where("role", "=", "admin").execute();
|
||||||
|
expect(admins.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no invite means invite_required even for a taken username", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
expect(await code(registerUser(db, { username: "alice", password: "x" }))).toBe("invite_required");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("only hashes are stored for invites and app tokens", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const u = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const inv = await createInvite(db, u.id);
|
||||||
|
const { token } = await createAppToken(db, u.id, "cli", ["read"]);
|
||||||
|
const invRow = await db.k.selectFrom("invites").selectAll().executeTakeFirstOrThrow();
|
||||||
|
const tokRow = await db.k.selectFrom("app_tokens").selectAll().executeTakeFirstOrThrow();
|
||||||
|
expect(invRow.code_hash).not.toBe(inv);
|
||||||
|
expect(tokRow.token_hash).not.toBe(token);
|
||||||
|
expect(tokRow.token_hash).not.toContain(token);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("revoking with another user's id leaves the token valid", async () => {
|
||||||
|
const db = getDb();
|
||||||
|
const a = await registerUser(db, { username: "alice", password: "x" });
|
||||||
|
const b = await registerUser(db, {
|
||||||
|
username: "bob",
|
||||||
|
password: "x",
|
||||||
|
inviteCode: await createInvite(db, a.id),
|
||||||
|
});
|
||||||
|
const { id, token } = await createAppToken(db, a.id, "cli", ["read"]);
|
||||||
|
await revokeAppToken(db, b.id, id);
|
||||||
|
expect(await verifyAppToken(db, token, "read")).toEqual(a);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user