diff --git a/apps/web/src/db.ts b/apps/web/src/db.ts index 2208135..539d588 100644 --- a/apps/web/src/db.ts +++ b/apps/web/src/db.ts @@ -14,8 +14,7 @@ export async function migrate() { await sql` CREATE TABLE accounts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - key_lookup TEXT NOT NULL UNIQUE, - key_hash TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, email_hash TEXT, created_at TIMESTAMPTZ DEFAULT now() ) @@ -57,9 +56,7 @@ export async function migrate() { await sql` CREATE TABLE api_keys ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - key_lookup TEXT NOT NULL UNIQUE, - key_hash TEXT NOT NULL, - key_plain TEXT NOT NULL, + key TEXT NOT NULL UNIQUE, account_id UUID NOT NULL REFERENCES accounts(id) ON DELETE CASCADE, label TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT now(), diff --git a/apps/web/src/routes/auth.ts b/apps/web/src/routes/auth.ts index a55aa99..7d705dd 100644 --- a/apps/web/src/routes/auth.ts +++ b/apps/web/src/routes/auth.ts @@ -1,47 +1,21 @@ import { Elysia, t } from "elysia"; -import { randomBytes, createHash } from "crypto"; +import { createHash } from "crypto"; import sql from "../db"; function generateKey(): string { - return crypto.randomUUID(); // standard UUID v4, 128-bit, familiar format -} - -function normalizeKey(raw: string): string { - // Strip dashes/spaces so both formatted and raw hex keys resolve correctly - return raw.replace(/[-\s]/g, "").toLowerCase(); -} - -function sha256(data: string): string { - return createHash("sha256").update(data).digest("hex"); + return crypto.randomUUID(); } function hashEmail(email: string): string { return createHash("sha256").update(email.toLowerCase().trim()).digest("hex"); } -/** - * Resolves a raw key to an account. - * 1. Compute sha256 of the raw key for O(1) lookup - * 2. Query accounts or api_keys by key_lookup - * 3. Verify with bcrypt for extra security - */ -async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: string | null } | null> { - const normalized = normalizeKey(rawKey); - const lookup = sha256(normalized); +async function resolveKey(key: string): Promise<{ accountId: string; keyId: string | null } | null> { + const [account] = await sql`SELECT id FROM accounts WHERE key = ${key}`; + if (account) return { accountId: account.id, keyId: null }; - // Check primary account key - const [account] = await sql`SELECT id, key_hash FROM accounts WHERE key_lookup = ${lookup}`; - if (account) { - const valid = await Bun.password.verify(normalized, account.key_hash); - if (!valid) return null; - return { accountId: account.id, keyId: null }; - } - - // Check API sub-keys - const [apiKey] = await sql`SELECT id, account_id, key_hash FROM api_keys WHERE key_lookup = ${lookup}`; + const [apiKey] = await sql`SELECT id, account_id FROM api_keys WHERE key = ${key}`; if (apiKey) { - const valid = await Bun.password.verify(normalized, apiKey.key_hash); - if (!valid) return null; sql`UPDATE api_keys SET last_used_at = now() WHERE id = ${apiKey.id}`.catch(() => {}); return { accountId: apiKey.account_id, keyId: apiKey.id }; } @@ -49,16 +23,13 @@ async function resolveKey(rawKey: string): Promise<{ accountId: string; keyId: s return null; } -// Exported for SSR use in dashboard route export { resolveKey }; export function requireAuth(app: Elysia) { return app .derive(async ({ headers, cookie, set }) => { - // 1. Bearer token (API clients) — case-insensitive const authHeader = headers["authorization"] ?? ""; const bearer = authHeader.match(/^bearer\s+(.+)$/i)?.[1]?.trim(); - // 2. Cookie (dashboard / SSR) const cookieKey = cookie?.pingql_key?.value; const key = bearer || cookieKey; @@ -87,12 +58,11 @@ const COOKIE_OPTS = { sameSite: "lax" as const, path: "/", domain: process.env.COOKIE_DOMAIN ?? ".pingql.com", - maxAge: 60 * 60 * 24 * 365, // 1 year + maxAge: 60 * 60 * 24 * 365, }; export const account = new Elysia({ prefix: "/account" }) - // ── Login (sets cookie) ────────────────────────────────────────────── .post("/login", async ({ body, cookie, set }) => { const key = (body.key as string)?.trim(); if (!key) { set.status = 400; return { error: "Key required" }; } @@ -100,39 +70,27 @@ export const account = new Elysia({ prefix: "/account" }) const resolved = await resolveKey(key); if (!resolved) { set.status = 401; - // If it's a form POST, redirect back with error if ((body as any)._form) { set.redirect = "/dashboard?error=invalid"; return; } return { error: "Invalid account key" }; } cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); - - // Form POST → redirect to dashboard if ((body as any)._form) { set.redirect = "/dashboard/home"; return; } return { ok: true }; }, { detail: { hide: true } }) - // ── Logout ─────────────────────────────────────────────────────────── .get("/logout", ({ cookie, set }) => { cookie.pingql_key.remove(); set.redirect = "/dashboard"; }, { detail: { hide: true } }) - // ── Register ──────────────────────────────────────────────────────── .post("/register", async ({ body, cookie }) => { - const rawKey = generateKey(); - const norm = normalizeKey(rawKey); - const keyLookup = sha256(norm); - const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 }); + const key = generateKey(); const emailHash = body.email ? hashEmail(body.email) : null; - - await sql`INSERT INTO accounts (key_lookup, key_hash, email_hash) VALUES (${keyLookup}, ${keyHash}, ${emailHash})`; - - // Set cookie so user is immediately logged in - cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS }); - + await sql`INSERT INTO accounts (key, email_hash) VALUES (${key}, ${emailHash})`; + cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); return { - key: rawKey, + key, ...(body.email ? { email_registered: true } : { email_registered: false }), }; }, { @@ -141,13 +99,11 @@ export const account = new Elysia({ prefix: "/account" }) }), }) - // ── Auth-required routes below ─────────────────────────────────────── .use(requireAuth) - // Get account settings .get("/settings", async ({ accountId }) => { const [acc] = await sql`SELECT id, email_hash, created_at FROM accounts WHERE id = ${accountId}`; - const keys = await sql`SELECT id, key_plain, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; + const keys = await sql`SELECT id, key, label, created_at, last_used_at FROM api_keys WHERE account_id = ${accountId} ORDER BY created_at DESC`; return { account_id: acc.id, has_email: !!acc.email_hash, @@ -156,7 +112,6 @@ export const account = new Elysia({ prefix: "/account" }) }; }) - // Update email .post("/email", async ({ accountId, body }) => { const emailHash = body.email ? hashEmail(body.email) : null; await sql`UPDATE accounts SET email_hash = ${emailHash} WHERE id = ${accountId}`; @@ -167,40 +122,23 @@ export const account = new Elysia({ prefix: "/account" }) }), }) - // Reset primary key — generates a new one, old one immediately invalid .post("/reset-key", async ({ accountId, cookie }) => { - const rawKey = generateKey(); - const norm = normalizeKey(rawKey); - const keyLookup = sha256(norm); - const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 }); - - await sql`UPDATE accounts SET key_lookup = ${keyLookup}, key_hash = ${keyHash} WHERE id = ${accountId}`; - - // Set the new key as the cookie so the user stays logged in - cookie.pingql_key.set({ value: rawKey, ...COOKIE_OPTS }); - - return { - key: rawKey, - message: "Primary key rotated. Your old key is now invalid.", - }; + const key = generateKey(); + await sql`UPDATE accounts SET key = ${key} WHERE id = ${accountId}`; + cookie.pingql_key.set({ value: key, ...COOKIE_OPTS }); + return { key, message: "Primary key rotated. Your old key is now invalid." }; }) - // Create a sub-key (for different apps or shared access) .post("/keys", async ({ accountId, body }) => { - const rawKey = generateKey(); - const norm = normalizeKey(rawKey); - const keyLookup = sha256(norm); - const keyHash = await Bun.password.hash(norm, { algorithm: "bcrypt", cost: 10 }); - - const [created] = await sql`INSERT INTO api_keys (key_lookup, key_hash, key_plain, account_id, label) VALUES (${keyLookup}, ${keyHash}, ${rawKey}, ${accountId}, ${body.label}) RETURNING id`; - return { key: rawKey, id: created.id, label: body.label }; + const key = generateKey(); + const [created] = await sql`INSERT INTO api_keys (key, account_id, label) VALUES (${key}, ${accountId}, ${body.label}) RETURNING id`; + return { key, id: created.id, label: body.label }; }, { body: t.Object({ label: t.String({ description: "A name for this key, e.g. 'ci-pipeline' or 'mobile-app'" }), }), }) - // Delete a sub-key .delete("/keys/:id", async ({ accountId, params, error }) => { const [deleted] = await sql` DELETE FROM api_keys WHERE id = ${params.id} AND account_id = ${accountId} RETURNING id diff --git a/apps/web/src/views/settings.ejs b/apps/web/src/views/settings.ejs index 085546c..2983cb0 100644 --- a/apps/web/src/views/settings.ejs +++ b/apps/web/src/views/settings.ejs @@ -78,8 +78,8 @@
<%= k.key_plain %>
-
+ <%= k.key %>
+
created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %>