stage 1
This commit is contained in:
+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