Files
website/server/src/middleware/rateLimit.js
wtclaude 10aed49bb6
All checks were successful
PR Checks / client-build (pull_request) Successful in 9m45s
PR Checks / server-tests (pull_request) Successful in 10m42s
PR Checks / bot-install (pull_request) Successful in 9m21s
feat(auth): self-service password reset (backend + web)
Add a full password-reset flow — the prerequisite for the Android app
(docs/android/PLAN.md §8.2), which hands off to the website for reset
rather than shipping a native screen.

Backend:
- password_resets table: stores only the sha256 hash of an opaque 32-byte
  token (mirrors user_invites / mobile_refresh_tokens), single-use, ~1h TTL.
- model/passwordResets + users.getActiveByEmail (email is non-unique, so a
  request can match several accounts, each emailed its own link).
- mailer.sendPasswordReset (fails soft when email is unconfigured).
- Endpoints: POST /auth/password/forgot (always a generic 200 — no account
  enumeration), GET|POST /auth/password/reset/:token. Confirming rotates the
  hash and revokes every session (web cutoff + mobile refresh tokens); it does
  not auto-login, so a 2FA account still passes TOTP next sign-in. Also serves
  SSO-only accounts (null hash) as their set-initial-password path.
- Dedicated request/confirm rate limiters. Swagger regenerated.

Web:
- ForgotPassword + ResetPassword pages, routes /account/forgot and
  /account/reset/:token, and a "Forgot your password?" link on the login page.

Tests: test/passwordResets.test.js (5). All server tests pass; client builds;
end-to-end smoketest against MariaDB passes (no-enumeration, single-use, hash
rotation, session revoke, login with the new password).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NgyHnrNa8WwG3doxvxjuCr
2026-07-19 03:57:13 -05:00

103 lines
3.2 KiB
JavaScript

const rateLimit = require('express-rate-limit')
const log = require('../utils/logger')('ratelimit')
function makeLimiter({ windowMs, max, label, message }) {
return rateLimit({
windowMs,
max,
standardHeaders: true,
legacyHeaders: false,
message: { message },
handler: (req, res, next, options) => {
log.warn(`${label} rate limit exceeded`, { ip: req.ip, path: req.originalUrl })
res.status(options.statusCode).json(options.message)
},
})
}
// Brute-force protection on login.
const loginLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'login',
message: 'Too many login attempts. Please try again later.',
})
// Public self-registration. Mirrors the login cap: a handful of legitimate
// attempts per window, a flood is abuse. The global botScore guard + honeypot
// cover the rest.
const registerLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'register',
message: 'Too many registration attempts. Please try again later.',
})
// Authenticated self-service credential changes (username / password). Tighter
// than login — a signed-in player rarely changes these, and the wrong-current-
// password path also feeds the shared login backoff (see the controller).
const accountChangeLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 10,
label: 'account-change',
message: 'Too many changes. Please try again later.',
})
// Throttle the public contact form.
const contactLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 5,
label: 'contact',
message: 'Too many messages sent. Please try again later.',
})
// Cap mobile refresh-token exchanges per IP. Legitimate apps refresh at most a
// handful of times per window; a flood is either a bug or an attempt to brute
// the refresh endpoint.
const mobileRefreshLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'mobile-refresh',
message: 'Too many refresh attempts. Please try again later.',
})
// Throttle SSO redirect starts per IP — cheap to trigger, and a flood is either a
// bug or an attempt to spin the OAuth flow. Generous enough for real users.
const ssoStartLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 30,
label: 'sso-start',
message: 'Too many sign-in attempts. Please try again later.',
})
// Password-reset requests per IP. Each one can send email, so cap tighter than
// login to blunt email-bombing and enumeration timing probes. The endpoint always
// returns a generic success regardless of match, so honest users never see this.
const passwordResetRequestLimiter = makeLimiter({
windowMs: 60 * 60 * 1000,
max: 5,
label: 'password-reset-request',
message: 'Too many reset requests. Please try again later.',
})
// Reset confirmations (token + new password) per IP. A wrong/expired token is a
// guessing surface; the token itself is 256-bit random, but cap anyway.
const passwordResetConfirmLimiter = makeLimiter({
windowMs: 15 * 60 * 1000,
max: 15,
label: 'password-reset-confirm',
message: 'Too many attempts. Please try again later.',
})
module.exports = {
loginLimiter,
registerLimiter,
accountChangeLimiter,
contactLimiter,
mobileRefreshLimiter,
ssoStartLimiter,
passwordResetRequestLimiter,
passwordResetConfirmLimiter,
}