refactor: drop all hashing, store keys plaintext
This commit is contained in:
parent
54c89a5a11
commit
e461d73ce3
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -78,8 +78,8 @@
|
|||
<button onclick="deleteKey('<%= k.id %>', this)" class="text-xs text-gray-600 hover:text-red-400 transition-colors px-2 py-1">Revoke</button>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key_plain %></code>
|
||||
<button onclick="copyKey(this, '<%= k.key_plain %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
||||
<code class="flex-1 bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-blue-400 text-xs font-mono select-all"><%= k.key %></code>
|
||||
<button onclick="copyKey(this, '<%= k.key %>')" class="px-3 bg-gray-800 hover:bg-gray-700 border border-gray-700 rounded-lg text-gray-400 hover:text-white text-xs transition-colors">Copy</button>
|
||||
</div>
|
||||
<p class="text-xs text-gray-600 mt-1.5">created <%= new Date(k.created_at).toLocaleDateString() %> <%~ k.last_used_at ? '· last used ' + it.timeAgoSSR(k.last_used_at) : '· never used' %></p>
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue