SPB Git
4.5 KB · 55 lines typescript
1import "server-only";2import { cookies, headers } from "next/headers";3import { createHash, createHmac, randomBytes, timingSafeEqual } from "node:crypto";4import { and, eq, gt, isNull, ne } from "drizzle-orm";5import { db, schema } from "@/db";6import { cache } from "react";78export const COOKIE = process.env.NODE_ENV === "production" ? "__Host-tools_session" : "tools_session";9const IDLE_DAYS = 14, ABS_DAYS = 30, ROTATE_HOURS = 12;10const sha = (s: string) => createHash("sha256").update(s).digest("hex");1112export function clientIp(h: Headers) { return (h.get("x-forwarded-for")?.split(",")[0] ?? h.get("x-real-ip") ?? "0.0.0.0").trim(); }13const cookieOpts = { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "strict" as const, path: "/" };1415export async function createSession(userId: string, opts: { mfaPassed?: boolean } = {}) {16  const token = randomBytes(32).toString("base64url"); const h = await headers(); const now = Date.now();17  await db.insert(schema.sessions).values({ userId, tokenHash: sha(token), csrfSecret: randomBytes(32).toString("base64url"), userAgent: h.get("user-agent")?.slice(0, 300) ?? null, ip: clientIp(h), expiresAt: new Date(now + IDLE_DAYS * 864e5), absoluteExpiresAt: new Date(now + ABS_DAYS * 864e5), mfaPassed: opts.mfaPassed ?? false });18  return token;19}20export async function setSessionCookie(token: string) { (await cookies()).set(COOKIE, token, { ...cookieOpts, maxAge: ABS_DAYS * 86400 }); }21export async function clearSessionCookie() { (await cookies()).set(COOKIE, "", { ...cookieOpts, maxAge: 0 }); }2223export type SessionUser = { id: string; email: string; displayName: string; role: string; totpEnabled: boolean; mustChangePassword: boolean; sessionId: string; mfaPassed: boolean; csrfSecret: string };2425export const getSession = cache(async (): Promise<SessionUser | null> => {26  const token = (await cookies()).get(COOKIE)?.value; if (!token) return null;27  const now = new Date();28  const rows = await db.select({ s: schema.sessions, u: schema.users }).from(schema.sessions).innerJoin(schema.users, eq(schema.users.id, schema.sessions.userId))29    .where(and(eq(schema.sessions.tokenHash, sha(token)), isNull(schema.sessions.revokedAt), gt(schema.sessions.expiresAt, now), gt(schema.sessions.absoluteExpiresAt, now))).limit(1);30  if (!rows.length) return null;31  const { s, u } = rows[0];32  if (now.getTime() - s.lastSeenAt.getTime() > 5 * 60_000) db.update(schema.sessions).set({ lastSeenAt: now, expiresAt: new Date(now.getTime() + IDLE_DAYS * 864e5) }).where(eq(schema.sessions.id, s.id)).catch(() => {});33  return { id: u.id, email: u.email, displayName: u.displayName, role: u.role, totpEnabled: u.totpEnabled, mustChangePassword: u.mustChangePassword, sessionId: s.id, mfaPassed: s.mfaPassed, csrfSecret: s.csrfSecret };34});3536/** Rotate the session token (after login step-up or every ROTATE_HOURS). Returns the new token to set. */37export async function rotateSession(sessionId: string): Promise<string> {38  const token = randomBytes(32).toString("base64url");39  await db.update(schema.sessions).set({ tokenHash: sha(token), rotatedAt: new Date() }).where(eq(schema.sessions.id, sessionId));40  return token;41}42export async function maybeRotate(s: SessionUser) {43  const [row] = await db.select({ rotatedAt: schema.sessions.rotatedAt }).from(schema.sessions).where(eq(schema.sessions.id, s.sessionId));44  if (row && Date.now() - row.rotatedAt.getTime() > ROTATE_HOURS * 3600e3) { const t = await rotateSession(s.sessionId); await setSessionCookie(t); }45}46export async function markMfaPassed(sessionId: string) { await db.update(schema.sessions).set({ mfaPassed: true }).where(eq(schema.sessions.id, sessionId)); }47export async function revokeSession(sessionId: string) { await db.update(schema.sessions).set({ revokedAt: new Date() }).where(eq(schema.sessions.id, sessionId)); }48export async function revokeOtherSessions(userId: string, keep: string) { await db.update(schema.sessions).set({ revokedAt: new Date() }).where(and(eq(schema.sessions.userId, userId), isNull(schema.sessions.revokedAt), ne(schema.sessions.id, keep))); }4950/** CSRF: double-submit token derived from the per-session secret (HMAC with SESSION_SECRET). */51export function csrfToken(s: SessionUser) { return createHmac("sha256", process.env.SESSION_SECRET ?? "dev").update(s.csrfSecret).digest("base64url"); }52export function verifyCsrf(s: SessionUser, token: string | null | undefined) {53  if (!token) return false; const a = Buffer.from(csrfToken(s)); const b = Buffer.from(token); return a.length === b.length && timingSafeEqual(a, b);54}55
Files · laptop-initial-20261102
Menu

Theme