- 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>
134 lines
4.8 KiB
JavaScript
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 };
|