import crypto from "node:crypto"; import type { NextFunction, Request, Response } from "express"; import { config } from "./config.ts"; import { blockedFor, recordFailure, recordSuccess } from "./throttle.ts"; const COOKIE_NAME = "pub_repo_session"; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Express { interface Request { isOwner: boolean; } } } function timingSafeEqual(a: string, b: string): boolean { const bufA = Buffer.from(a, "utf8"); const bufB = Buffer.from(b, "utf8"); // Even lang maken zodat de vergelijking zelf niets over de lengte verklapt. const length = Math.max(bufA.length, bufB.length, 32); const padA = Buffer.alloc(length); const padB = Buffer.alloc(length); bufA.copy(padA); bufB.copy(padB); return crypto.timingSafeEqual(padA, padB) && bufA.length === bufB.length; } /** * Gebruikersnaam én wachtwoord worden gecontroleerd, en altijd beide, zodat de * duur van de vergelijking niet verklapt welke van de twee fout was. */ export function checkOwnerCredentials(user: string, password: string): boolean { const userOk = timingSafeEqual(user, config.ownerUser); const passwordOk = timingSafeEqual(password, config.ownerToken); return userOk && passwordOk; } function sign(payload: string): string { return crypto.createHmac("sha256", config.sessionSecret).update(payload).digest("base64url"); } export function createSessionCookie(): string { const expires = Date.now() + SESSION_TTL_MS; const payload = String(expires); const value = `${payload}.${sign(payload)}`; const secure = config.publicUrl.startsWith("https://") ? "; Secure" : ""; const maxAge = Math.floor(SESSION_TTL_MS / 1000); return `${COOKIE_NAME}=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure}`; } export function clearSessionCookie(): string { return `${COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0`; } function parseCookies(header: string | undefined): Map { const jar = new Map(); if (!header) return jar; for (const part of header.split(";")) { const eq = part.indexOf("="); if (eq === -1) continue; jar.set(part.slice(0, eq).trim(), decodeURIComponent(part.slice(eq + 1).trim())); } return jar; } function hasValidSession(req: Request): boolean { const raw = parseCookies(req.headers.cookie).get(COOKIE_NAME); if (!raw) return false; const dot = raw.lastIndexOf("."); if (dot === -1) return false; const payload = raw.slice(0, dot); const signature = raw.slice(dot + 1); if (!timingSafeEqual(signature, sign(payload))) return false; const expires = Number.parseInt(payload, 10); return Number.isFinite(expires) && expires > Date.now(); } function basicAuth(req: Request): { user: string; password: string } | null { const header = req.headers.authorization; if (!header?.startsWith("Basic ")) return null; const decoded = Buffer.from(header.slice(6), "base64").toString("utf8"); const colon = decoded.indexOf(":"); if (colon === -1) return null; return { user: decoded.slice(0, colon), password: decoded.slice(colon + 1) }; } /** * Zet `req.isOwner` voor elke request, op basis van cookie óf Basic auth, en * weigert afzenders die te vaak verkeerd geraden hebben. */ export function identify(req: Request, res: Response, next: NextFunction): void { const key = throttleKey(req); const credentials = basicAuth(req); if (credentials !== null) { // De rem geldt alleen voor wie inloggegevens meestuurt. Wie gewoon een publieke // repo bekijkt of cloont merkt hier niets van, ook niet als hij achter hetzelfde // IP zit als iemand die aan het raden was. if (tooManyAttempts(res, key)) return; if (checkOwnerCredentials(credentials.user, credentials.password)) { recordSuccess(key); req.isOwner = true; next(); return; } recordFailure(key); } req.isOwner = hasValidSession(req); next(); } export function throttleKey(req: Request): string { return req.ip ?? "onbekend"; } /** Stuurt zelf een 429 en geeft `true` terug als er niets meer mag. */ export function tooManyAttempts(res: Response, key: string): boolean { const wait = blockedFor(key); if (wait === 0) return false; const seconds = Math.ceil(wait / 1000); res.setHeader("Retry-After", String(seconds)); res .status(429) .type("text/plain") .send(`Te veel mislukte inlogpogingen. Probeer het over ${seconds}s opnieuw.\n`); return true; } export function requireOwnerWeb(req: Request, res: Response, next: NextFunction): void { if (req.isOwner) return next(); const target = encodeURIComponent(req.originalUrl); res.redirect(`/login?next=${target}`); } /** Voor git-clients: 401 met WWW-Authenticate, zodat git om inloggegevens vraagt. */ export function requireOwnerGit(res: Response): void { res.setHeader("WWW-Authenticate", 'Basic realm="pub-repo", charset="UTF-8"'); res.status(401).type("text/plain").send("Authenticatie vereist.\n"); }