267 lines
6.3 KiB
JavaScript
267 lines
6.3 KiB
JavaScript
import crypto from 'crypto';
|
|
|
|
const SESSION_COOKIE_NAME = 'oc_ui_session';
|
|
const SESSION_TTL_MS = 12 * 60 * 60 * 1000;
|
|
const CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
|
|
|
|
const isSecureRequest = (req) => {
|
|
if (req.secure) {
|
|
return true;
|
|
}
|
|
const forwardedProto = req.headers['x-forwarded-proto'];
|
|
if (typeof forwardedProto === 'string') {
|
|
const firstProto = forwardedProto.split(',')[0]?.trim().toLowerCase();
|
|
return firstProto === 'https';
|
|
}
|
|
return false;
|
|
};
|
|
|
|
const parseCookies = (cookieHeader) => {
|
|
if (!cookieHeader || typeof cookieHeader !== 'string') {
|
|
return {};
|
|
}
|
|
|
|
return cookieHeader.split(';').reduce((acc, segment) => {
|
|
const [name, ...rest] = segment.split('=');
|
|
if (!name) {
|
|
return acc;
|
|
}
|
|
const key = name.trim();
|
|
if (!key) {
|
|
return acc;
|
|
}
|
|
const value = rest.join('=').trim();
|
|
acc[key] = decodeURIComponent(value || '');
|
|
return acc;
|
|
}, {});
|
|
};
|
|
|
|
const buildCookie = ({
|
|
name,
|
|
value,
|
|
maxAge,
|
|
secure,
|
|
}) => {
|
|
const attributes = [
|
|
`${name}=${value}`,
|
|
'Path=/',
|
|
'HttpOnly',
|
|
'SameSite=Strict',
|
|
];
|
|
|
|
if (typeof maxAge === 'number') {
|
|
attributes.push(`Max-Age=${Math.max(0, Math.floor(maxAge))}`);
|
|
}
|
|
|
|
const expires = maxAge === 0
|
|
? 'Thu, 01 Jan 1970 00:00:00 GMT'
|
|
: new Date(Date.now() + maxAge * 1000).toUTCString();
|
|
|
|
attributes.push(`Expires=${expires}`);
|
|
|
|
if (secure) {
|
|
attributes.push('Secure');
|
|
}
|
|
|
|
return attributes.join('; ');
|
|
};
|
|
|
|
const normalizePassword = (candidate) => {
|
|
if (typeof candidate !== 'string') {
|
|
return '';
|
|
}
|
|
return candidate.normalize().trim();
|
|
};
|
|
|
|
export const createUiAuth = ({
|
|
password,
|
|
cookieName = SESSION_COOKIE_NAME,
|
|
sessionTtlMs = SESSION_TTL_MS,
|
|
} = {}) => {
|
|
const normalizedPassword = normalizePassword(password);
|
|
|
|
if (!normalizedPassword) {
|
|
return {
|
|
enabled: false,
|
|
requireAuth: (_req, _res, next) => next(),
|
|
handleSessionStatus: (_req, res) => {
|
|
res.json({ authenticated: true, disabled: true });
|
|
},
|
|
handleSessionCreate: (_req, res) => {
|
|
res.status(400).json({ error: 'UI password not configured' });
|
|
},
|
|
dispose: () => {
|
|
|
|
},
|
|
};
|
|
}
|
|
|
|
const salt = crypto.randomBytes(16);
|
|
const expectedHash = crypto.scryptSync(normalizedPassword, salt, 64);
|
|
const sessions = new Map();
|
|
|
|
let cleanupTimer = null;
|
|
|
|
const getTokenFromRequest = (req) => {
|
|
const cookies = parseCookies(req.headers.cookie);
|
|
if (cookies[cookieName]) {
|
|
return cookies[cookieName];
|
|
}
|
|
return null;
|
|
};
|
|
|
|
const dropSession = (token) => {
|
|
if (token) {
|
|
sessions.delete(token);
|
|
}
|
|
};
|
|
|
|
const setSessionCookie = (req, res, token) => {
|
|
const secure = isSecureRequest(req);
|
|
const maxAgeSeconds = Math.floor(sessionTtlMs / 1000);
|
|
const header = buildCookie({
|
|
name: cookieName,
|
|
value: encodeURIComponent(token),
|
|
maxAge: maxAgeSeconds,
|
|
secure,
|
|
});
|
|
res.setHeader('Set-Cookie', header);
|
|
};
|
|
|
|
const clearSessionCookie = (req, res) => {
|
|
const secure = isSecureRequest(req);
|
|
const header = buildCookie({
|
|
name: cookieName,
|
|
value: '',
|
|
maxAge: 0,
|
|
secure,
|
|
});
|
|
res.setHeader('Set-Cookie', header);
|
|
};
|
|
|
|
const verifyPassword = (candidate) => {
|
|
if (!candidate) {
|
|
return false;
|
|
}
|
|
const normalizedCandidate = normalizePassword(candidate);
|
|
if (!normalizedCandidate) {
|
|
return false;
|
|
}
|
|
try {
|
|
const candidateHash = crypto.scryptSync(normalizedCandidate, salt, 64);
|
|
return crypto.timingSafeEqual(candidateHash, expectedHash);
|
|
} catch {
|
|
return false;
|
|
}
|
|
};
|
|
|
|
const isSessionValid = (token) => {
|
|
if (!token) {
|
|
return false;
|
|
}
|
|
const record = sessions.get(token);
|
|
if (!record) {
|
|
return false;
|
|
}
|
|
if (Date.now() - record.lastSeen > sessionTtlMs) {
|
|
sessions.delete(token);
|
|
return false;
|
|
}
|
|
record.lastSeen = Date.now();
|
|
return true;
|
|
};
|
|
|
|
const issueSession = (req, res) => {
|
|
const token = crypto.randomBytes(32).toString('base64url');
|
|
const now = Date.now();
|
|
sessions.set(token, { createdAt: now, lastSeen: now });
|
|
setSessionCookie(req, res, token);
|
|
return token;
|
|
};
|
|
|
|
const cleanupStaleSessions = () => {
|
|
const now = Date.now();
|
|
for (const [token, record] of sessions.entries()) {
|
|
if (now - record.lastSeen > sessionTtlMs) {
|
|
sessions.delete(token);
|
|
}
|
|
}
|
|
};
|
|
|
|
const startCleanup = () => {
|
|
if (!cleanupTimer) {
|
|
cleanupTimer = setInterval(cleanupStaleSessions, CLEANUP_INTERVAL_MS);
|
|
if (cleanupTimer && typeof cleanupTimer.unref === 'function') {
|
|
cleanupTimer.unref();
|
|
}
|
|
}
|
|
};
|
|
|
|
startCleanup();
|
|
|
|
const respondUnauthorized = (req, res) => {
|
|
res.status(401);
|
|
const acceptsJson = req.headers.accept?.includes('application/json');
|
|
if (acceptsJson || req.path.startsWith('/api')) {
|
|
res.json({ error: 'UI authentication required', locked: true });
|
|
} else {
|
|
res.type('text/plain').send('Authentication required');
|
|
}
|
|
};
|
|
|
|
const requireAuth = (req, res, next) => {
|
|
if (req.method === 'OPTIONS') {
|
|
return next();
|
|
}
|
|
const token = getTokenFromRequest(req);
|
|
if (isSessionValid(token)) {
|
|
return next();
|
|
}
|
|
clearSessionCookie(req, res);
|
|
return respondUnauthorized(req, res);
|
|
};
|
|
|
|
const handleSessionStatus = (req, res) => {
|
|
const token = getTokenFromRequest(req);
|
|
if (isSessionValid(token)) {
|
|
res.json({ authenticated: true });
|
|
return;
|
|
}
|
|
clearSessionCookie(req, res);
|
|
res.status(401).json({ authenticated: false, locked: true });
|
|
};
|
|
|
|
const handleSessionCreate = (req, res) => {
|
|
const candidate = typeof req.body?.password === 'string' ? req.body.password : '';
|
|
if (!verifyPassword(candidate)) {
|
|
clearSessionCookie(req, res);
|
|
res.status(401).json({ error: 'Invalid password', locked: true });
|
|
return;
|
|
}
|
|
|
|
const previousToken = getTokenFromRequest(req);
|
|
if (previousToken) {
|
|
dropSession(previousToken);
|
|
}
|
|
|
|
issueSession(req, res);
|
|
res.json({ authenticated: true });
|
|
};
|
|
|
|
const dispose = () => {
|
|
if (cleanupTimer) {
|
|
clearInterval(cleanupTimer);
|
|
cleanupTimer = null;
|
|
}
|
|
sessions.clear();
|
|
};
|
|
|
|
return {
|
|
enabled: true,
|
|
requireAuth,
|
|
handleSessionStatus,
|
|
handleSessionCreate,
|
|
dispose,
|
|
};
|
|
};
|