Reset tokens live only in Redis as a paired key shape (mirrors 010's own revocation-denylist pattern) - never in Postgres, never storing the raw token. Password-strength policy is one shared validator called from both the new reset-consume endpoint and 010's existing POST /admin/users. Login rate-limiting reuses the existing checkRateLimit helper from 002's own inbound trust boundary, keyed by submitted email, checked before any credential verification. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2.8 KiB
Data Model: Authentication Hardening
No Postgres schema changes. User.passwordHash (010-identity-auth) is updated in place by a
successful reset; no other model changes.
Redis-only: Password Reset Token
Not a Prisma model — exists only as two paired Redis keys, both expiring together.
| Key | Value | TTL |
|---|---|---|
password-reset:token:<sha256(token)> |
userId |
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES |
password-reset:user:<userId> |
sha256(token) |
same |
Issuing (requestPasswordReset): if password-reset:user:<userId> already has a value,
delete password-reset:token:<that value> first (invalidating the prior token — FR-002), then
set both new keys.
Consuming (resetPassword): GET password-reset:token:<sha256(presented token)> → if
absent, reject (FR-004: invalid/expired/already-used, indistinguishably — the key not existing
covers all three cases identically, which is itself desirable: a consumer can't tell "expired"
from "already used" from "never existed," matching the same non-leaking spirit as 010's own
login-failure parity). If present, resolve userId, delete both keys (single-use), update the
password.
Configuration (new)
| Env var | Purpose | Default |
|---|---|---|
PASSWORD_MIN_LENGTH |
Minimum password length, enforced everywhere a password is set | 10 |
PASSWORD_RESET_TOKEN_LIFETIME_MINUTES |
How long a reset token stays valid | 30 |
LOGIN_RATE_LIMIT_MAX_ATTEMPTS |
Max login attempts per email per window | 5 |
LOGIN_RATE_LIMIT_WINDOW_SECONDS |
The window LOGIN_RATE_LIMIT_MAX_ATTEMPTS applies over |
300 |
Validation / Business Rules
requestPasswordReset(email): always returns the same shape regardless of whetheremailresolves to a real, active account (FR-001) — internally, only issues a real token when it does; the caller-visible response is identical either way.resetPassword(token, newPassword):validatePasswordStrengthruns first (fail fast on the cheap, stateless check), then the token is looked up. Unlike login/reset-request, account-existence secrecy doesn't apply here — FR-004 and User Story 2 both call for their own, specific rejection reasons ("password too short" vs. "invalid or expired token"); only FR-001's account-existence question needs the identical-response treatment, not this endpoint's two legitimately-different failure modes.login(email, password): the rate-limit check (login:<email>) runs first, beforerepo.findByEmail/verifyPassword(FR-007) — a rate-limited request never reaches the identical-failure-response logic 010 already built; it gets its own distinct rate-limit rejection instead (Acceptance Scenario 1's own point: a rate limit is an honestly-different condition from a credentials failure, not disguised as one).