Initial import of Forge app
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user