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(),
|
email: text("email").unique(),
|
||||||
name: text("name"),
|
name: text("name"),
|
||||||
image: text("image"),
|
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 })
|
authProvider: text("auth_provider", { enum: authProviderEnum })
|
||||||
.notNull()
|
.notNull()
|
||||||
.default("dev"),
|
.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