Files
support_backend/specs/013-auth-hardening/data-model.md
T
saqib mirandClaude Sonnet 5 52f1fa3db0 docs(013-auth-hardening): plan, research, data model, contract, quickstart
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>
2026-09-07 17:26:49 +05:30

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 whether email resolves 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): validatePasswordStrength runs 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, before repo.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).