129 lines
3.6 KiB
JavaScript
129 lines
3.6 KiB
JavaScript
import { extractBearerToken, verifyToken, TokenError } from './verify.js';
|
|
|
|
const USER_CACHE_TTL_MS = 60_000;
|
|
|
|
// Source IPs that bypass token auth — used so the office kiosk can render
|
|
// /small without anyone having to log in daily on the device. Synthetic user
|
|
// has no permissions, so only endpoints that don't gate on requirePermission()
|
|
// are reachable. Requires server.js `trust proxy` setting so req.ip is the
|
|
// real client behind Caddy, not 127.0.0.1.
|
|
function parseKioskIps(raw) {
|
|
return new Set(
|
|
(raw || '')
|
|
.split(',')
|
|
.map((s) => s.trim())
|
|
.filter(Boolean)
|
|
);
|
|
}
|
|
|
|
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, kioskIps = process.env.KIOSK_IPS }) {
|
|
const cache = createUserCache();
|
|
const kioskIpSet = parseKioskIps(kioskIps);
|
|
|
|
return async function authenticateMiddleware(req, res, next) {
|
|
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
|
|
req.user = {
|
|
id: 'kiosk',
|
|
username: 'kiosk',
|
|
is_admin: false,
|
|
is_active: true,
|
|
permissions: [],
|
|
is_kiosk: true,
|
|
};
|
|
return 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' });
|
|
}
|