fix(auth): harden session lifecycle, reset links, and OIDC logging

- Fix session store expiry: cookie.maxAge is already in milliseconds, so
  stored sessions outlived the cookie by 1000x
- Regenerate the session ID on login, first-run setup, and OIDC login to
  prevent session fixation
- Mark session cookies Secure on TLS connections (secure: 'auto') and add
  TRUST_PROXY support for reverse-proxy deployments
- Build password reset links from APP_BASE_URL instead of the Host header
  to prevent reset-link poisoning
- Rate-limit forgot-password requests (5 per IP per 15 minutes)
- Strip OIDC debug logging that leaked authorization codes, subject IDs,
  and emails to logs
This commit is contained in:
2026-06-11 21:54:35 -06:00
parent 427b064af1
commit 3fd3285c13
6 changed files with 109 additions and 84 deletions
+8
View File
@@ -6,6 +6,14 @@ SESSION_MAX_AGE_HOURS=168 # default: 168 (7 days)
PORT=3000 PORT=3000
DB_PATH=/app/data/check-printing.db DB_PATH=/app/data/check-printing.db
# Public base URL of the app — used to build password reset links.
# Strongly recommended in production (prevents host-header link poisoning).
APP_BASE_URL=https://checks.example.com
# Set to 1 when running behind a reverse proxy (TLS termination) so client IPs
# and HTTPS detection work correctly. Leave unset for direct LAN access.
TRUST_PROXY=
# OIDC / SSO (optional — omit or leave blank to disable) # OIDC / SSO (optional — omit or leave blank to disable)
OIDC_ENABLED=false OIDC_ENABLED=false
OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration OIDC_DISCOVERY_URL=https://auth.example.com/.well-known/openid-configuration
+5
View File
@@ -162,6 +162,8 @@ docker exec -it check-printing node migrations/import-mdb.js \
| `SESSION_MAX_AGE_HOURS` | `168` | Session lifetime in hours (default 7 days) | | `SESSION_MAX_AGE_HOURS` | `168` | Session lifetime in hours (default 7 days) |
| `PORT` | `3000` | HTTP listen port | | `PORT` | `3000` | HTTP listen port |
| `DB_PATH` | `/app/data/check-printing.db` | SQLite database file path | | `DB_PATH` | `/app/data/check-printing.db` | SQLite database file path |
| `APP_BASE_URL` | *(empty)* | Public base URL used in password reset links, e.g. `https://checks.example.com`. Recommended in production |
| `TRUST_PROXY` | *(empty)* | Set to `1` when running behind a reverse proxy so client IPs and HTTPS detection work correctly |
| `OIDC_ENABLED` | *(empty)* | Set to `true` or `1` to enable OIDC login | | `OIDC_ENABLED` | *(empty)* | Set to `true` or `1` to enable OIDC login |
| `OIDC_DISCOVERY_URL` | *(empty)* | Provider's `.well-known/openid-configuration` URL | | `OIDC_DISCOVERY_URL` | *(empty)* | Provider's `.well-known/openid-configuration` URL |
| `OIDC_CLIENT_ID` | *(empty)* | OIDC client ID | | `OIDC_CLIENT_ID` | *(empty)* | OIDC client ID |
@@ -185,6 +187,9 @@ services:
- check-printing-data:/app/data - check-printing-data:/app/data
environment: environment:
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET}
# Optional: public base URL for reset links, reverse-proxy support
- APP_BASE_URL=${APP_BASE_URL:-}
- TRUST_PROXY=${TRUST_PROXY:-}
# Optional: OIDC / SSO # Optional: OIDC / SSO
- OIDC_ENABLED=${OIDC_ENABLED:-} - OIDC_ENABLED=${OIDC_ENABLED:-}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
+4
View File
@@ -14,6 +14,10 @@ services:
- DB_PATH=/app/data/check-printing.db - DB_PATH=/app/data/check-printing.db
# Required in production — generate with: openssl rand -hex 32 # Required in production — generate with: openssl rand -hex 32
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET}
# Public base URL for password reset links (recommended)
- APP_BASE_URL=${APP_BASE_URL:-}
# Set to 1 when behind a reverse proxy / TLS termination
- TRUST_PROXY=${TRUST_PROXY:-}
# OIDC / SSO (optional — omit or leave blank to disable) # OIDC / SSO (optional — omit or leave blank to disable)
- OIDC_ENABLED=${OIDC_ENABLED:-} - OIDC_ENABLED=${OIDC_ENABLED:-}
- OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-} - OIDC_DISCOVERY_URL=${OIDC_DISCOVERY_URL:-}
+9 -1
View File
@@ -27,12 +27,20 @@ const SESSION_SECRET = process.env.SESSION_SECRET;
const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000; const SESSION_MAX_AGE_MS = (parseInt(process.env.SESSION_MAX_AGE_HOURS, 10) || 168) * 60 * 60 * 1000;
// Behind a reverse proxy (TLS termination), set TRUST_PROXY=1 so req.ip and
// req.protocol reflect the original client instead of the proxy.
if (process.env.TRUST_PROXY === '1' || process.env.TRUST_PROXY === 'true') {
app.set('trust proxy', 1);
}
app.use(session({ app.use(session({
store: new SessionStore(db), store: new SessionStore(db),
secret: SESSION_SECRET, secret: SESSION_SECRET,
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { httpOnly: true, sameSite: 'strict', maxAge: SESSION_MAX_AGE_MS }, // secure: 'auto' marks the cookie Secure only on TLS connections, so plain-HTTP
// LAN deployments keep working while proxied HTTPS deployments get Secure cookies
cookie: { httpOnly: true, sameSite: 'strict', secure: 'auto', maxAge: SESSION_MAX_AGE_MS },
})); }));
// Security headers // Security headers
+2 -1
View File
@@ -28,8 +28,9 @@ class SessionStore extends Store {
set(sid, sess, cb) { set(sid, sess, cb) {
try { try {
// cookie.maxAge is already in milliseconds
const maxAge = (sess.cookie && sess.cookie.maxAge) const maxAge = (sess.cookie && sess.cookie.maxAge)
? sess.cookie.maxAge * 1000 ? sess.cookie.maxAge
: 7 * 24 * 60 * 60 * 1000; : 7 * 24 * 60 * 60 * 1000;
const expired = Date.now() + maxAge; const expired = Date.now() + maxAge;
this.db.prepare( this.db.prepare(
+75 -76
View File
@@ -15,45 +15,57 @@ function validatePassword(password) {
return null; return null;
} }
// ── Login rate limiter ──────────────────────────────────────────────────────── // ── Rate limiting ─────────────────────────────────────────────────────────────
// Tracks failed login attempts per IP. After 10 failures within 15 minutes, // Sliding-window counter per key (IP). After `max` hits within `windowMs`,
// further attempts are blocked until the window resets. // further attempts are blocked until the window resets.
const loginAttempts = new Map(); // ip -> { count, resetAt } function makeRateLimiter(max, windowMs) {
const RATE_WINDOW_MS = 15 * 60 * 1000; // 15 minutes const attempts = new Map(); // key -> { count, resetAt }
const RATE_MAX_FAILS = 10;
function checkLoginRate(ip) {
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 0, resetAt: now + RATE_WINDOW_MS });
return true; // allow
}
return entry.count < RATE_MAX_FAILS;
}
function recordLoginFailure(ip) {
const now = Date.now();
const entry = loginAttempts.get(ip);
if (!entry || now > entry.resetAt) {
loginAttempts.set(ip, { count: 1, resetAt: now + RATE_WINDOW_MS });
} else {
entry.count++;
}
}
function clearLoginFailures(ip) {
loginAttempts.delete(ip);
}
// Purge stale entries every 30 minutes to prevent unbounded memory growth // Purge stale entries every 30 minutes to prevent unbounded memory growth
setInterval(() => { setInterval(() => {
const now = Date.now(); const now = Date.now();
for (const [ip, entry] of loginAttempts) { for (const [key, entry] of attempts) {
if (now > entry.resetAt) loginAttempts.delete(ip); if (now > entry.resetAt) attempts.delete(key);
} }
}, 30 * 60 * 1000).unref(); }, 30 * 60 * 1000).unref();
return {
allowed(key) {
const entry = attempts.get(key);
if (!entry || Date.now() > entry.resetAt) return true;
return entry.count < max;
},
record(key) {
const now = Date.now();
const entry = attempts.get(key);
if (!entry || now > entry.resetAt) {
attempts.set(key, { count: 1, resetAt: now + windowMs });
} else {
entry.count++;
}
},
clear(key) {
attempts.delete(key);
},
};
}
// 10 failed logins per IP per 15 minutes
const loginLimiter = makeRateLimiter(10, 15 * 60 * 1000);
// 5 reset emails per IP per 15 minutes (counts every request, success or not)
const resetLimiter = makeRateLimiter(5, 15 * 60 * 1000);
// Regenerates the session ID before establishing a login (prevents session fixation)
function establishSession(req, user, cb) {
req.session.regenerate(err => {
if (err) return cb(err);
req.session.userId = user.id;
req.session.username = user.username;
req.session.role = user.role;
cb(null);
});
}
// GET /api/auth/setup-needed — true when no users exist (first-run) // GET /api/auth/setup-needed — true when no users exist (first-run)
router.get('/setup-needed', (req, res) => { router.get('/setup-needed', (req, res) => {
const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get(); const { n } = db.prepare('SELECT COUNT(*) AS n FROM users').get();
@@ -75,18 +87,18 @@ router.post('/setup', async (req, res) => {
"INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')" "INSERT INTO users (username, password_hash, role) VALUES (?, ?, 'admin')"
).run(username.trim(), hash); ).run(username.trim(), hash);
req.session.userId = result.lastInsertRowid; const user = { id: result.lastInsertRowid, username: username.trim(), role: 'admin' };
req.session.username = username.trim(); establishSession(req, user, err => {
req.session.role = 'admin'; if (err) return res.status(500).json({ error: 'Failed to create session.' });
res.status(201).json(user);
res.status(201).json({ id: result.lastInsertRowid, username: username.trim(), role: 'admin' }); });
}); });
// POST /api/auth/login // POST /api/auth/login
router.post('/login', async (req, res) => { router.post('/login', async (req, res) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown'; const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!checkLoginRate(ip)) { if (!loginLimiter.allowed(ip)) {
return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' }); return res.status(429).json({ error: 'Too many failed login attempts. Please try again later.' });
} }
@@ -95,23 +107,22 @@ router.post('/login', async (req, res) => {
const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim()); const user = db.prepare('SELECT * FROM users WHERE username = ? COLLATE NOCASE').get(username.trim());
if (!user) { if (!user) {
recordLoginFailure(ip); loginLimiter.record(ip);
return res.status(401).json({ error: 'Invalid username or password.' }); return res.status(401).json({ error: 'Invalid username or password.' });
} }
const match = await bcrypt.compare(password, user.password_hash); const match = await bcrypt.compare(password, user.password_hash);
if (!match) { if (!match) {
recordLoginFailure(ip); loginLimiter.record(ip);
return res.status(401).json({ error: 'Invalid username or password.' }); return res.status(401).json({ error: 'Invalid username or password.' });
} }
clearLoginFailures(ip); loginLimiter.clear(ip);
req.session.userId = user.id; establishSession(req, user, err => {
req.session.username = user.username; if (err) return res.status(500).json({ error: 'Failed to create session.' });
req.session.role = user.role;
res.json({ id: user.id, username: user.username, role: user.role }); res.json({ id: user.id, username: user.username, role: user.role });
}); });
});
// POST /api/auth/logout // POST /api/auth/logout
router.post('/logout', (req, res) => { router.post('/logout', (req, res) => {
@@ -154,6 +165,12 @@ router.post('/change-password', async (req, res) => {
// POST /api/auth/forgot-password — always 200 to avoid user enumeration // POST /api/auth/forgot-password — always 200 to avoid user enumeration
router.post('/forgot-password', async (req, res) => { router.post('/forgot-password', async (req, res) => {
const ip = req.ip || req.socket.remoteAddress || 'unknown';
if (!resetLimiter.allowed(ip)) {
return res.status(429).json({ error: 'Too many reset requests. Please try again later.' });
}
resetLimiter.record(ip);
const { email } = req.body; const { email } = req.body;
if (!email) return res.status(400).json({ error: 'Email is required.' }); if (!email) return res.status(400).json({ error: 'Email is required.' });
@@ -168,7 +185,9 @@ router.post('/forgot-password', async (req, res) => {
db.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)').run(user.id, tokenHash, expiresAt); db.prepare('INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, ?, ?)').run(user.id, tokenHash, expiresAt);
})(); })();
const baseUrl = `${req.protocol}://${req.get('host')}`; // Prefer the configured base URL; deriving it from the Host header lets an
// attacker poison reset links (host header injection)
const baseUrl = (process.env.APP_BASE_URL || `${req.protocol}://${req.get('host')}`).replace(/\/+$/, '');
const resetLink = `${baseUrl}/#reset?token=${token}`; const resetLink = `${baseUrl}/#reset?token=${token}`;
try { try {
@@ -221,17 +240,13 @@ function getOidcSettings() {
async function getOidcClient(settings) { async function getOidcClient(settings) {
const { Issuer } = require('openid-client'); const { Issuer } = require('openid-client');
console.log('[oidc] discovering issuer from:', settings.discovery_url);
const issuer = await Issuer.discover(settings.discovery_url); const issuer = await Issuer.discover(settings.discovery_url);
console.log('[oidc] discovered issuer:', issuer.issuer); return new issuer.Client({
const client = new issuer.Client({
client_id: settings.client_id, client_id: settings.client_id,
client_secret: settings.client_secret, client_secret: settings.client_secret,
redirect_uris: [settings.redirect_uri], redirect_uris: [settings.redirect_uri],
response_types: ['code'], response_types: ['code'],
}); });
console.log('[oidc] client created, redirect_uri:', settings.redirect_uri);
return client;
} }
// GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label // GET /api/auth/oidc/config — public, returns whether OIDC is enabled + button label
@@ -244,8 +259,6 @@ router.get('/oidc/config', (req, res) => {
router.get('/oidc/authorize', async (req, res) => { router.get('/oidc/authorize', async (req, res) => {
try { try {
const settings = getOidcSettings(); const settings = getOidcSettings();
console.log('[oidc] authorize: enabled=%s, discovery_url=%s, client_id=%s, redirect_uri=%s',
settings.enabled, settings.discovery_url, settings.client_id, settings.redirect_uri);
if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' }); if (!settings.enabled) return res.status(400).json({ error: 'OIDC is not enabled.' });
const { generators } = require('openid-client'); const { generators } = require('openid-client');
@@ -266,11 +279,10 @@ router.get('/oidc/authorize', async (req, res) => {
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
console.log('[oidc] authorize: redirecting to:', authUrl.substring(0, 200) + '...');
// Ensure session is persisted before redirecting (saveUninitialized is false) // Ensure session is persisted before redirecting (saveUninitialized is false)
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } catch (err) {
console.error('[oidc] authorize error:', err.message, err.stack); console.error('[oidc] authorize error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.')); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO login.'));
} }
}); });
@@ -278,7 +290,6 @@ router.get('/oidc/authorize', async (req, res) => {
// GET /api/auth/oidc/callback — handles the provider redirect // GET /api/auth/oidc/callback — handles the provider redirect
router.get('/oidc/callback', async (req, res) => { router.get('/oidc/callback', async (req, res) => {
try { try {
console.log('[oidc] callback: query params:', JSON.stringify(req.query));
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
@@ -287,12 +298,9 @@ router.get('/oidc/callback', async (req, res) => {
console.error('[oidc] callback: no oidc session data found — session may have expired or cookie lost'); console.error('[oidc] callback: no oidc session data found — session may have expired or cookie lost');
return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.')); return res.redirect('/#oidc-error=' + encodeURIComponent('Session expired. Please try again.'));
} }
console.log('[oidc] callback: session has oidc data, linking=%s, linkUserId=%s',
!!oidcSession.linking, oidcSession.linkUserId || 'n/a');
const client = await getOidcClient(settings); const client = await getOidcClient(settings);
const params = client.callbackParams(req); const params = client.callbackParams(req);
console.log('[oidc] callback: exchanging code for tokens...');
const tokenSet = await client.callback(settings.redirect_uri, params, { const tokenSet = await client.callback(settings.redirect_uri, params, {
code_verifier: oidcSession.code_verifier, code_verifier: oidcSession.code_verifier,
@@ -303,25 +311,21 @@ router.get('/oidc/callback', async (req, res) => {
const claims = tokenSet.claims(); const claims = tokenSet.claims();
const sub = claims.sub; const sub = claims.sub;
const issuer = claims.iss; const issuer = claims.iss;
console.log('[oidc] callback: token exchange OK, sub=%s, iss=%s, email=%s, name=%s',
sub, issuer, claims.email || 'n/a', claims.name || 'n/a');
delete req.session.oidc; delete req.session.oidc;
// Self-service linking flow // Self-service linking flow
if (oidcSession.linking && oidcSession.linkUserId) { if (oidcSession.linking && oidcSession.linkUserId) {
console.log('[oidc] callback: linking flow for userId=%s', oidcSession.linkUserId);
const existing = db.prepare( const existing = db.prepare(
'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?' 'SELECT id FROM users WHERE oidc_issuer = ? AND oidc_sub = ? AND id != ?'
).get(issuer, sub, oidcSession.linkUserId); ).get(issuer, sub, oidcSession.linkUserId);
if (existing) { if (existing) {
console.warn('[oidc] callback: identity already linked to userId=%s', existing.id);
return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.')); return res.redirect('/#oidc-error=' + encodeURIComponent('This identity is already linked to another account.'));
} }
db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?") db.prepare("UPDATE users SET oidc_sub = ?, oidc_issuer = ?, updated_at = datetime('now') WHERE id = ?")
.run(sub, issuer, oidcSession.linkUserId); .run(sub, issuer, oidcSession.linkUserId);
console.log('[oidc] callback: linked sub=%s to userId=%s', sub, oidcSession.linkUserId); console.log('[oidc] linked identity to userId=%s', oidcSession.linkUserId);
return res.redirect('/#oidc-linked'); return res.redirect('/#oidc-linked');
} }
@@ -331,26 +335,23 @@ router.get('/oidc/callback', async (req, res) => {
).get(issuer, sub); ).get(issuer, sub);
if (!user) { if (!user) {
console.warn('[oidc] callback: no user found for iss=%s sub=%s — not linked', issuer, sub); console.warn('[oidc] callback: identity not linked to any user');
return res.redirect('/#oidc-error=' + encodeURIComponent( return res.redirect('/#oidc-error=' + encodeURIComponent(
'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.' 'No account is linked to this identity. Ask an admin to link your account, or sign in with your password and link it yourself.'
)); ));
} }
console.log('[oidc] callback: login success, userId=%s, username=%s, role=%s', user.id, user.username, user.role); console.log('[oidc] login success for userId=%s', user.id);
req.session.userId = user.id; establishSession(req, user, err => {
req.session.username = user.username; if (err) return res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
req.session.role = user.role;
// Load account access into session (mirrors login behavior) // Load account access into session (mirrors login behavior)
if (user.role !== 'admin') { if (user.role !== 'admin') {
const accts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id); req.session.accounts = db.prepare('SELECT account_id, role FROM user_accounts WHERE user_id = ?').all(user.id);
req.session.accounts = accts;
} }
res.redirect('/'); res.redirect('/');
});
} catch (err) { } catch (err) {
console.error('[oidc] callback error:', err.message, err.stack); console.error('[oidc] callback error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.')); res.redirect('/#oidc-error=' + encodeURIComponent('SSO login failed. Please try again.'));
} }
}); });
@@ -362,7 +363,6 @@ router.get('/oidc/link', async (req, res) => {
} }
try { try {
console.log('[oidc] link: userId=%s initiating linking flow', req.session.userId);
const settings = getOidcSettings(); const settings = getOidcSettings();
if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.')); if (!settings.enabled) return res.redirect('/#oidc-error=' + encodeURIComponent('OIDC is not enabled.'));
@@ -384,10 +384,9 @@ router.get('/oidc/link', async (req, res) => {
code_challenge_method: 'S256', code_challenge_method: 'S256',
}); });
console.log('[oidc] link: redirecting to provider');
req.session.save(() => res.redirect(authUrl)); req.session.save(() => res.redirect(authUrl));
} catch (err) { } catch (err) {
console.error('[oidc] link error:', err.message, err.stack); console.error('[oidc] link error:', err.message);
res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.')); res.redirect('/#oidc-error=' + encodeURIComponent('Failed to initiate SSO linking.'));
} }
}); });