adeo-maturity-tool/server/auth.js
Phil Dore 1f537b8a3b Add Admin tab, Economics overview, Access Log, fix deliverables and gap data
- Merge Economics + Update Data into single Admin tab with sub-nav (Economics | Update Data | Access Log)
- Add Economics tab: per-market financial metrics parsed from Box MD files, card grid matching Markets dashboard style, drill-in detail view for key characteristics; admin-only
- Add Access Log: DB migration (003_access_log), login events recorded in auth.js, admin endpoint + frontend table showing user/action/IP/timestamp
- Fix deliverable downloads: copy PDFs/XLSXs to clients/adeo/deliverables/ and update deliverables.json paths to container-accessible locations
- Patch gap analysis: extract gaps from Question_XX_Analysis.md files in Box for all 8 markets (90 questions patched)
- Mobile: fix card grid minmax, modal max-width uses min(740px,90vw)
- Loading: replace invisible auth-loading with visible spinner overlay
- Add economic.json with key metrics for all 8 markets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-05 16:35:12 +01:00

134 lines
4.8 KiB
JavaScript

'use strict';
const bcrypt = require('bcryptjs');
const jwt = require('jsonwebtoken');
const rateLimit = require('express-rate-limit');
const { pool } = require('./db');
const SECRET = process.env.AUTH_SECRET;
const COOKIE_NAME = 'adeo_session';
const COOKIE_PATH = process.env.COOKIE_PATH || '/';
const COOKIE_SECURE = String(process.env.COOKIE_SECURE ?? 'true').toLowerCase() === 'true';
const TOKEN_TTL_DAYS = 7;
if (!SECRET || SECRET.length < 32) {
console.error('[auth] AUTH_SECRET must be set and at least 32 chars — refusing to start');
process.exit(1);
}
// Pre-computed bcrypt hash used to defeat user-enumeration timing oracles
// when a login attempt targets a non-existent email. Compare cost matches
// real hashes (cost 12).
const DUMMY_HASH = bcrypt.hashSync('not-a-real-password', 12);
function setSessionCookie(res, token) {
res.cookie(COOKIE_NAME, token, {
httpOnly: true,
secure: COOKIE_SECURE,
sameSite: 'strict',
path: COOKIE_PATH,
maxAge: TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000,
});
}
function clearSessionCookie(res) {
res.clearCookie(COOKIE_NAME, { path: COOKIE_PATH });
}
function signToken(user) {
return jwt.sign(
{ sub: user.id, email: user.email, role: user.role },
SECRET,
{ expiresIn: `${TOKEN_TTL_DAYS}d` }
);
}
function authenticate(req, res, next) {
const token = req.cookies?.[COOKIE_NAME];
if (!token) return res.status(401).json({ error: 'Not authenticated' });
try {
const payload = jwt.verify(token, SECRET);
req.user = { id: payload.sub, email: payload.email, role: payload.role };
next();
} catch {
return res.status(401).json({ error: 'Invalid session' });
}
}
function requireAdmin(req, res, next) {
if (req.user?.role !== 'admin') return res.status(403).json({ error: 'Forbidden' });
next();
}
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 10,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many login attempts. Try again in a few minutes.' },
});
function mountAuth(app) {
app.post('/api/auth/login', loginLimiter, async (req, res) => {
try {
const { email, password } = req.body || {};
if (!email || !password) return res.status(400).json({ error: 'Email and password are required' });
const r = await pool.query(
'SELECT id, email, password_hash, role FROM users WHERE lower(email) = lower($1)',
[email]
);
const user = r.rows[0];
let ok = false;
if (user?.password_hash) {
ok = await bcrypt.compare(password, user.password_hash);
} else {
// Burn equivalent CPU on missing/SSO-only users so timing doesn't
// leak which emails exist.
await bcrypt.compare(password, DUMMY_HASH);
}
if (!ok) return res.status(401).json({ error: 'Invalid credentials' });
await pool.query('UPDATE users SET last_login_at = now() WHERE id = $1', [user.id]);
const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress || '';
pool.query('INSERT INTO access_log (user_email, action, detail, ip) VALUES ($1, $2, $3, $4)',
[user.email, 'login', 'Password login', ip]).catch(() => {});
setSessionCookie(res, signToken(user));
res.json({ id: user.id, email: user.email, role: user.role });
} catch (e) {
console.error('[auth] login error:', e);
res.status(500).json({ error: 'Login failed' });
}
});
app.post('/api/auth/logout', (_req, res) => {
clearSessionCookie(res);
res.json({ ok: true });
});
app.get('/api/auth/me', authenticate, (req, res) => {
res.json(req.user);
});
// ── Microsoft SSO — scaffolded, not yet wired up ──
//
// To enable:
// 1. npm i @azure/msal-node
// 2. Set AZURE_CLIENT_ID, AZURE_TENANT_ID, AZURE_CLIENT_SECRET, and
// AZURE_REDIRECT_URI (e.g. https://optical-dev.oliver.solutions/adeo-maturity/api/auth/msft/callback)
// in .env. Register the redirect URI in the Azure app registration.
// 3. Replace these stubs with msal-node ConfidentialClientApplication
// using getAuthCodeUrl + acquireTokenByCode.
// 4. On callback, look up users by azure_oid (the `oid` claim from the
// ID token); auto-provision a user row on first SSO login (role
// defaults to 'user' — promote to 'admin' manually). Then issue our
// own JWT cookie via signToken() so the rest of the app is unchanged.
app.get('/api/auth/msft/login', (_req, res) => {
res.status(501).json({ error: 'Microsoft SSO is not yet configured on this deployment' });
});
app.get('/api/auth/msft/callback', (_req, res) => {
res.status(501).json({ error: 'Microsoft SSO is not yet configured on this deployment' });
});
}
module.exports = { authenticate, requireAdmin, mountAuth, signToken };