import { randomUUID, randomBytes, scryptSync, timingSafeEqual } from 'crypto'; import { NextFunction, Request, Response } from 'express'; import { eq, lt, sql } from 'drizzle-orm'; import { db } from './db/client'; import { users, authSessions } from './db/schema'; // User-account session auth (see memory-bank/FEATURE_User_Accounts.md). // // Replaces the original single shared-password mode (AUTH_PASSWORD env var, // in-memory session Set): accounts live in the `users` table, sessions in // `auth_sessions` (so a server restart no longer signs everyone out), and the // first account is created through POST /api/auth/setup while the users table // is empty. Auth is always on once a user exists — there is no open mode. const COOKIE_NAME = 'mkb_session'; const SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days // Flat pause on a failed login — cheap brute-force damping without the // bookkeeping of per-IP counters (single-family LAN app). const FAILED_LOGIN_DELAY_MS = 350; export interface SessionUser { id: number; username: string; } // requireAuth attaches the session's user here; protected routes can cast. export interface AuthedRequest extends Request { user?: SessionUser; } // --- Password hashing (node:crypto scrypt — no native dependency) --- export function hashPassword(password: string): string { const salt = randomBytes(16); const hash = scryptSync(password, salt, 64); return `scrypt:${salt.toString('hex')}:${hash.toString('hex')}`; } export function verifyPassword(password: string, stored: string): boolean { const [scheme, saltHex, hashHex] = stored.split(':'); if (scheme !== 'scrypt' || !saltHex || !hashHex) return false; const expected = Buffer.from(hashHex, 'hex'); const actual = scryptSync(password, Buffer.from(saltHex, 'hex'), expected.length); return timingSafeEqual(actual, expected); } // --- Users --- export function anyUsersExist(): boolean { return db.select({ id: users.id }).from(users).limit(1).get() !== undefined; } // Case-insensitive lookup: the schema's UNIQUE index is case-sensitive, so both // login and the duplicate-username guard go through lower() instead. export function findUserByUsername(username: string) { return db .select() .from(users) .where(sql`lower(${users.username}) = lower(${username})`) .get(); } export function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } export const failedLoginDelayMs = FAILED_LOGIN_DELAY_MS; export const MIN_PASSWORD_LENGTH = 8; // Shared by POST /api/auth/setup and POST /api/users — returns an error message // for the 400 response, or null when the pair is acceptable. export function validateNewCredentials(username: unknown, password: unknown): string | null { if (typeof username !== 'string' || username.trim().length === 0) { return 'Username is required'; } if (username.trim().length > 64) { return 'Username is too long (max 64 characters)'; } if (typeof password !== 'string' || password.length < MIN_PASSWORD_LENGTH) { return `Password must be at least ${MIN_PASSWORD_LENGTH} characters`; } return null; } // --- Cookie / session plumbing --- function parseCookies(header: string | undefined): Record { const out: Record = {}; if (!header) return out; for (const part of header.split(';')) { const eq = part.indexOf('='); if (eq === -1) continue; const key = part.slice(0, eq).trim(); if (key) out[key] = decodeURIComponent(part.slice(eq + 1).trim()); } return out; } export function readSessionToken(req: Request): string | undefined { return parseCookies(req.headers.cookie)[COOKIE_NAME]; } export function createSession(res: Response, userId: number): void { const token = randomUUID(); const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString(); db.insert(authSessions).values({ token, userId, expiresAt }).run(); res.cookie(COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: SESSION_TTL_MS, }); } export function destroySession(req: Request, res: Response): void { const token = readSessionToken(req); if (token) db.delete(authSessions).where(eq(authSessions.token, token)).run(); res.clearCookie(COOKIE_NAME, { path: '/' }); } // Resolve the request's session to its user, lazily purging expired sessions. // ISO strings compare lexicographically, so `lt` against now-ISO is correct. export function getSessionUser(req: Request): SessionUser | null { const token = readSessionToken(req); if (!token) return null; db.delete(authSessions).where(lt(authSessions.expiresAt, new Date().toISOString())).run(); const row = db .select({ id: users.id, username: users.username }) .from(authSessions) .innerJoin(users, eq(authSessions.userId, users.id)) .where(eq(authSessions.token, token)) .get(); return row ?? null; } export function requireAuth(req: Request, res: Response, next: NextFunction) { const user = getSessionUser(req); if (!user) { res.status(401).json({ error: 'Unauthorized' }); return; } (req as AuthedRequest).user = user; next(); } export function logAuthStatus() { if (process.env.AUTH_PASSWORD) { console.warn( 'Auth: AUTH_PASSWORD is set but no longer used — auth is account-based now. ' + 'Remove MYKINDLEBOX_PASSWORD/AUTH_PASSWORD from your environment.' ); } if (anyUsersExist()) { console.log('Auth: user accounts active.'); } else { console.log('Auth: no users yet — the app will show the first-run setup screen.'); } }