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>
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:
- 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).
- 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).
- 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.
- 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.
- 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:
- 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.
- 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.
- 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:
- 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).
- 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.
- 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/loginattempts 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 aCONFIGURABLEvalue 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).