feat(auth): add password_hash column + bcrypt helper for Auth.js Credentials provider
- Migration 0002 adds nullable password_hash to users (idempotent) - src/lib/passwords.ts: encode/verify with self-describing format (algo$N$salt$hash) so we can migrate to a stronger KDF later - Schema adds passwordHash column; OAuth-only users leave it null
This commit is contained in:
@@ -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;
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user