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>
195 lines
11 KiB
Markdown
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).
|