192 lines
6.7 KiB
JavaScript
192 lines
6.7 KiB
JavaScript
// Session cookie and current-user helpers for the web auth flow.
|
|
|
|
const { normalizePermissionKeys } = require('#src/rbac');
|
|
|
|
function getRequestOrigin(req) {
|
|
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
|
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
|
|
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
|
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
|
|
return host ? `${protocol}://${host}`.replace(/\/$/, '') : 'http://localhost';
|
|
}
|
|
|
|
function normalizeReturnToPath(value, baseUrl) {
|
|
const rawValue = String(value || '').trim();
|
|
if (!rawValue) {
|
|
return '';
|
|
}
|
|
|
|
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '') || 'http://localhost';
|
|
|
|
try {
|
|
const parsed = new URL(rawValue, normalizedBaseUrl);
|
|
const baseOrigin = new URL(normalizedBaseUrl).origin;
|
|
if (parsed.origin !== baseOrigin) {
|
|
return '';
|
|
}
|
|
|
|
return parsed.pathname + parsed.search + parsed.hash;
|
|
} catch (_error) {
|
|
return rawValue.charAt(0) === '/' ? rawValue : '';
|
|
}
|
|
}
|
|
|
|
function createSessionService(options) {
|
|
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
|
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
|
const hashSessionToken = options && options.hashSessionToken;
|
|
const createSessionToken = options && options.createSessionToken;
|
|
const authMessageCookieName = 'pulse_auth_message';
|
|
|
|
if (!sessionCookieName || !Number.isFinite(sessionMaxAgeMs) || typeof hashSessionToken !== 'function' || typeof createSessionToken !== 'function') {
|
|
throw new Error('createSessionService requires the session dependencies.');
|
|
}
|
|
|
|
function parseCookies(cookieHeader) {
|
|
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
|
|
if (!pair) {
|
|
return cookies;
|
|
}
|
|
const separatorIndex = pair.indexOf('=');
|
|
if (separatorIndex === -1) {
|
|
return cookies;
|
|
}
|
|
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
|
|
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
|
|
if (name) {
|
|
cookies[name] = value;
|
|
}
|
|
return cookies;
|
|
}, {});
|
|
}
|
|
|
|
function serializeCookie(name, value, options) {
|
|
const parts = [`${encodeURIComponent(name)}=${encodeURIComponent(value)}`];
|
|
if (options && options.maxAge !== undefined) {
|
|
parts.push(`Max-Age=${Math.max(0, Math.trunc(Number(options.maxAge) / 1000))}`);
|
|
}
|
|
parts.push('Path=/');
|
|
parts.push('HttpOnly');
|
|
parts.push('SameSite=Lax');
|
|
return parts.join('; ');
|
|
}
|
|
|
|
function appendCookieHeader(res, cookieValue) {
|
|
const existingCookieHeader = res.getHeader('Set-Cookie');
|
|
if (!existingCookieHeader) {
|
|
res.setHeader('Set-Cookie', cookieValue);
|
|
return;
|
|
}
|
|
|
|
const cookies = Array.isArray(existingCookieHeader) ? existingCookieHeader.slice() : [existingCookieHeader];
|
|
cookies.push(cookieValue);
|
|
res.setHeader('Set-Cookie', cookies);
|
|
}
|
|
|
|
function clearSessionCookie(res) {
|
|
appendCookieHeader(res, serializeCookie(sessionCookieName, '', { maxAge: 0 }));
|
|
}
|
|
|
|
function setSessionCookie(res, token) {
|
|
appendCookieHeader(res, serializeCookie(sessionCookieName, token, { maxAge: sessionMaxAgeMs }));
|
|
}
|
|
|
|
function setAuthMessageCookie(res, message) {
|
|
appendCookieHeader(res, serializeCookie(authMessageCookieName, message, { maxAge: 60 * 1000 }));
|
|
}
|
|
|
|
function consumeAuthMessageCookie(req, res) {
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
const message = String(cookies[authMessageCookieName] || '').trim();
|
|
if (message) {
|
|
appendCookieHeader(res, serializeCookie(authMessageCookieName, '', { maxAge: 0 }));
|
|
}
|
|
return message;
|
|
}
|
|
|
|
async function loadCurrentUser(pool, req) {
|
|
const cookies = parseCookies(req.headers.cookie || '');
|
|
const token = cookies[sessionCookieName];
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
const tokenHash = hashSessionToken(token);
|
|
const [rows] = await pool.query(
|
|
`SELECT s.user_id, u.id, u.name, u.username
|
|
FROM a_sessions s
|
|
JOIN a_users u ON u.id = s.user_id
|
|
WHERE s.session_hash = ?
|
|
AND s.expires_at > NOW()
|
|
LIMIT 1`,
|
|
[tokenHash]
|
|
);
|
|
if (!rows.length) {
|
|
return null;
|
|
}
|
|
|
|
const userId = Number(rows[0].id);
|
|
const [roleRows] = await pool.query(
|
|
`SELECT r.role_key
|
|
FROM a_user_roles ur
|
|
JOIN a_roles r ON r.id = ur.role_id
|
|
WHERE ur.user_id = ?
|
|
ORDER BY r.name ASC`,
|
|
[userId]
|
|
);
|
|
const [permissionRows] = await pool.query(
|
|
`SELECT p.permission_key
|
|
FROM a_user_roles ur
|
|
JOIN a_role_permissions rp ON rp.role_id = ur.role_id
|
|
JOIN a_permissions p ON p.id = rp.permission_id
|
|
WHERE ur.user_id = ?
|
|
ORDER BY p.section_name ASC, p.name ASC`,
|
|
[userId]
|
|
);
|
|
|
|
await pool.query('UPDATE a_sessions SET last_used_at = CURRENT_TIMESTAMP, modified_by = ? WHERE session_hash = ?', [rows[0].user_id, tokenHash]);
|
|
return Object.assign({}, rows[0], {
|
|
roleKeys: roleRows.map(function (row) {
|
|
return String(row.role_key || '').trim();
|
|
}).filter(Boolean),
|
|
permissionKeys: normalizePermissionKeys(permissionRows.map(function (row) {
|
|
return String(row.permission_key || '').trim();
|
|
}))
|
|
});
|
|
}
|
|
|
|
async function createUserSession(pool, userId) {
|
|
const token = createSessionToken();
|
|
const tokenHash = hashSessionToken(token);
|
|
const expiresAt = new Date(Date.now() + sessionMaxAgeMs);
|
|
await pool.query(
|
|
'INSERT INTO a_sessions (session_hash, user_id, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?)',
|
|
[tokenHash, userId, expiresAt, userId, userId]
|
|
);
|
|
return token;
|
|
}
|
|
|
|
function requireAuth(req, res, next) {
|
|
if (req.currentUser) {
|
|
return next();
|
|
}
|
|
setAuthMessageCookie(res, 'Please sign in to continue.');
|
|
const returnTo = normalizeReturnToPath(req && (req.originalUrl || req.url || req.path), getRequestOrigin(req));
|
|
res.redirect(returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login');
|
|
}
|
|
|
|
return {
|
|
parseCookies: parseCookies,
|
|
serializeCookie: serializeCookie,
|
|
clearSessionCookie: clearSessionCookie,
|
|
setSessionCookie: setSessionCookie,
|
|
setAuthMessageCookie: setAuthMessageCookie,
|
|
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
|
loadCurrentUser: loadCurrentUser,
|
|
createUserSession: createUserSession,
|
|
requireAuth: requireAuth,
|
|
getRequestOrigin: getRequestOrigin
|
|
};
|
|
}
|
|
|
|
module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin }; |