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:
parent
276a841adf
commit
e97a3dc89f
2 changed files with 80 additions and 2 deletions
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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' })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue