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 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) {
|
||||
// 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
|
||||
|
|
@ -92,15 +150,24 @@ export async function authRoutes(app) {
|
|||
const { email, password } = request.body || {}
|
||||
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(
|
||||
'SELECT id, password_hash, active FROM users WHERE email = $1',
|
||||
[email.toLowerCase().trim()]
|
||||
[normalised]
|
||||
)
|
||||
|
||||
if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) {
|
||||
recordLoginFailure(throttleKey)
|
||||
return reply.status(401).send({ error: 'Invalid email or password' })
|
||||
}
|
||||
|
||||
_loginFailures.delete(throttleKey)
|
||||
|
||||
const full = await getUserWithApps(user.id)
|
||||
const token = await signToken({
|
||||
sub: full.email,
|
||||
|
|
|
|||
|
|
@ -2,7 +2,12 @@ import { pool } from '../db.js'
|
|||
import { hashPassword, verifyPassword, signToken } from '../jwt.js'
|
||||
import { findEmployeeByEmail, getEmployeeDepartments } from '../workforce.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) {
|
||||
// POST /api/auth/register
|
||||
|
|
@ -15,6 +20,9 @@ export async function registerRoutes(app) {
|
|||
|
||||
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.
|
||||
// Prevents rapid-fire spam while still allowing genuine "I didn't get it" retries after cooldown.
|
||||
const { rows: [existing] } = await pool.query(
|
||||
|
|
@ -67,6 +75,9 @@ export async function registerRoutes(app) {
|
|||
if (!email || !pin || !password) {
|
||||
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) {
|
||||
return reply.status(400).send({ error: 'Password must be at least 8 characters' })
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue