You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat: rate-limit the passwordless OTP issuance endpoint server-side
POST /auth/login/otp (emitOTP) had zero server-side throttle, unlike the
MFA resend endpoint's 2fa.rate:resend. Reuses TwoFactorRateLimitMiddleware
/TwoFactorRateLimitService via a new 'otp' action instead of duplicating a
parallel middleware - the counting logic (cache-backed fixed window, 429
JSON shape) was already subject-agnostic; only the subject-resolution step
needed a branch, since emitOTP() never writes any session state to key on
(verified: zero Session::put calls in that method) unlike the session-keyed
MFA actions.
isRateLimited()/increment()/cacheKey() widen from int to
string|int - source-compatible with both existing call sites
(TwoFactorRateLimitMiddleware, UserController::postLogin()), which already
pass an int.
The otp subject is the submitted email, lowercased and trimmed - not just
trimmed like postLogin()'s username normalization, which is safe only
because it feeds a case-insensitive DB lookup before ever reaching a rate
limiter. otp has no such lookup; the raw string IS the cache key, so
trim-only normalization would let an attacker reset the budget every
request by cycling the target email's casing (verified live: users.email
collation is utf8mb3_unicode_ci). Caught and fixed via spec-review before
implementation - see the case-insensitivity test below.
New config keys max_otp_email_requests/otp_email_window_minutes (both
default 5/15min, same as the MFA resend budget) are kept independent so
ops can tune the anonymous endpoint separately. Client: emitOtpAction's
error handler now shows a specific 'Too many attempts' message on 429
instead of the generic fallback.
Two new PHPUnit tests: threshold + per-email isolation, and the
case-insensitivity fix specifically. Both verified RED before
implementation. flushRateLimitCounters() extended to also clear the new
email-keyed cache entries between tests - a real cross-test contamination
bug surfaced when running the full suite (an early test failed because
the new tests' counters leaked into it), not merely anticipated.
Verified: full TwoFactorLoginFlowTest suite green (34 tests, 145
assertions, includes regression coverage for the existing MFA rate
limits). Live end-to-end in-browser: a real 429 with the specific
snackbar message, confirmed against localhost with the limit temporarily
lowered to 1. Also found and fixed, as a side effect of that live check,
a pre-existing storage/framework/cache permission issue unrelated to this
change's code (files owned by root from prior root-run test sessions
blocked www-data's cache writes) - not part of this commit's diff.
Plan: docs/plans/2026-07-23-passwordless-otp-resend-cooldown.md, Task 2.
0 commit comments