171 lines
6.0 KiB
TypeScript
171 lines
6.0 KiB
TypeScript
import { z } from "zod";
|
|
import bcrypt from "bcryptjs";
|
|
import { prisma } from "./prisma";
|
|
|
|
// Minimal internal auth accounts — see prisma/schema.prisma's User model
|
|
// comment and CLAUDE.md's auth decision notes. A handful of trusted IT
|
|
// staff accounts, created by hand via scripts/create-user.ts. No
|
|
// self-registration, no `role` field (flat permission model: any logged-in
|
|
// user can do anything), no public list/CRUD API route — just enough to
|
|
// support login + account creation.
|
|
|
|
// bcryptjs cost factor for hashing. 10 is bcrypt's long-standing default
|
|
// and a reasonable balance of hashing cost vs. login latency for this use
|
|
// case (a handful of staff logins, not a high-value target).
|
|
const BCRYPT_SALT_ROUNDS = 10;
|
|
|
|
// Deliberately chosen minimum — long enough to rule out trivially weak
|
|
// passwords for a handful of trusted IT staff accounts, not a full password
|
|
// policy (no complexity rules, no breach-list check). Revisit if this ever
|
|
// grows into employee self-service.
|
|
const MIN_PASSWORD_LENGTH = 8;
|
|
|
|
// Precomputed bcrypt hash of a fixed dummy password, hashed with the same
|
|
// cost factor (BCRYPT_SALT_ROUNDS) used for real user passwords. Used as a
|
|
// timing-safety decoy in verifyUserCredentials() below: when the looked-up
|
|
// email doesn't exist, we still run a bcrypt.compare() against this hash
|
|
// (whose result is always discarded) so that the "unknown email" code path
|
|
// takes roughly the same time as the "wrong password" path — otherwise the
|
|
// early return would let an attacker distinguish "email doesn't exist" from
|
|
// "email exists, password wrong" purely by response latency (bcrypt.compare
|
|
// dominates request time). Computed once at module load via hashSync (not
|
|
// per-request) since hashing itself is deliberately expensive.
|
|
const DUMMY_PASSWORD_HASH = bcrypt.hashSync(
|
|
"dummy-password-for-timing-safety",
|
|
BCRYPT_SALT_ROUNDS
|
|
);
|
|
|
|
export interface UserRecord {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
created_at: string;
|
|
}
|
|
|
|
export interface CreateUserInput {
|
|
email: string;
|
|
name: string;
|
|
password: string;
|
|
}
|
|
|
|
export type ValidationResult =
|
|
| { ok: true; value: CreateUserInput }
|
|
| { ok: false; error: string };
|
|
|
|
// A string field that must be present and not just whitespace. Mirrors the
|
|
// same helper in src/lib/requests.ts (not shared/exported from there since
|
|
// that file doesn't export it — small enough to duplicate rather than
|
|
// introduce a shared-helpers module for one function).
|
|
function nonEmptyString(message: string) {
|
|
return z.string({ required_error: message, invalid_type_error: message }).refine(
|
|
(value) => value.trim() !== "",
|
|
{ message }
|
|
);
|
|
}
|
|
|
|
const createUserSchema = z.object({
|
|
email: z
|
|
.string({ required_error: "email is required", invalid_type_error: "email is required" })
|
|
.email("email must be a valid email address"),
|
|
name: nonEmptyString("name is required and must be a non-empty string"),
|
|
password: z
|
|
.string({
|
|
required_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
invalid_type_error: `password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`,
|
|
})
|
|
.min(
|
|
MIN_PASSWORD_LENGTH,
|
|
`password is required and must be at least ${MIN_PASSWORD_LENGTH} characters`
|
|
),
|
|
});
|
|
|
|
/**
|
|
* Validates raw (untyped) user-creation input. email must be a valid email
|
|
* address, name must be a non-empty string, password must be at least
|
|
* MIN_PASSWORD_LENGTH characters. The password is validated here in its
|
|
* plaintext form — hashing happens separately in createUser().
|
|
*/
|
|
export function validateCreateUserInput(input: {
|
|
email?: unknown;
|
|
name?: unknown;
|
|
password?: unknown;
|
|
}): ValidationResult {
|
|
const result = createUserSchema.safeParse(input);
|
|
if (!result.success) {
|
|
const message = result.error.issues[0]?.message ?? "Invalid input";
|
|
return { ok: false, error: message };
|
|
}
|
|
return { ok: true, value: result.data };
|
|
}
|
|
|
|
function toRecord(row: {
|
|
id: string;
|
|
email: string;
|
|
name: string;
|
|
createdAt: Date;
|
|
}): UserRecord {
|
|
return {
|
|
id: row.id,
|
|
email: row.email,
|
|
name: row.name,
|
|
created_at: row.createdAt.toISOString(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Hashes the given plaintext password with bcryptjs and inserts a new User
|
|
* row. Caller is expected to have already validated the input via
|
|
* validateCreateUserInput() — this does not re-validate.
|
|
*/
|
|
export async function createUser(input: CreateUserInput): Promise<UserRecord> {
|
|
const passwordHash = await bcrypt.hash(input.password, BCRYPT_SALT_ROUNDS);
|
|
const created = await prisma.user.create({
|
|
data: {
|
|
email: input.email,
|
|
name: input.name,
|
|
passwordHash,
|
|
},
|
|
});
|
|
return toRecord(created);
|
|
}
|
|
|
|
/**
|
|
* Looks up a user by email and compares the given plaintext password
|
|
* against the stored bcrypt hash. Returns the user record (never the
|
|
* password hash) on success, or null if the email doesn't exist or the
|
|
* password doesn't match — callers should treat both failure cases
|
|
* identically (don't leak which one it was).
|
|
*
|
|
* Accepts `unknown` for email/password since this is also the core logic
|
|
* behind the Credentials provider's authorize() callback, whose input
|
|
* comes from unvalidated form/request data — see src/auth.ts.
|
|
*/
|
|
export async function verifyUserCredentials(
|
|
email: unknown,
|
|
password: unknown
|
|
): Promise<UserRecord | null> {
|
|
if (typeof email !== "string" || typeof password !== "string") {
|
|
return null;
|
|
}
|
|
if (email.trim() === "" || password === "") {
|
|
return null;
|
|
}
|
|
|
|
const user = await prisma.user.findUnique({ where: { email } });
|
|
if (!user) {
|
|
// Run a dummy compare against a fixed decoy hash and discard the result
|
|
// — see DUMMY_PASSWORD_HASH comment above. This keeps timing consistent
|
|
// with the "email exists, password wrong" branch below instead of
|
|
// returning early.
|
|
await bcrypt.compare(password, DUMMY_PASSWORD_HASH);
|
|
return null;
|
|
}
|
|
|
|
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
|
if (!passwordMatches) {
|
|
return null;
|
|
}
|
|
|
|
return toRecord(user);
|
|
}
|