feat: add User password hashing + account creation script
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
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;
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const passwordMatches = await bcrypt.compare(password, user.passwordHash);
|
||||
if (!passwordMatches) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return toRecord(user);
|
||||
}
|
||||
Reference in New Issue
Block a user