stage 1
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
|
||||
export interface AuditInput {
|
||||
actorId?: string | null;
|
||||
action: string;
|
||||
entity: string;
|
||||
entityId?: string | null;
|
||||
before?: unknown;
|
||||
after?: unknown;
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
export async function audit(input: AuditInput): Promise<void> {
|
||||
try {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
actorId: input.actorId ?? null,
|
||||
action: input.action,
|
||||
entity: input.entity,
|
||||
entityId: input.entityId ?? null,
|
||||
before: input.before ? JSON.stringify(input.before) : null,
|
||||
after: input.after ? JSON.stringify(input.after) : null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[audit] failed to record:", err);
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getSessionUser, type SessionUser } from "@/lib/session";
|
||||
|
||||
export async function getCurrentUser(): Promise<SessionUser | null> {
|
||||
return getSessionUser();
|
||||
}
|
||||
|
||||
export async function requireUser(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireAdmin(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.role !== "admin") redirect("/");
|
||||
return user;
|
||||
}
|
||||
|
||||
export async function requireOperator(): Promise<SessionUser> {
|
||||
const user = await getSessionUser();
|
||||
if (!user) redirect("/login");
|
||||
if (user.role !== "operator") redirect("/");
|
||||
return user;
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const EnvSchema = z.object({
|
||||
DATABASE_URL: z.string().min(1),
|
||||
UPLOAD_DIR: z.string().default("./data/uploads"),
|
||||
APP_URL: z.string().url().default("http://localhost:3000"),
|
||||
APP_SECRET: z.string().min(32, "APP_SECRET must be at least 32 chars"),
|
||||
ADMIN_SESSION_HOURS: z.coerce.number().int().positive().default(8),
|
||||
OPERATOR_SESSION_HOURS: z.coerce.number().int().positive().default(12),
|
||||
BOOTSTRAP_ADMIN_EMAIL: z.string().email().optional(),
|
||||
BOOTSTRAP_ADMIN_PASSWORD: z.string().min(1).optional(),
|
||||
BOOTSTRAP_ADMIN_NAME: z.string().default("Administrator"),
|
||||
PIN_MAX_ATTEMPTS: z.coerce.number().int().positive().default(5),
|
||||
PIN_LOCKOUT_MINUTES: z.coerce.number().int().positive().default(15),
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof EnvSchema>;
|
||||
|
||||
function load(): Env {
|
||||
const parsed = EnvSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".")}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
export const env = load();
|
||||
@@ -0,0 +1,29 @@
|
||||
import bcrypt from "bcryptjs";
|
||||
|
||||
const ADMIN_ROUNDS = 12;
|
||||
const PIN_ROUNDS = 12;
|
||||
|
||||
export async function hashPassword(password: string): Promise<string> {
|
||||
if (!password || password.length < 8) {
|
||||
throw new Error("Password must be at least 8 characters");
|
||||
}
|
||||
return bcrypt.hash(password, ADMIN_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
|
||||
return bcrypt.compare(password, hash);
|
||||
}
|
||||
|
||||
export function isValidPin(pin: string): boolean {
|
||||
return /^\d{4}$/.test(pin);
|
||||
}
|
||||
|
||||
export async function hashPin(pin: string): Promise<string> {
|
||||
if (!isValidPin(pin)) throw new Error("PIN must be exactly 4 digits");
|
||||
return bcrypt.hash(pin, PIN_ROUNDS);
|
||||
}
|
||||
|
||||
export async function verifyPin(pin: string, hash: string): Promise<boolean> {
|
||||
if (!isValidPin(pin)) return false;
|
||||
return bcrypt.compare(pin, hash);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
declare global {
|
||||
var __prismaClient: PrismaClient | undefined;
|
||||
}
|
||||
|
||||
export const prisma =
|
||||
globalThis.__prismaClient ??
|
||||
new PrismaClient({
|
||||
log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
|
||||
});
|
||||
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
globalThis.__prismaClient = prisma;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
export function clientIp(req: NextRequest | Request): string | null {
|
||||
const headers = "headers" in req ? req.headers : new Headers();
|
||||
const fwd = headers.get("x-forwarded-for");
|
||||
if (fwd) return fwd.split(",")[0]!.trim();
|
||||
const real = headers.get("x-real-ip");
|
||||
if (real) return real.trim();
|
||||
return null;
|
||||
}
|
||||
|
||||
export function userAgent(req: NextRequest | Request): string | null {
|
||||
return req.headers.get("user-agent");
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { cookies } from "next/headers";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const SESSION_COOKIE = "mrp_session";
|
||||
|
||||
export type Role = "admin" | "operator";
|
||||
|
||||
export interface SessionUser {
|
||||
id: string;
|
||||
role: Role;
|
||||
name: string;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
function newToken(): { token: string; hash: string } {
|
||||
const token = randomBytes(32).toString("base64url");
|
||||
return { token, hash: sha256(token) };
|
||||
}
|
||||
|
||||
function ttlForRole(role: Role): number {
|
||||
const hours = role === "admin" ? env.ADMIN_SESSION_HOURS : env.OPERATOR_SESSION_HOURS;
|
||||
return hours * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
export interface CreateSessionInput {
|
||||
userId: string;
|
||||
role: Role;
|
||||
userAgent?: string | null;
|
||||
ipAddress?: string | null;
|
||||
deviceLabel?: string | null;
|
||||
}
|
||||
|
||||
export async function createSession(input: CreateSessionInput): Promise<{ token: string; expiresAt: Date }> {
|
||||
const { token, hash } = newToken();
|
||||
const expiresAt = new Date(Date.now() + ttlForRole(input.role));
|
||||
|
||||
await prisma.session.create({
|
||||
data: {
|
||||
userId: input.userId,
|
||||
tokenHash: hash,
|
||||
expiresAt,
|
||||
userAgent: input.userAgent ?? null,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
deviceLabel: input.deviceLabel ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const jar = await cookies();
|
||||
jar.set(SESSION_COOKIE, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
expires: expiresAt,
|
||||
});
|
||||
|
||||
return { token, expiresAt };
|
||||
}
|
||||
|
||||
export async function getSessionUser(): Promise<SessionUser | null> {
|
||||
const jar = await cookies();
|
||||
const token = jar.get(SESSION_COOKIE)?.value;
|
||||
if (!token) return null;
|
||||
|
||||
const hash = sha256(token);
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { tokenHash: hash },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!session) return null;
|
||||
if (session.expiresAt.getTime() < Date.now()) {
|
||||
await prisma.session.delete({ where: { id: session.id } }).catch(() => {});
|
||||
return null;
|
||||
}
|
||||
if (!session.user.active) return null;
|
||||
|
||||
// touch lastSeenAt at most once per minute to limit write load
|
||||
const stale = Date.now() - session.lastSeenAt.getTime() > 60_000;
|
||||
if (stale) {
|
||||
prisma.session
|
||||
.update({ where: { id: session.id }, data: { lastSeenAt: new Date() } })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
id: session.user.id,
|
||||
role: session.user.role as Role,
|
||||
name: session.user.name,
|
||||
email: session.user.email,
|
||||
};
|
||||
}
|
||||
|
||||
export async function destroyCurrentSession(): Promise<void> {
|
||||
const jar = await cookies();
|
||||
const token = jar.get(SESSION_COOKIE)?.value;
|
||||
if (token) {
|
||||
await prisma.session.deleteMany({ where: { tokenHash: sha256(token) } });
|
||||
}
|
||||
jar.delete(SESSION_COOKIE);
|
||||
}
|
||||
|
||||
export async function purgeExpiredSessions(): Promise<number> {
|
||||
const result = await prisma.session.deleteMany({
|
||||
where: { expiresAt: { lt: new Date() } },
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
Reference in New Issue
Block a user