114 lines
3.0 KiB
TypeScript
114 lines
3.0 KiB
TypeScript
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;
|
|
}
|