adeo-maturity-tool/server/auth.js
DJP 8e969fe015 Add login + role-based access (admin/user) backed by Postgres
Adds a Postgres-backed user store with bcrypt + JWT cookie sessions,
login screen, role-gated UI, and Microsoft SSO scaffolding ready to
fill in.

Backend
- New `db` service (Postgres 16-alpine) in compose, healthcheck-gated
  app startup, free-port autodetect (5435-5499) like other apps.
- `server/db.js` runs versioned `.sql` migrations on boot.
- `server/auth.js`: bcrypt + JWT cookie (httpOnly, sameSite=strict,
  path-scoped to /adeo-maturity), rate-limited login (10/15min),
  dummy bcrypt-compare on missing users to defeat timing oracles.
- `requireAdmin` on all writes (POST/import/sync); `authenticate`
  on all reads. /api/health stays public.
- Microsoft SSO endpoints stubbed at /api/auth/msft/{login,callback}
  (return 501); DB has azure_oid column ready; comments document
  exactly how to wire @azure/msal-node.

Frontend
- Login screen with email/password + greyed-out "Sign in with
  Microsoft" button; init() checks /api/auth/me first.
- Logout button + user badge in header.
- body.role-user CSS hides .admin-only elements (Update tab, New
  Client cards). Server enforces regardless.

Deploy
- deploy.sh generates DB_PASSWORD and AUTH_SECRET on first run and
  persists to .env, then runs `seed-users.js seed-defaults` to
  create admin@oliver.agency + user@oliver.agency with random
  passwords printed once. Subsequent deploys skip seeding unless
  --reseed is passed.
- node server/seed-users.js set-password <email> <pw> for ad-hoc
  resets later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 11:14:19 -04:00

131 lines
4.5 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]);
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 };