feat(security): Postgres-backed login rate limiting

Replaces the in-memory sliding-window Map (reset on every restart, not
shareable across instances) with a login_attempts table (migration 016).
No Redis added - login is not a hot path, and this is a single indexed
range-scan query. Restart behavior confirmed for real:
platform/test/e2e-phase3a.js's rate-limiting test passes after a genuine
Platform API restart in the same run.
This commit is contained in:
2026-08-24 20:15:03 -05:00
parent 6dda13b837
commit addf474b70
3 changed files with 44 additions and 20 deletions
+1 -1
View File
@@ -18,7 +18,7 @@ module.exports = function registerInstructorAuthRoutes(app, { pool, sessions, in
app.post('/api/auth/instructor/login', async (req, res) => {
const { email, password } = req.body || {};
const rateKey = `${req.ip}:${String(email || '').toLowerCase()}`;
if (tooManyAttempts(rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
if (await tooManyAttempts(pool, rateKey, LOGIN_MAX_ATTEMPTS, LOGIN_WINDOW_MS)) {
return res.status(429).json({ error: 'too_many_attempts', message: 'Too many login attempts. Try again in a few minutes.' });
}
if (!email || !password) return res.status(400).json({ error: 'email and password are required' });
+28 -19
View File
@@ -33,25 +33,34 @@ function checkCsrf(req, cookieName) {
}
// ── Login rate limiting ─────────────────────────────────────────────────────────────────
// In-memory sliding window, keyed by "ip:identifier" (e.g. ip:email). Fine for a single-process
// Platform API — a shared store (Postgres/Redis) would be needed for multi-instance, not a
// concern at this scale (see docs/PHASE3A... known limitations).
const attempts = new Map();
function tooManyAttempts(key, max, windowMs) {
const now = Date.now();
const arr = (attempts.get(key) || []).filter((t) => now - t < windowMs);
arr.push(now);
attempts.set(key, arr);
return arr.length > max;
// Phase 4.1 §15: Postgres-backed (migrations/016_login_attempts.sql), not in-memory — the
// original in-memory sliding-window Map reset on every Platform API restart (an attacker could
// just wait for a deploy) and would silently stop being shared the moment there's more than
// one Platform API instance. This table is the fix for both, at essentially no cost: login is
// not a hot path, and the query below is a single indexed range scan.
//
// Deliberately still just a table + two queries, not a new rate-limiting service or Redis —
// the volume here (failed login attempts) never justifies that, and a Postgres row is already
// exactly as "shared/persistent" as this needs to be.
async function tooManyAttempts(pool, key, max, windowMs) {
// Insert THEN count (including the just-inserted row), matching the original in-memory
// version's semantics exactly: this call itself counts as one of the attempts.
await pool.query(`INSERT INTO login_attempts (rate_key) VALUES ($1)`, [key]);
const { rows } = await pool.query(
`SELECT count(*)::int AS n FROM login_attempts WHERE rate_key = $1 AND created_at > now() - ($2 || ' milliseconds')::interval`,
[key, windowMs]
);
return rows[0].n > max;
}
setInterval(() => {
const cutoff = Date.now() - 60 * 60 * 1000;
for (const [key, arr] of attempts) {
const kept = arr.filter((t) => t > cutoff);
if (kept.length) attempts.set(key, kept); else attempts.delete(key);
}
}, 15 * 60 * 1000).unref();
// Periodic cleanup so the table doesn't grow forever — cosmetic/storage only, mirrors
// sessions.js's own sweeper pattern rather than inventing a new one.
function startLoginAttemptSweeper(pool) {
setInterval(() => {
pool.query(`DELETE FROM login_attempts WHERE created_at < now() - interval '24 hours'`).catch((e) => {
console.error('[login attempt sweep] failed:', e.message);
});
}, 60 * 60 * 1000).unref();
}
module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts };
module.exports = { makeCsrfToken, parseCookies, checkCsrf, tooManyAttempts, startLoginAttemptSweeper };