SPB Git
6.4 KB · 136 lines typescript
1import "server-only";2import { createHash, randomBytes, timingSafeEqual, createHmac } from "node:crypto";3import { hash as argonHash, verify as argonVerify } from "@node-rs/argon2";4import { cookies, headers } from "next/headers";5import { sql } from "./db";6import { env } from "./env";78export const SESSION_COOKIE = "csa_session";9const ARGON = { memoryCost: 65536, timeCost: 3, parallelism: 2, algorithm: 2 as const }; // argon2id, 64 MiB1011export async function hashPassword(pw: string): Promise<string> {12  return argonHash(pw, ARGON);13}14export async function verifyPassword(hash: string, pw: string): Promise<boolean> {15  try { return await argonVerify(hash, pw); } catch { return false; }16}1718function tokenId(token: string): string {19  return createHash("sha256").update(token).digest("hex");20}2122export interface SessionInfo { id: string; createdAt: Date; expiresAt: Date; lastSeen: Date; userAgent: string | null; ip: string | null; current: boolean }2324export async function ownerExists(): Promise<boolean> {25  const r = await sql`SELECT 1 FROM owner WHERE id = 1`;26  return r.length > 0;27}2829// ---------------------------------------------------------------- rate limiting30const MAX_FAILS = 5; const WINDOW_MIN = 15;3132export async function loginAllowed(ip: string): Promise<{ ok: boolean; retryAfterSec?: number }> {33  const rows = await sql<{ n: number; last: Date | null }[]>`34    SELECT count(*)::int AS n, max(at) AS last FROM login_attempts35    WHERE success = false AND at > now() - make_interval(mins => ${WINDOW_MIN}) AND (ip = ${ip} OR ${ip} = '*')`;36  const global = await sql<{ n: number }[]>`SELECT count(*)::int AS n FROM login_attempts WHERE success = false AND at > now() - interval '15 minutes'`;37  if (rows[0].n >= MAX_FAILS || global[0].n >= MAX_FAILS * 4) {38    const last = rows[0].last ? new Date(rows[0].last).getTime() : Date.now();39    return { ok: false, retryAfterSec: Math.max(30, Math.ceil((last + WINDOW_MIN * 60_000 - Date.now()) / 1000)) };40  }41  return { ok: true };42}4344export async function recordAttempt(ip: string, success: boolean) {45  await sql`INSERT INTO login_attempts (ip, success) VALUES (${ip}, ${success})`;46  if (success) await sql`DELETE FROM login_attempts WHERE ip = ${ip}`;47}4849// ---------------------------------------------------------------- sessions50export async function createSession(meta: { userAgent?: string | null; ip?: string | null }): Promise<string> {51  const token = randomBytes(32).toString("base64url");52  const expires = new Date(Date.now() + env.SESSION_TTL_DAYS * 86_400_000);53  await sql`INSERT INTO sessions (id, expires_at, user_agent, ip) VALUES (${tokenId(token)}, ${expires}, ${meta.userAgent ?? null}, ${meta.ip ?? null})`;54  // keep 5 min cleanup cheap55  await sql`DELETE FROM sessions WHERE expires_at < now() - interval '7 days'`;56  return token;57}5859export async function setSessionCookie(token: string) {60  const c = await cookies();61  c.set(SESSION_COOKIE, token, {62    httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/",63    maxAge: env.SESSION_TTL_DAYS * 86_400,64  });65}6667export async function clearSessionCookie() {68  const c = await cookies();69  c.set(SESSION_COOKIE, "", { httpOnly: true, sameSite: "strict", secure: env.NODE_ENV === "production", path: "/", maxAge: 0 });70}7172/** Returns the session id if the request carries a valid, non-revoked, non-expired session. */73export async function getSession(): Promise<{ id: string } | null> {74  const c = await cookies();75  const token = c.get(SESSION_COOKIE)?.value;76  if (!token) return null;77  const id = tokenId(token);78  const rows = await sql<{ id: string; stale: boolean }[]>`79    SELECT id, (last_seen < now() - interval '10 minutes') AS stale FROM sessions80    WHERE id = ${id} AND revoked_at IS NULL AND expires_at > now()`;81  if (!rows.length) return null;82  if (rows[0].stale) await sql`UPDATE sessions SET last_seen = now() WHERE id = ${id}`;83  return { id };84}8586export async function requireSession(): Promise<{ id: string }> {87  const s = await getSession();88  if (!s) throw new AuthError();89  return s;90}9192export class AuthError extends Error { status = 401; constructor() { super("Unauthorized"); } }9394export async function revokeSession(id: string) {95  await sql`UPDATE sessions SET revoked_at = now() WHERE id = ${id}`;96}97export async function revokeAllSessions(exceptId?: string) {98  await sql`UPDATE sessions SET revoked_at = now() WHERE revoked_at IS NULL AND id <> ${exceptId ?? ""}`;99}100export async function listSessions(currentId: string): Promise<SessionInfo[]> {101  const rows = await sql`SELECT id, created_at, expires_at, last_seen, user_agent, ip FROM sessions WHERE revoked_at IS NULL AND expires_at > now() ORDER BY last_seen DESC`;102  return rows.map(r => ({ id: r.id, createdAt: r.created_at, expiresAt: r.expires_at, lastSeen: r.last_seen, userAgent: r.user_agent, ip: r.ip, current: r.id === currentId }));103}104105// ---------------------------------------------------------------- CSRF (origin check for mutating requests)106export async function assertSameOrigin(): Promise<void> {107  const h = await headers();108  const origin = h.get("origin"); const host = h.get("x-forwarded-host") ?? h.get("host");109  if (!origin) return; // same-origin fetch without Origin header (older UAs) — cookie is SameSite=Strict anyway110  const o = new URL(origin).host;111  if (host && o !== host) throw Object.assign(new Error("Cross-origin request rejected"), { status: 403 });112}113114export async function clientIp(): Promise<string> {115  const h = await headers();116  return (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || h.get("x-real-ip") || "local";117}118119/** Signed, short-lived token for one-time URLs (e.g. file download links in <img src>). */120export function signToken(payload: string, ttlSec = 3600): string {121  const exp = Math.floor(Date.now() / 1000) + ttlSec;122  const body = `${payload}.${exp}`;123  const sig = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url");124  return Buffer.from(body).toString("base64url") + "." + sig;125}126export function verifyToken(token: string): string | null {127  const [b, sig] = token.split(".");128  if (!b || !sig) return null;129  const body = Buffer.from(b, "base64url").toString();130  const expect = createHmac("sha256", env.SESSION_SECRET).update(body).digest("base64url");131  if (expect.length !== sig.length || !timingSafeEqual(Buffer.from(expect), Buffer.from(sig))) return null;132  const i = body.lastIndexOf("."); const exp = Number(body.slice(i + 1));133  if (!exp || exp < Date.now() / 1000) return null;134  return body.slice(0, i);135}136
Files · laptop-build-20261101
Menu

Theme