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

195 lines
11 KiB
Markdown

# 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).