Files
support_backend/specs/013-auth-hardening/spec.md
T
saqib mirandClaude Sonnet 5 b016e77b70 docs(013-auth-hardening): spec for password reset, password policy, login rate-limiting
Phase 11's "security hardening pass" (docs/10-implementation-roadmap.md),
first slice, per explicit user direction. Closes the two concrete gaps
010-identity-auth's own Assumptions named as out of its scope. MFA is
intentionally excluded as its own larger follow-up feature. Email
delivery for password-reset is stubbed (server-side log) per explicit
user decision, since this codebase has no email infrastructure at all
today.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-07 17:23:49 +05:30

11 KiB

Feature Specification: Authentication Hardening

Feature Branch: 013-auth-hardening

Created: 2026-09-07

Status: Draft

Input: User description: "Phase 11 security hardening pass, first slice: password-reset (self-service, with a stubbed email-delivery step logging the reset link instead of actually emailing it), a password-strength policy applied wherever a password is set, and login rate-limiting to slow down credential-stuffing/brute-force attempts against POST /auth/login. MFA is a separate, larger follow-up feature, not this one's scope."

User Scenarios & Testing (mandatory)

User Story 1 - A user resets a forgotten password (Priority: P1)

A user who has forgotten their password requests a reset; the system issues a single-use, short-lived reset token and "delivers" it (this feature stubs delivery — see Assumptions — a later feature wires up real email). The user submits the token with a new password and can log in with it immediately afterward.

Why this priority: 010-identity-auth explicitly deferred this ("the smallest viable fix today is an admin recreating the account") — this is the first real self-service fix for a locked-out user, and the whole reason this feature exists.

Independent Test: Request a reset for a known account; retrieve the issued token (via the stub's own log output, since there's no real inbox to check); consume it with a new password; confirm login succeeds with the new password and fails with the old one.

