3m-portal/lib/proxy.js
Vadym Samoilenko 53a85c788d Add full auth system: SQLite sessions, email invites, admin console
- Real email/password login backed by SQLite (better-sqlite3)
- HttpOnly cookie sessions with 8h sliding TTL
- Admin role: invite users via Mailgun magic-link, manage roles/status
- Per-user One2Edit username mapping for job filtering
- Self-service forgot-password / reset-password via email
- Admin console (admin.html) with user table, invite modal, row actions
- New pages: change-password, forgot-password, reset-password, accept-invite
- Gated /api proxy: requires valid session, anti-hijack sessionId check
- Bootstrap initial admins from INITIAL_ADMINS env var on first boot
- Remove Oliver login button, SSO buttons, and legacy api.js/login.js
- deploy.sh: add build-essential (for native module), npm install, data dir

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 11:26:40 +01:00

138 lines
5.2 KiB
JavaScript

const https = require('https');
const { requireAuth, jsonError, getClientIp } = require('./auth-middleware');
const ONE2EDIT_API = 'https://oliver.one2edit.com/v3/Api.php';
const SERVICE_USERNAME = () => process.env.SERVICE_USERNAME;
const SERVICE_PASSWORD = () => process.env.SERVICE_PASSWORD;
const UPSTREAM_MS = parseInt(process.env.UPSTREAM_TIMEOUT_MS, 10) || 30000;
const IS_PROD = process.env.NODE_ENV === 'production';
const CORS_HEADERS = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
function injectCredentials(params, userExternSessionId) {
params.delete('authUsername');
params.delete('authPassword');
if (params.has('sessionId')) {
// Anti-hijack: the sessionId must be the one we issued to this user
if (userExternSessionId && params.get('sessionId') !== userExternSessionId) {
return false;
}
// sessionId present — One2Edit accepts it directly, no service-account creds needed
return true;
}
if (!params.has('authDomain')) params.set('authDomain', 'local');
params.set('authUsername', SERVICE_USERNAME());
params.set('authPassword', SERVICE_PASSWORD());
return true;
}
function logParams(method, params) {
const p = new URLSearchParams(params);
p.set('authPassword', '***');
if (IS_PROD) {
if (p.has('authUsername')) p.set('authUsername', '***');
if (p.has('username')) p.set('username', '***');
}
console.log(`Proxy ${method}:`, p.toString().substring(0, 200));
}
function handleApiResponse(res) {
return (apiRes) => {
if (apiRes.statusCode === 301 || apiRes.statusCode === 302) {
res.writeHead(401, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Authentication failed</message></error>');
return;
}
const chunks = [];
apiRes.on('data', c => chunks.push(c));
apiRes.on('end', () => {
const ct = apiRes.headers['content-type'] || 'text/xml;charset=UTF-8';
res.writeHead(apiRes.statusCode, { 'Content-Type': ct, ...CORS_HEADERS });
res.end(Buffer.concat(chunks));
});
};
}
function handleProxyError(res) {
return (err) => {
console.error('Proxy error:', err.message);
if (res.headersSent) return;
res.writeHead(502, { 'Content-Type': 'text/plain', ...CORS_HEADERS });
res.end('Error connecting to API');
};
}
function attachTimeout(apiReq, res) {
apiReq.setTimeout(UPSTREAM_MS, () => {
apiReq.destroy(new Error('Upstream timeout'));
if (res.headersSent) return;
res.writeHead(504, { 'Content-Type': 'text/plain', ...CORS_HEADERS });
res.end('Upstream request timed out');
});
}
function proxyGet(queryString, res, userExternSessionId) {
const params = new URLSearchParams(queryString);
if (!injectCredentials(params, userExternSessionId)) {
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Session mismatch</message></error>');
return;
}
logParams('GET', params);
const apiReq = https.get(`${ONE2EDIT_API}?${params}`, handleApiResponse(res));
apiReq.on('error', handleProxyError(res));
attachTimeout(apiReq, res);
}
function proxyPost(body, res, userExternSessionId) {
const params = new URLSearchParams(body);
if (!injectCredentials(params, userExternSessionId)) {
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
res.end('<?xml version="1.0" encoding="UTF-8"?><error><message>Session mismatch</message></error>');
return;
}
logParams('POST', params);
const newBody = params.toString();
const apiUrl = new URL(ONE2EDIT_API);
const options = {
hostname: apiUrl.hostname,
path: apiUrl.pathname,
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(newBody) },
};
const apiReq = https.request(options, handleApiResponse(res));
apiReq.on('error', handleProxyError(res));
attachTimeout(apiReq, res);
apiReq.write(newBody);
apiReq.end();
}
function handleProxy(req, res, parsedUrl) {
// Require authenticated portal session
if (!requireAuth(req, res)) return;
// Reject limited sessions (must change password)
if (req.user.must_change_password) {
const xmlErr = '<?xml version="1.0" encoding="UTF-8"?><error><message>Password change required</message></error>';
res.writeHead(403, { 'Content-Type': 'text/xml;charset=UTF-8', ...CORS_HEADERS });
res.end(xmlErr);
return;
}
const userExternSessionId = req.session.one2edit_extern_session_id;
if (req.method === 'POST') {
const chunks = [];
req.on('data', c => chunks.push(c));
req.on('end', () => proxyPost(Buffer.concat(chunks).toString(), res, userExternSessionId));
} else {
proxyGet(parsedUrl.search.slice(1), res, userExternSessionId);
}
}
module.exports = { handleProxy, CORS_HEADERS };