diff --git a/db/migrations/0002_admin_password.sql b/db/migrations/0002_admin_password.sql new file mode 100644 index 0000000..edeacdd --- /dev/null +++ b/db/migrations/0002_admin_password.sql @@ -0,0 +1,21 @@ +-- 0002_admin_password.sql +-- +-- Adds a `password_hash` column to `users` so the Auth.js Credentials +-- provider can verify email + password against the database. +-- +-- Idempotent: uses `ADD COLUMN IF NOT EXISTS`. The column is nullable +-- because OAuth-only users (Google) never set a password. +-- +-- The Credentials provider's `authorize` function is documented in +-- `src/lib/auth.ts` — it queries this column and runs `verifyPassword` +-- (see `src/lib/passwords.ts`) before returning the user. + +BEGIN; + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS password_hash TEXT; + +-- Update the updated_at trigger tracking — no new triggers needed since +-- `users` already has `set_updated_at` from migration 0001. + +COMMIT; diff --git a/db/schema/tenants.ts b/db/schema/tenants.ts index 2449819..7f7ad88 100644 --- a/db/schema/tenants.ts +++ b/db/schema/tenants.ts @@ -35,6 +35,13 @@ export const users = pgTable( email: text("email").unique(), name: text("name"), image: text("image"), + /** + * Bcrypt / scrypt / argon2 hash, or null for OAuth-only users. + * Format is self-describing (algorithm$N$salt$hash) so we can + * migrate to a stronger KDF later without losing existing hashes. + * See `src/lib/passwords.ts` for the encoder/decoder. + */ + passwordHash: text("password_hash"), authProvider: text("auth_provider", { enum: authProviderEnum }) .notNull() .default("dev"), diff --git a/src/lib/passwords.ts b/src/lib/passwords.ts new file mode 100644 index 0000000..cb31839 --- /dev/null +++ b/src/lib/passwords.ts @@ -0,0 +1,67 @@ +import "server-only"; +import { scryptSync, randomBytes, timingSafeEqual } from "node:crypto"; + +/** + * Password hashing + verification. + * + * Format: `scrypt$N$salt$hash` (hex-encoded). + * + * - scrypt: algorithm identifier (future-proof — easy to migrate to + * argon2id by adding a new branch in `verifyPassword`) + * - N: scrypt cost parameter (CPU/memory). 2^14 (16384) is a + * reasonable default for an interactive login on modern + * hardware (~50ms per hash on a typical server) + * - salt: 16 random bytes, hex-encoded + * - hash: 64-byte derived key, hex-encoded + * + * Why scrypt and not bcrypt / argon2? + * - No extra dependency. Node's `crypto` module has it built in. + * - Argon2id is the modern recommendation but requires a native module + * (`argon2` or `@node-rs/argon2`) which complicates the install. + * When we add a native build step, we should switch to argon2id. + * - bcrypt is fine but not better than scrypt for our use case, and + * also requires a native module. + * + * All comparisons are constant-time (`timingSafeEqual`) to defeat + * timing-based side-channel attacks. + */ + +const KEY_LEN = 64; +const SALT_LEN = 16; +const DEFAULT_N = 16384; // 2^14 +const ALGO = "scrypt"; + +export function hashPassword(plain: string): string { + if (typeof plain !== "string" || plain.length === 0) { + throw new Error("hashPassword: password must be a non-empty string"); + } + const salt = randomBytes(SALT_LEN).toString("hex"); + const hash = scryptSync(plain, salt, KEY_LEN, { N: DEFAULT_N }).toString("hex"); + return `${ALGO}$${DEFAULT_N}$${salt}$${hash}`; +} + +export function verifyPassword(plain: string, stored: string): boolean { + if (typeof plain !== "string" || typeof stored !== "string") return false; + const parts = stored.split("$"); + if (parts.length !== 4 || parts[0] !== ALGO) return false; + const n = Number.parseInt(parts[1], 10); + if (!Number.isFinite(n) || n < 1024 || n > 1_000_000) return false; + const salt = parts[2]; + const expectedHex = parts[3]; + if (!salt || !expectedHex) return false; + + let actual: Buffer; + let expected: Buffer; + try { + actual = scryptSync(plain, salt, KEY_LEN, { N: n }); + expected = Buffer.from(expectedHex, "hex"); + } catch { + return false; + } + if (actual.length !== expected.length) return false; + try { + return timingSafeEqual(actual, expected); + } catch { + return false; + } +}