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>
131 lines
4.5 KiB
JavaScript
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 };
|