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:
@@ -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' });
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user