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');
}
}
+24
View File
@@ -0,0 +1,24 @@
export const allowedOrigins = [
'https://tools.acherryontop.com',
'https://inventory.kent.pw',
'https://acot.site',
/^http:\/\/localhost:(5174|5175)$/,
];
export const corsOptions = {
origin(origin, callback) {
if (!origin) return callback(null, true);
const ok = allowedOrigins.some((allowed) =>
allowed instanceof RegExp ? allowed.test(origin) : allowed === origin
);
if (ok) return callback(null, true);
callback(new Error('CORS not allowed'));
},
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'],
exposedHeaders: ['Content-Type'],
credentials: true,
maxAge: 600,
};
export default corsOptions;
+19
View File
@@ -0,0 +1,19 @@
import pg from 'pg';
const { Pool } = pg;
export function createPool(envPrefix = 'DB', overrides = {}) {
const get = (key) => process.env[`${envPrefix}_${key}`];
return new Pool({
host: overrides.host ?? get('HOST'),
user: overrides.user ?? get('USER'),
password: overrides.password ?? get('PASSWORD'),
database: overrides.database ?? get('NAME'),
port: overrides.port ?? Number(get('PORT')) || 5432,
ssl: (overrides.ssl ?? get('SSL')) === 'true' ? { rejectUnauthorized: false } : false,
max: overrides.max ?? 20,
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
});
}
+27
View File
@@ -0,0 +1,27 @@
import Redis from 'ioredis';
export function createRedis(overrides = {}) {
const url = overrides.url ?? process.env.REDIS_URL;
const options = {
lazyConnect: true,
maxRetriesPerRequest: 3,
enableOfflineQueue: false,
retryStrategy(times) {
return Math.min(times * 200, 2_000);
},
...overrides,
};
if (url) {
return new Redis(url, options);
}
return new Redis({
host: overrides.host ?? process.env.REDIS_HOST ?? 'localhost',
port: overrides.port ?? Number(process.env.REDIS_PORT) || 6379,
username: overrides.username ?? process.env.REDIS_USERNAME,
password: overrides.password ?? process.env.REDIS_PASSWORD,
...options,
});
}
+18
View File
@@ -0,0 +1,18 @@
import { logger } from '../logging/logger.js';
export function errorHandler(err, req, res, _next) {
const status = err.status ?? err.statusCode ?? 500;
logger.error({
err: { message: err.message, stack: err.stack, code: err.code },
method: req.method,
url: req.url,
userId: req.user?.id,
}, 'request failed');
const body = { error: status >= 500 ? 'Internal server error' : err.message };
if (process.env.NODE_ENV !== 'production' && status >= 500) {
body.detail = err.message;
}
res.status(status).json(body);
}
+2
View File
@@ -0,0 +1,2 @@
export { logger, createLogger } from './logger.js';
export { requestLog } from './request-log.js';
+27
View File
@@ -0,0 +1,27 @@
import { pino } from 'pino';
const REDACTED_PATHS = [
'req.headers.authorization',
'req.headers.cookie',
'headers.authorization',
'headers.cookie',
'*.password',
'*.token',
'*.jwt',
];
export function createLogger(options = {}) {
return pino({
level: process.env.LOG_LEVEL ?? 'info',
redact: {
paths: REDACTED_PATHS,
censor: '[REDACTED]',
},
base: {
service: options.service ?? process.env.SERVICE_NAME ?? 'inventory',
},
...options,
});
}
export const logger = createLogger();
@@ -0,0 +1,32 @@
import { pinoHttp } from 'pino-http';
import { logger } from './logger.js';
export function requestLog(options = {}) {
return pinoHttp({
logger,
customLogLevel(req, res, err) {
if (err || res.statusCode >= 500) return 'error';
if (res.statusCode >= 400) return 'warn';
return 'info';
},
customSuccessMessage(req, res) {
return `${req.method} ${req.url} ${res.statusCode}`;
},
customErrorMessage(req, res, err) {
return `${req.method} ${req.url} ${res.statusCode} ${err?.message ?? ''}`;
},
serializers: {
req(req) {
return {
method: req.method,
url: req.url,
userId: req.raw?.user?.id,
};
},
res(res) {
return { statusCode: res.statusCode };
},
},
...options,
});
}
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@inventory/shared",
"version": "1.0.0",
"description": "Shared modules used by inventory-server, auth-server, dashboard-server, and acot-server",
"type": "module",
"private": true,
"exports": {
"./auth/middleware": "./auth/middleware.js",
"./auth/verify": "./auth/verify.js",
"./db/pg": "./db/pg.js",
"./db/redis": "./db/redis.js",
"./logging/logger": "./logging/logger.js",
"./logging/request-log": "./logging/request-log.js",
"./logging": "./logging/index.js",
"./errors/handler": "./errors/handler.js",
"./cors/policy": "./cors/policy.js",
"./rate-limit/login": "./rate-limit/login.js"
},
"dependencies": {
"cors": "^2.8.5",
"express-rate-limit": "^7.4.0",
"ioredis": "^5.4.0",
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pino": "^9.5.0",
"pino-http": "^10.3.0"
}
}
@@ -0,0 +1,17 @@
import rateLimit from 'express-rate-limit';
export const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
message: { error: 'Too many login attempts, try again later' },
standardHeaders: true,
legacyHeaders: false,
});
export const verifyLimiter = rateLimit({
windowMs: 60 * 1000,
max: 600,
message: { error: 'Too many requests' },
standardHeaders: true,
legacyHeaders: false,
});