3m-portal/lib/session.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

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 };