The login query names its columns, and territory and is_admin were not among them -- so every session carried an empty list, every account scoped to FALSE, and the forecast page came back with nothing however the grant was set. The CLI had written it correctly; nothing read it. The empty case then aborted twice over. First on the index, fixed already. Then on the layout: an empty table has no schema, so restoring a saved config asks for the dtype of a column that is not there and the worker dies -- "Could not get dtype for column `sseas_e`". With no rows there is nothing to lay out, so nothing is restored, and the saved layout waits in localStorage for rows to come back. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
111 lines
4.2 KiB
JavaScript
111 lines
4.2 KiB
JavaScript
const express = require('express');
|
|
const { verifyPassword } = require('../lib/auth');
|
|
|
|
// Per-IP login throttle. In-memory on purpose: it only has to blunt online
|
|
// guessing, and a counter that resets on restart is the acceptable cost of not
|
|
// writing a failed-attempt row for every knock on an internet-facing port.
|
|
const WINDOW_MS = 15 * 60 * 1000;
|
|
const MAX_ATTEMPTS = 10;
|
|
const attempts = new Map(); // ip -> { count, resetAt }
|
|
|
|
function tooManyAttempts(ip) {
|
|
const rec = attempts.get(ip);
|
|
if (!rec || Date.now() > rec.resetAt) return false;
|
|
return rec.count >= MAX_ATTEMPTS;
|
|
}
|
|
|
|
function recordFailure(ip) {
|
|
const rec = attempts.get(ip);
|
|
if (!rec || Date.now() > rec.resetAt) {
|
|
attempts.set(ip, { count: 1, resetAt: Date.now() + WINDOW_MS });
|
|
} else {
|
|
rec.count += 1;
|
|
}
|
|
}
|
|
|
|
// Keep the map from growing without bound on a long-lived process.
|
|
setInterval(() => {
|
|
const now = Date.now();
|
|
for (const [ip, rec] of attempts) if (now > rec.resetAt) attempts.delete(ip);
|
|
}, WINDOW_MS).unref();
|
|
|
|
module.exports = function(pool) {
|
|
const router = express.Router();
|
|
|
|
router.post('/login', async (req, res) => {
|
|
const ip = req.ip;
|
|
if (tooManyAttempts(ip)) {
|
|
return res.status(429).json({ error: 'Too many failed attempts. Try again later.' });
|
|
}
|
|
|
|
const username = String(req.body?.username || '').trim();
|
|
const password = String(req.body?.password || '');
|
|
if (!username || !password) {
|
|
return res.status(400).json({ error: 'Username and password are required' });
|
|
}
|
|
|
|
try {
|
|
const result = await pool.query(
|
|
`SELECT id, username, display_name, pass_hash, is_active,
|
|
is_admin, territory
|
|
FROM pf.app_user WHERE lower(username) = lower($1)`,
|
|
[username]
|
|
);
|
|
const user = result.rows[0];
|
|
|
|
// Same message and roughly the same work either way: no unknown
|
|
// user / wrong password / disabled distinction to enumerate.
|
|
const ok = user && user.is_active && verifyPassword(password, user.pass_hash);
|
|
if (!ok) {
|
|
recordFailure(ip);
|
|
return res.status(401).json({ error: 'Invalid username or password' });
|
|
}
|
|
|
|
// New session id on login — an existing cookie can't be fixated.
|
|
req.session.regenerate(err => {
|
|
if (err) {
|
|
console.error(err);
|
|
return res.status(500).json({ error: 'Could not start session' });
|
|
}
|
|
req.session.user = {
|
|
id: user.id,
|
|
username: user.username,
|
|
display_name: user.display_name,
|
|
is_admin: !!user.is_admin,
|
|
territory: Array.isArray(user.territory) ? user.territory : [],
|
|
};
|
|
pool.query(`UPDATE pf.app_user SET last_login_at = now() WHERE id = $1`, [user.id])
|
|
.catch(e => console.error('last_login_at update failed', e));
|
|
req.session.save(err2 => {
|
|
if (err2) {
|
|
console.error(err2);
|
|
return res.status(500).json({ error: 'Could not start session' });
|
|
}
|
|
attempts.delete(ip);
|
|
res.json({ user: req.session.user });
|
|
});
|
|
});
|
|
} catch (err) {
|
|
console.error(err);
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
router.post('/logout', (req, res) => {
|
|
const name = req.session?.cookie && req.app.get('session cookie name');
|
|
req.session.destroy(err => {
|
|
if (err) console.error(err);
|
|
res.clearCookie(name || 'pf.sid');
|
|
res.json({ ok: true });
|
|
});
|
|
});
|
|
|
|
// The UI calls this on load to decide between the login screen and the app.
|
|
router.get('/me', (req, res) => {
|
|
if (!req.session?.user) return res.status(401).json({ error: 'Not authenticated' });
|
|
res.json({ user: req.session.user });
|
|
});
|
|
|
|
return router;
|
|
};
|