Files

87 lines
3.6 KiB
TypeScript

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;
}
}