Phase 1-2 of server consolidation + security hardening

This commit is contained in:
2026-05-23 17:27:22 -04:00
parent 36f23b527e
commit 1ab14ba45f
46 changed files with 1103 additions and 6826 deletions
+101
View File
@@ -0,0 +1,101 @@
import { extractBearerToken, verifyToken, TokenError } from './verify.js';
const USER_CACHE_TTL_MS = 60_000;
function createUserCache() {
const entries = new Map();
return {
get(userId) {
const hit = entries.get(userId);
if (!hit) return null;
if (Date.now() - hit.cachedAt > USER_CACHE_TTL_MS) {
entries.delete(userId);
return null;
}
return hit.user;
},
set(userId, user) {
entries.set(userId, { user, cachedAt: Date.now() });
},
invalidate(userId) {
entries.delete(userId);
},
};
}
async function loadUser(pool, userId) {
const userResult = await pool.query(
'SELECT id, username, email, is_admin, is_active FROM users WHERE id = $1',
[userId]
);
const user = userResult.rows[0];
if (!user) return null;
if (user.is_admin) {
user.permissions = [];
return user;
}
const permResult = await pool.query(
`SELECT p.code
FROM permissions p
JOIN user_permissions up ON p.id = up.permission_id
WHERE up.user_id = $1`,
[userId]
);
user.permissions = permResult.rows.map((r) => r.code);
return user;
}
export function authenticate({ pool, secret = process.env.JWT_SECRET }) {
const cache = createUserCache();
return async function authenticateMiddleware(req, res, next) {
let decoded;
try {
const token = extractBearerToken(req.headers.authorization);
decoded = verifyToken(token, secret);
} catch (err) {
if (err instanceof TokenError) {
return res.status(401).json({ error: err.message });
}
return res.status(401).json({ error: 'Authentication required' });
}
try {
let user = cache.get(decoded.userId);
if (!user) {
user = await loadUser(pool, decoded.userId);
if (!user) {
return res.status(401).json({ error: 'User not found' });
}
cache.set(decoded.userId, user);
}
if (!user.is_active) {
cache.invalidate(decoded.userId);
return res.status(403).json({ error: 'Account inactive' });
}
req.user = user;
next();
} catch (err) {
next(err);
}
};
}
export function requirePermission(code) {
return function requirePermissionMiddleware(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (req.user.is_admin) return next();
if (Array.isArray(req.user.permissions) && req.user.permissions.includes(code)) {
return next();
}
res.status(403).json({ error: 'Insufficient permissions' });
};
}
export function requireAdmin(req, res, next) {
if (!req.user) return res.status(401).json({ error: 'Authentication required' });
if (req.user.is_admin) return next();
res.status(403).json({ error: 'Admin only' });
}
+37
View File
@@ -0,0 +1,37 @@
import jwt from 'jsonwebtoken';
export class TokenError extends Error {
constructor(message, code) {
super(message);
this.name = 'TokenError';
this.code = code;
}
}
export function extractBearerToken(authorizationHeader) {
if (!authorizationHeader || typeof authorizationHeader !== 'string') {
throw new TokenError('No token provided', 'missing');
}
if (!authorizationHeader.startsWith('Bearer ')) {
throw new TokenError('Malformed Authorization header', 'malformed');
}
const token = authorizationHeader.slice(7).trim();
if (!token) {
throw new TokenError('Empty bearer token', 'malformed');
}
return token;
}
export function verifyToken(token, secret) {
if (!secret) {
throw new TokenError('JWT_SECRET not configured', 'misconfigured');
}
try {
return jwt.verify(token, secret);
} catch (err) {
if (err.name === 'TokenExpiredError') {
throw new TokenError('Token expired', 'expired');
}
throw new TokenError('Invalid token', 'invalid');
}
}