Add rate limiting: login (5 fails/15min per email+IP), register + verify per-IP throttles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
jtricerolph 2026-07-07 15:51:16 +00:00
parent 276a841adf
commit e97a3dc89f
2 changed files with 80 additions and 2 deletions

View file

@ -6,6 +6,64 @@ const DOMAIN = process.env.DOMAIN || 'localhost'
const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30') const SESSION_DAYS = parseInt(process.env.SESSION_DAYS || '30')
const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60 const COOKIE_MAX_AGE = SESSION_DAYS * 24 * 60 * 60
// In-memory login throttle: 5 failures per email+IP within 15 minutes.
// Single-container deployment, so no shared store needed.
const LOGIN_MAX_FAILURES = 5
const LOGIN_WINDOW_MS = 15 * 60_000
const _loginFailures = new Map() // key → [timestamps]
function loginKey(request, email) {
const ip = request.headers['x-forwarded-for']?.split(',')[0].trim() || request.ip
return `${ip}|${email}`
}
function isLoginBlocked(key) {
const now = Date.now()
const recent = (_loginFailures.get(key) ?? []).filter(t => now - t < LOGIN_WINDOW_MS)
if (recent.length) _loginFailures.set(key, recent)
else _loginFailures.delete(key)
return recent.length >= LOGIN_MAX_FAILURES
}
function recordLoginFailure(key) {
const list = _loginFailures.get(key) ?? []
list.push(Date.now())
_loginFailures.set(key, list)
}
// Prune stale entries so the map can't grow unbounded
setInterval(() => {
const now = Date.now()
for (const [key, list] of _loginFailures) {
const recent = list.filter(t => now - t < LOGIN_WINDOW_MS)
if (recent.length) _loginFailures.set(key, recent)
else _loginFailures.delete(key)
}
}, 10 * 60_000).unref()
// Generic per-IP throttle for public endpoints (register, etc.)
export function makeIpThrottle(maxRequests, windowMs) {
const hits = new Map() // ip → [timestamps]
setInterval(() => {
const now = Date.now()
for (const [ip, list] of hits) {
const recent = list.filter(t => now - t < windowMs)
if (recent.length) hits.set(ip, recent)
else hits.delete(ip)
}
}, 10 * 60_000).unref()
return function check(request) {
const ip = request.headers['x-forwarded-for']?.split(',')[0].trim() || request.ip
const now = Date.now()
const recent = (hits.get(ip) ?? []).filter(t => now - t < windowMs)
if (recent.length >= maxRequests) return false
recent.push(now)
hits.set(ip, recent)
return true
}
}
export function cookieOpts(request, clear = false) { export function cookieOpts(request, clear = false) {
// Mark the cookie Secure only when the request actually arrived over HTTPS // Mark the cookie Secure only when the request actually arrived over HTTPS
// (via NPM's X-Forwarded-Proto). Over plain HTTP on the LAN, a Secure cookie // (via NPM's X-Forwarded-Proto). Over plain HTTP on the LAN, a Secure cookie
@ -92,15 +150,24 @@ export async function authRoutes(app) {
const { email, password } = request.body || {} const { email, password } = request.body || {}
if (!email || !password) return reply.status(400).send({ error: 'Email and password required' }) if (!email || !password) return reply.status(400).send({ error: 'Email and password required' })
const normalised = email.toLowerCase().trim()
const throttleKey = loginKey(request, normalised)
if (isLoginBlocked(throttleKey)) {
return reply.status(429).send({ error: 'Too many failed attempts — try again in 15 minutes' })
}
const { rows: [user] } = await pool.query( const { rows: [user] } = await pool.query(
'SELECT id, password_hash, active FROM users WHERE email = $1', 'SELECT id, password_hash, active FROM users WHERE email = $1',
[email.toLowerCase().trim()] [normalised]
) )
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) { if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
recordLoginFailure(throttleKey)
return reply.status(401).send({ error: 'Invalid email or password' }) return reply.status(401).send({ error: 'Invalid email or password' })
} }
_loginFailures.delete(throttleKey)
const full = await getUserWithApps(user.id) const full = await getUserWithApps(user.id)
const token = await signToken({ const token = await signToken({
sub: full.email, sub: full.email,

View file

@ -2,7 +2,12 @@ import { pool } from '../db.js'
import { hashPassword, verifyPassword, signToken } from '../jwt.js' import { hashPassword, verifyPassword, signToken } from '../jwt.js'
import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js' import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.js'
import { sendPinEmail } from '../email.js' import { sendPinEmail } from '../email.js'
import { getUserWithApps, cookieOpts } from './auth.js' import { getUserWithApps, cookieOpts, makeIpThrottle } from './auth.js'
// 10 registration requests per IP per hour — each triggers a Workforce lookup + email
const registerThrottle = makeIpThrottle(10, 60 * 60_000)
// 20 verify attempts per IP per hour — PIN guessing across emails
const verifyThrottle = makeIpThrottle(20, 60 * 60_000)
export async function registerRoutes(app) { export async function registerRoutes(app) {
// POST /api/auth/register // POST /api/auth/register
@ -15,6 +20,9 @@ export async function registerRoutes(app) {
const respond = () => reply.send({ sent: true }) const respond = () => reply.send({ sent: true })
// Silently drop over-limit requests — same response as success, no enumeration signal
if (!registerThrottle(request)) return respond()
// Rate limit: if a PIN was sent within the last 2 minutes, silently pretend we sent again. // Rate limit: if a PIN was sent within the last 2 minutes, silently pretend we sent again.
// Prevents rapid-fire spam while still allowing genuine "I didn't get it" retries after cooldown. // Prevents rapid-fire spam while still allowing genuine "I didn't get it" retries after cooldown.
const { rows: [existing] } = await pool.query( const { rows: [existing] } = await pool.query(
@ -67,6 +75,9 @@ export async function registerRoutes(app) {
if (!email || !pin || !password) { if (!email || !pin || !password) {
return reply.status(400).send({ error: 'Email, PIN and password are required' }) return reply.status(400).send({ error: 'Email, PIN and password are required' })
} }
if (!verifyThrottle(request)) {
return reply.status(429).send({ error: 'Too many attempts — try again later' })
}
if (password.length < 8) { if (password.length < 8) {
return reply.status(400).send({ error: 'Password must be at least 8 characters' }) return reply.status(400).send({ error: 'Password must be at least 8 characters' })
} }