Acceptance Scenarios:

  1. Given an existing account, When its email requests a password reset, Then a single-use reset token is issued and "delivered" via the stub — the response itself never includes the token (it's not a client-visible value, matching a real email-delivery contract).
  2. Given an email that doesn't correspond to any account, When it requests a password reset, Then the response is identical to Scenario 1's own success response — never revealing whether the account exists (mirrors 010's own FR-002 philosophy).
  3. Given a valid, unexpired reset token, When it's submitted with a new password meeting the password-strength policy (User Story 2), Then the account's password is updated and the token becomes unusable — a second consume attempt with the same token is rejected.
  4. Given an expired or already-used reset token, When it's submitted, Then the request is rejected with a clear, specific reason — never silently accepted.
  5. Given a freshly-reset password, When the user logs in with it, Then login succeeds; the old password no longer works.

User Story 2 - Password strength is enforced wherever a password is set (Priority: P1)

Whenever a password is set — an admin creating a new staff account, or a user resetting their own — the system enforces a minimum strength policy and rejects a weak password with a specific, actionable reason.

Why this priority: 010-identity-auth's own admin-account-creation (POST /admin/users) and this feature's own password-reset both accept a plaintext password with no strength check today — the most basic hardening gap a "security hardening pass" exists to close first.

Independent Test: Attempt to create an account (or reset a password) with a password that fails the policy (too short); confirm a clear rejection naming what's wrong. Repeat with a policy-meeting password; confirm it succeeds.

Acceptance Scenarios:

  1. Given the admin account-creation endpoint, When a password shorter than the configured minimum length is submitted, Then the request is rejected with a message naming the actual requirement, not a generic validation error.
  2. Given the password-reset consume endpoint, When a policy-violating password is submitted, Then it's rejected the same way — one policy, enforced identically everywhere a password is ever set.
  3. Given a password meeting the policy, When it's submitted to either endpoint, Then it's accepted.

User Story 3 - Login attempts are rate-limited (Priority: P1)

Repeated login attempts against the same account within a short window are throttled, slowing down credential-stuffing and brute-force attacks without permanently locking out a legitimate user who mistypes their password a few times.

Why this priority: POST /auth/login has no attempt limit today — an attacker can try passwords against a known email address as fast as the network allows. This is the other baseline hardening gap named explicitly in 010-identity-auth's own Assumptions.

Independent Test: Submit repeated failed login attempts for the same email within the configured window; confirm attempts beyond the configured maximum are rejected with a rate-limit response, distinct from an authentication failure; confirm a successful login for a different account is unaffected.

Acceptance Scenarios:

  1. Given the configured maximum login attempts per window has been reached for one email, When another attempt is made for that same email within the window, Then it's rejected with a clear rate-limit response (not the identical-failure-response body User Story 1/010 uses for wrong credentials — a rate limit is a different, honestly-reported condition).
  2. Given the same exhausted window, When a login attempt is made for a different email, Then it proceeds normally — the limit is per-account, not global.
  3. Given the rate-limit window has elapsed, When a new attempt is made for the previously-limited email, Then it's evaluated normally again.

Edge Cases

  • What happens if a user requests a password reset for the same account multiple times before consuming the first token? Each request issues its own new token; consuming any valid, unexpired one succeeds, and consuming one invalidates all of that account's other outstanding reset tokens (never allowing two guesses to both later succeed independently).
  • What happens if a reset token is consumed for an account that was deactivated after the token was issued but before it was used? The reset is rejected — reactivating a deactivated account is an admin action (010's own domain), not something a password-reset flow performs incidentally.
  • What happens to a rate-limited login attempt that would have actually succeeded (correct password, but the account is rate-limited from prior failed attempts)? It's still rejected — the rate limit is evaluated before credentials, exactly like a real brute-force defense must be, not skipped for a lucky correct guess.

Requirements (mandatory)

Functional Requirements

  • FR-001: The system MUST let a user request a password reset by email, always returning an identical response regardless of whether the email corresponds to an existing account (mirrors 010's FR-002).
  • FR-002: The system MUST issue a single-use, time-limited reset token per request, and MUST invalidate a token immediately upon use or upon a newer token being issued for the same account.
  • FR-003: The system MUST "deliver" the reset token via a clearly-labeled stub (server-side log output) rather than a real email — this feature does not add email-sending infrastructure (Assumptions).
  • FR-004: The system MUST let a user consume a valid reset token with a new password, updating the account's password hash and rejecting an invalid, expired, or already-used token with a specific, distinguishable reason.
  • FR-005: The system MUST enforce one configured password-strength policy (at minimum, a minimum length) identically at every point a password is ever set — admin account creation and password-reset consumption alike — never two different or duplicated policies.
  • FR-006: The system MUST rate-limit POST /auth/login attempts per submitted email within a configured window, rejecting attempts beyond the configured maximum with a response distinct from a credentials failure.
  • FR-007: The login rate limit MUST be evaluated before password verification, so a rate-limited attempt is rejected regardless of whether the submitted password is actually correct.
  • FR-008: The system MUST NOT lock an account indefinitely — the rate limit is a rolling/ fixed window that clears on its own, not a manual-unlock-required lockout.

Key Entities

  • Password Reset Token: A single-use, time-limited credential tying one request to one account, consumed exactly once to authorize a password change.
  • Password Policy: The configured minimum-strength rule(s) applied identically at every password-setting point in the system.
  • Login Attempt Counter: A rolling/fixed-window count of failed login attempts per submitted email, backing the rate limit.

Success Criteria (mandatory)

Measurable Outcomes

  • SC-001: 100% of password-reset requests (existing or nonexistent account) receive an identical response — 0% reveal account existence.
  • SC-002: 100% of password-reset tokens are usable exactly once; a second consume attempt with the same token fails 100% of the time.
  • SC-003: 100% of passwords accepted by any password-setting endpoint meet the configured policy; 0% of policy-violating passwords are ever stored.
  • SC-004: An account subjected to more login attempts than the configured maximum within the configured window is rejected on 100% of the excess attempts, regardless of whether the submitted password was correct.

Assumptions

  • Email delivery is stubbed, not real — the reset token is logged server-side rather than emailed, per explicit user decision; wiring up a real email provider is a separate, later concern once that infrastructure choice is made.
  • MFA is out of scope — a separate, larger follow-up feature; this pass only closes the two gaps 010-identity-auth's own Assumptions named as "not this feature's job."
  • No account self-registration — unchanged from 010; password reset only ever applies to an existing account, never creates one.
  • The password-strength policy is a minimum-length rule, configurable, not a fixed hardcoded value (docs/10-implementation-roadmap.md's own "never hardcode a placeholder value and ship it as final" instruction) — the exact minimum is a CONFIGURABLE value with a reasonable default, not a business-confirmed final number; additional complexity rules (character classes, breached-password checks) are a possible future enhancement, not required here.
  • Rate limiting is per submitted email, not per IP — the most direct defense against credential-stuffing a specific known account; IP-based limiting is a possible future enhancement layered on top, not required here.
  • Existing sessions are not force-revoked on password reset — a reset invalidates the password (and all other outstanding reset tokens for that account), but any already-issued, unexpired login session remains valid until its own natural expiry (010's own 4-hour token lifetime bounds this) rather than requiring a database check on every authenticated request (010's own performance goal of a single Redis round trip per request, no DB read).