import session from 'express-session'; import connectPgSimple from 'connect-pg-simple'; import bcrypt from 'bcryptjs'; import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { pool, ROLES, type Role } from './db'; // Session-cookie auth for the read UI (username + password), mirroring the Husky // template. Sessions are stored in Postgres (connect-pg-simple) so they survive // restarts and don't leak like MemoryStore. The sync ingest (/api/sync) uses its // own bearer token and is unaffected. declare module 'express-session' { interface SessionData { user?: { username: string; role: Role }; } } const isProd = process.env.NODE_ENV === 'production'; export function sessionMiddleware(): RequestHandler { const secret = process.env.SESSION_SECRET; if (!secret) { if (isProd) { console.error('FATAL: SESSION_SECRET is required in production'); process.exit(1); } console.warn('SESSION_SECRET unset — using an insecure dev fallback'); } const PgStore = connectPgSimple(session); return session({ store: new PgStore({ pool, tableName: 'user_sessions', createTableIfMissing: true }), secret: secret || 'dev-insecure-secret-change-me', resave: false, saveUninitialized: false, name: 'forge.sid', cookie: { httpOnly: true, sameSite: 'lax', secure: isProd, // requires HTTPS in prod (behind the reverse proxy) maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days }, }); } // Gate for the read API. 401 (not 403) so the client drops to the login screen. export function requireAuth(req: Request, res: Response, next: NextFunction): void { if (req.session?.user) return next(); res.status(401).json({ error: 'unauthenticated' }); } // Gate requiring at least `min` role. 401 when unauthenticated (client → login), // 403 when authenticated but under-privileged (permission error, stays put). export function requireRole(min: Role): (req: Request, res: Response, next: NextFunction) => void { const minRank = ROLES.indexOf(min); return (req, res, next) => { const user = req.session?.user; if (!user) return void res.status(401).json({ error: 'unauthenticated' }); if (ROLES.indexOf(user.role) < minRank) return void res.status(403).json({ error: 'forbidden' }); next(); }; } // Verify a username/password against app_users. Returns {username, role} on // success, null on any failure. Constant-time via bcrypt.compare; never logs the password. export async function verifyLogin(username: string, password: string): Promise<{ username: string; role: Role } | null> { if (!username || !password) return null; try { const { rows } = await pool.query<{ username: string; password_hash: string; role: Role }>( 'SELECT username, password_hash, role FROM app_users WHERE username = $1', [username], ); const row = rows[0]; // Compare against a valid (never-matching) hash when the user is unknown, so // the bcrypt work runs either way and login timing doesn't reveal whether the // username exists. const hash = row?.password_hash ?? '$2b$12$D.FzBg5Tc02/Jpq7efzno.0TATE/sQKIX1w4yGFz0K5qI5FRp3Qdu'; const ok = await bcrypt.compare(password, hash); if (!ok || !row) return null; pool.query('UPDATE app_users SET last_login_at = NOW() WHERE username = $1', [row.username]) .catch(err => console.error('last_login_at update failed:', (err as Error).message)); return { username: row.username, role: row.role }; } catch (err) { console.error('verifyLogin error:', (err as Error).message); return null; } }