feat: add User password hashing + account creation script
This commit is contained in:
Generated
+10
@@ -10,6 +10,7 @@
|
|||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
@@ -5210,6 +5211,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bcryptjs": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"bin": {
|
||||||
|
"bcrypt": "bin/bcrypt"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -9,10 +9,12 @@
|
|||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"postinstall": "prisma generate",
|
"postinstall": "prisma generate",
|
||||||
"db:migrate": "tsx scripts/migrate.ts"
|
"db:migrate": "tsx scripts/migrate.ts",
|
||||||
|
"user:create": "tsx scripts/create-user.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^5.20.0",
|
"@prisma/client": "^5.20.0",
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.28.0",
|
"lucide-react": "^1.28.0",
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// Standalone CLI entry point for creating an internal auth account by hand:
|
||||||
|
// `npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."`
|
||||||
|
// There is deliberately no self-registration UI or public API route for
|
||||||
|
// this — see src/lib/users.ts / prisma/schema.prisma's User model comment
|
||||||
|
// for why (a handful of trusted IT staff accounts, not company-wide).
|
||||||
|
import { createUser, validateCreateUserInput } from "../src/lib/users";
|
||||||
|
import { prisma } from "../src/lib/prisma";
|
||||||
|
|
||||||
|
function parseArgs(argv: string[]): Record<string, string> {
|
||||||
|
const args: Record<string, string> = {};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (!arg.startsWith("--")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = arg.slice(2);
|
||||||
|
const value = argv[i + 1];
|
||||||
|
if (value === undefined || value.startsWith("--")) {
|
||||||
|
throw new Error(`Missing value for --${key}`);
|
||||||
|
}
|
||||||
|
args[key] = value;
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
|
||||||
|
const validation = validateCreateUserInput({
|
||||||
|
email: args.email,
|
||||||
|
name: args.name,
|
||||||
|
password: args.password,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!validation.ok) {
|
||||||
|
console.error(`Error: ${validation.error}`);
|
||||||
|
console.error(
|
||||||
|
'Usage: npx tsx scripts/create-user.ts --email [email protected] --name "Foo Bar" --password "..."'
|
||||||
|
);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await createUser(validation.value);
|
||||||
|
console.log(`Created user ${user.email} (id: ${user.id})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.catch((error) => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
@@ -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