// Author: Simon-Pierre Boucher // Contact: contact@spboucher.ai // Project: chat.spboucher.ai import { hash as argon2Hash, verify as argon2Verify } from "@node-rs/argon2"; import crypto from "node:crypto"; import { getDb } from "@/lib/db/database"; export const SESSION_COOKIE = "spb_session"; const SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 30; // 30 days const LOGIN_WINDOW_MS = 1000 * 60 * 15; const MAX_ATTEMPTS_PER_WINDOW = 8; // argon2id with sane defaults for a single-user server const ARGON2_OPTS = { memoryCost: 65536, timeCost: 3, parallelism: 2 }; export interface SessionInfo { userId: string; username: string; sessionId: string; } function sha256(input: string): string { return crypto.createHash("sha256").update(input).digest("hex"); } export async function hashPassword(password: string): Promise { return argon2Hash(password, ARGON2_OPTS); } export function loginRateLimited(ip: string): boolean { const db = getDb(); const since = Date.now() - LOGIN_WINDOW_MS; const row = db .prepare( "SELECT COUNT(*) AS n FROM login_attempts WHERE ip = ? AND created_at > ? AND success = 0" ) .get(ip, since) as { n: number }; return row.n >= MAX_ATTEMPTS_PER_WINDOW; } export function recordLoginAttempt(ip: string, success: boolean): void { const db = getDb(); db.prepare("INSERT INTO login_attempts (ip, success, created_at) VALUES (?, ?, ?)").run( ip, success ? 1 : 0, Date.now() ); // opportunistic pruning db.prepare("DELETE FROM login_attempts WHERE created_at < ?").run(Date.now() - LOGIN_WINDOW_MS * 8); } export async function verifyCredentials( username: string, password: string ): Promise<{ id: string; username: string } | null> { const db = getDb(); const user = db .prepare("SELECT id, username, password_hash FROM users WHERE username = ?") .get(username) as { id: string; username: string; password_hash: string } | undefined; if (!user) { // constant-ish time: still run a hash verification against a dummy await argon2Verify( "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", password ).catch(() => false); return null; } const ok = await argon2Verify(user.password_hash, password).catch(() => false); return ok ? { id: user.id, username: user.username } : null; } export function createSession(userId: string, userAgent?: string, ip?: string): string { const db = getDb(); const rawToken = crypto.randomBytes(32).toString("base64url"); const now = Date.now(); db.prepare( `INSERT INTO sessions (id, user_id, created_at, expires_at, last_seen_at, user_agent, ip) VALUES (?, ?, ?, ?, ?, ?, ?)` ).run(sha256(rawToken), userId, now, now + SESSION_TTL_MS, now, userAgent ?? null, ip ?? null); db.prepare("DELETE FROM sessions WHERE expires_at < ?").run(now); return rawToken; } export function getSession(rawToken: string | undefined): SessionInfo | null { if (!rawToken) return null; const db = getDb(); const now = Date.now(); const row = db .prepare( `SELECT s.id AS session_id, u.id AS user_id, u.username FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.id = ? AND s.expires_at > ?` ) .get(sha256(rawToken), now) as | { session_id: string; user_id: string; username: string } | undefined; if (!row) return null; db.prepare("UPDATE sessions SET last_seen_at = ?, expires_at = ? WHERE id = ?").run( now, now + SESSION_TTL_MS, row.session_id ); return { userId: row.user_id, username: row.username, sessionId: row.session_id }; } export function destroySession(rawToken: string | undefined): void { if (!rawToken) return; getDb().prepare("DELETE FROM sessions WHERE id = ?").run(sha256(rawToken)); } export function sessionCookieHeader(rawToken: string, maxAgeSeconds: number): string { return [ `${SESSION_COOKIE}=${rawToken}`, "Path=/", "HttpOnly", "Secure", "SameSite=Lax", `Max-Age=${maxAgeSeconds}`, ].join("; "); }