- 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>
84 lines
3 KiB
JavaScript
84 lines
3 KiB
JavaScript
const crypto = require('crypto');
|
|
const { db } = require('./db');
|
|
|
|
const TTL_MS = parseInt(process.env.SESSION_TTL_MS, 10) || 8 * 60 * 60 * 1000; // 8h
|
|
const COOKIE_NAME = process.env.COOKIE_NAME || 'portal_session';
|
|
const IS_SECURE = process.env.COOKIE_SECURE !== 'false';
|
|
|
|
const stmts = {
|
|
insert: db.prepare(`
|
|
INSERT INTO sessions (id, user_id, one2edit_extern_session_id, one2edit_user_id,
|
|
expires_at, created_at, last_seen_at, user_agent, ip)
|
|
VALUES (@id, @userId, @externSessionId, @o2UserId, @expiresAt, @now, @now, @ua, @ip)
|
|
`),
|
|
get: db.prepare('SELECT * FROM sessions WHERE id = ?'),
|
|
touch: db.prepare('UPDATE sessions SET expires_at = @exp, last_seen_at = @now WHERE id = @id'),
|
|
destroy: db.prepare('DELETE FROM sessions WHERE id = ?'),
|
|
purgeUser: db.prepare('DELETE FROM sessions WHERE user_id = ?'),
|
|
purgeExpired: db.prepare('DELETE FROM sessions WHERE expires_at < ?'),
|
|
updateO2: db.prepare('UPDATE sessions SET one2edit_extern_session_id = @eid, one2edit_user_id = @uid WHERE id = @id'),
|
|
};
|
|
|
|
function create(userId, { ua = null, ip = null, externSessionId = null, o2UserId = null } = {}) {
|
|
const id = crypto.randomBytes(32).toString('hex');
|
|
const now = Date.now();
|
|
stmts.insert.run({ id, userId, externSessionId, o2UserId, expiresAt: now + TTL_MS, now, ua, ip });
|
|
return id;
|
|
}
|
|
|
|
function get(token) {
|
|
if (!token) return null;
|
|
const row = stmts.get.get(token);
|
|
if (!row) return null;
|
|
const now = Date.now();
|
|
if (row.expires_at < now) {
|
|
stmts.destroy.run(token);
|
|
return null;
|
|
}
|
|
stmts.touch.run({ id: token, exp: now + TTL_MS, now });
|
|
return row;
|
|
}
|
|
|
|
function setOne2EditSession(token, externSessionId, o2UserId) {
|
|
stmts.updateO2.run({ id: token, eid: externSessionId, uid: o2UserId });
|
|
}
|
|
|
|
function destroy(token) {
|
|
stmts.destroy.run(token);
|
|
}
|
|
|
|
function destroyAllForUser(userId) {
|
|
stmts.purgeUser.run(userId);
|
|
}
|
|
|
|
function cleanup() {
|
|
const n = stmts.purgeExpired.run(Date.now()).changes;
|
|
if (n > 0) console.log(`[sessions] purged ${n} expired session(s)`);
|
|
}
|
|
|
|
function setCookie(res, token) {
|
|
const maxAge = TTL_MS / 1000;
|
|
const secure = IS_SECURE ? '; Secure' : '';
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=${token}; HttpOnly; SameSite=Lax; Path=/; Max-Age=${maxAge}${secure}`);
|
|
}
|
|
|
|
function clearCookie(res) {
|
|
res.setHeader('Set-Cookie', `${COOKIE_NAME}=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0`);
|
|
}
|
|
|
|
function parseCookieHeader(cookieHeader) {
|
|
if (!cookieHeader) return {};
|
|
return Object.fromEntries(
|
|
cookieHeader.split(';').map(p => {
|
|
const idx = p.indexOf('=');
|
|
return [p.slice(0, idx).trim(), p.slice(idx + 1).trim()];
|
|
})
|
|
);
|
|
}
|
|
|
|
function getTokenFromRequest(req) {
|
|
const cookies = parseCookieHeader(req.headers.cookie);
|
|
return cookies[COOKIE_NAME] || null;
|
|
}
|
|
|
|
module.exports = { create, get, setOne2EditSession, destroy, destroyAllForUser, cleanup, setCookie, clearCookie, getTokenFromRequest };
|