Phase 1-2 of server consolidation + security hardening
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { logger, createLogger } from './logger.js';
|
||||
export { requestLog } from './request-log.js';
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
Reference in New Issue
Block a user