SPB Git

Repository Public

auth.ts 5.4 KB 102 lines typescript

1import { cookies, headers } from "next/headers";2import { redirect } from "next/navigation";3import { randomBytes } from "node:crypto";4import { generateSecret, generateURI, verifySync } from "otplib";5import { sql } from "./db";6import { env } from "./env";7import { sha256 } from "./hash";89export const SESSION_COOKIE = "clip_session";10const IDLE_HOURS = 12, ABSOLUTE_DAYS = 7;11export type Role = "owner" | "editor" | "viewer";12const RANK: Record<Role, number> = { viewer: 1, editor: 2, owner: 3 };1314export interface User { id: string; email: string; name: string; role: Role; must_change_password: boolean; totp_enabled: boolean }15interface SessionRow extends User { mfa_passed: boolean; last_seen_at: Date; created_at: Date; token_hash: string }1617export async function createSession(userId: string, mfaPassed: boolean) {18  const token = randomBytes(32).toString("base64url");19  const h = await headers();20  await sql`INSERT INTO sessions (token_hash, user_id, expires_at, mfa_passed, user_agent)21            VALUES (${sha256(token)}, ${userId}, now() + ${`${ABSOLUTE_DAYS} days`}::interval, ${mfaPassed}, ${(h.get("user-agent") ?? "").slice(0, 200)})`;22  (await cookies()).set(SESSION_COOKIE, token, { httpOnly: true, secure: env.isProd, sameSite: "strict", path: "/", maxAge: ABSOLUTE_DAYS * 86400 });23}2425async function sessionRow(): Promise<SessionRow | null> {26  const token = (await cookies()).get(SESSION_COOKIE)?.value;27  if (!token) return null;28  const [row] = await sql<SessionRow[]>`29    SELECT u.id, u.email, u.name, u.role, u.must_change_password, u.totp_enabled, s.mfa_passed, s.last_seen_at, s.created_at, s.token_hash30    FROM sessions s JOIN users u ON u.id = s.user_id31    WHERE s.token_hash = ${sha256(token)} AND s.expires_at > now() AND s.last_seen_at > now() - ${`${IDLE_HOURS} hours`}::interval`;32  if (!row) return null;33  if (Date.now() - row.last_seen_at.getTime() > 60_000) await sql`UPDATE sessions SET last_seen_at = now() WHERE token_hash = ${row.token_hash}`;34  return row;35}3637/** Session state: `pendingMfa` means the password was accepted but the second factor is still owed. */38export async function sessionState(): Promise<{ user: User | null; pendingMfa: User | null }> {39  const row = await sessionRow();40  if (!row) return { user: null, pendingMfa: null };41  if (row.totp_enabled && !row.mfa_passed) return { user: null, pendingMfa: row };42  return { user: row, pendingMfa: null };43}44export const currentUser = async () => (await sessionState()).user;4546/** Server-side authorization for studio pages and actions. Redirects when not allowed. */47export async function requireRole(min: Role = "viewer", o: { allowPasswordChange?: boolean } = {}): Promise<User> {48  const { user, pendingMfa } = await sessionState();49  if (pendingMfa) redirect("/studio/login?step=mfa");50  if (!user) redirect("/studio/login");51  if (user.must_change_password && !o.allowPasswordChange) redirect("/studio/account?first=1");52  if (RANK[user.role] < RANK[min]) redirect("/studio?denied=1");53  return user;54}55/** Same check for route handlers: returns a Response to send instead of redirecting. */56export async function apiUser(min: Role = "viewer"): Promise<{ user: User; error?: undefined } | { error: Response; user?: undefined }> {57  const user = await currentUser();58  if (!user) return { error: Response.json({ error: "unauthenticated" }, { status: 401 }) };59  if (user.must_change_password) return { error: Response.json({ error: "password_change_required" }, { status: 403 }) };60  if (RANK[user.role] < RANK[min]) return { error: Response.json({ error: "forbidden" }, { status: 403 }) };61  return { user };62}63export const can = (user: User, min: Role) => RANK[user.role] >= RANK[min];6465export async function destroySession() {66  const jar = await cookies();67  const token = jar.get(SESSION_COOKIE)?.value;68  if (token) await sql`DELETE FROM sessions WHERE token_hash = ${sha256(token)}`;69  jar.delete(SESSION_COOKIE);70}71export async function markMfaPassed() {72  const token = (await cookies()).get(SESSION_COOKIE)?.value;73  if (token) await sql`UPDATE sessions SET mfa_passed = true WHERE token_hash = ${sha256(token)}`;74}7576/** CSRF defence for route handlers: a mutating request must come from this origin. */77export function sameOrigin(req: Request): boolean {78  const origin = req.headers.get("origin");79  if (!origin) return req.headers.get("sec-fetch-site") === "same-origin" || !env.isProd;80  try { return new URL(origin).host === (req.headers.get("x-forwarded-host") ?? req.headers.get("host")); } catch { return false; }81}8283export const newTotpSecret = () => generateSecret();84export const totpUri = (email: string, secret: string) => generateURI({ issuer: "CLIP Studio", label: email, secret });85export function verifyTotp(secret: string, token: string): boolean {86  if (!/^\d{6}$/.test(token)) return false;87  try { return verifySync({ secret, token, epochTolerance: 30 }).valid; } catch { return false; }88}8990async function clientIp(): Promise<string> {91  const h = await headers();92  return (h.get("x-forwarded-for") ?? "").split(",")[0].trim() || h.get("x-real-ip") || "0";93}94/** Daily-rotating anonymous visitor hash; the raw IP is never stored. */95export async function visitorHash(): Promise<string> {96  const h = await headers();97  return sha256(`${env.appSecret}|${new Date().toISOString().slice(0, 10)}|${await clientIp()}|${h.get("user-agent") ?? ""}`).slice(0, 32);98}99export async function clientIpHash(): Promise<string> {100  return sha256(`${env.appSecret}|ip|${await clientIp()}`).slice(0, 24);101}102
Files main
Menu

Theme