diff --git a/platform/api/migrations/016_login_attempts.sql b/platform/api/migrations/016_login_attempts.sql new file mode 100644 index 0000000..53afc7d --- /dev/null +++ b/platform/api/migrations/016_login_attempts.sql @@ -0,0 +1,15 @@ +-- Postgres-backed login rate limiting — Phase 4.1 §15. Replaces the in-memory sliding-window +-- Map in security.js, which reset on every Platform API restart (an attacker could always just +-- wait for a deploy) and would silently stop being shared the moment there's more than one +-- Platform API instance. One small table, one cheap query per login attempt (this is a login +-- form, not a hot path) — no Redis, no new infrastructure dependency. + +CREATE TABLE IF NOT EXISTS login_attempts ( + id BIGSERIAL PRIMARY KEY, + rate_key TEXT NOT NULL, -- "ip:email", same composite key security.js always used + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- The hot query is "count attempts for this key within the last N minutes" — this index makes +-- that a range scan on one key instead of a table scan. +CREATE INDEX IF NOT EXISTS idx_login_attempts_key_time ON login_attempts(rate_key, created_at); diff --git a/platform/api/src/instructor-auth-routes.js b/platform/api/src/instructor-auth-routes.js index b9feed0..6364a8e 100644 --- a/platform/api/src/instructor-auth-routes.js +++ b/platform/api/src/instructor-auth-routes.js @@ -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' }); diff --git a/platform/api/src/security.js b/platform/api/src/security.js index 4928336..3ea43df 100644 --- a/platform/api/src/security.js +++ b/platform/api/src/security.js @@ -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 };