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