feat(F-083): completed feature
This commit is contained in:
89
work/artifacts/F-083/architect.md
Normal file
89
work/artifacts/F-083/architect.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# F-083 — Architect: Customers password reset via email link
|
||||
|
||||
## Goal
|
||||
|
||||
Secure password reset flow:
|
||||
1. Customer enters email → backend always returns 200 (no user enumeration), generates single-use signed token with 1h TTL, stores hash, sends email.
|
||||
2. Customer opens email link → storefront form at `/cuenta/restablecer?token=...` submits new password.
|
||||
3. Confirm endpoint validates token hash + expiry + unused → updates password hash, marks token used.
|
||||
4. Rate limit per IP and per email.
|
||||
5. Admin `/customers` row has a "Send reset link" button that triggers the same flow.
|
||||
6. Audit log entry for each request and confirm.
|
||||
|
||||
## Design
|
||||
|
||||
### Backend
|
||||
|
||||
**Migration `032_password_reset_tokens.js`**: new table
|
||||
```sql
|
||||
CREATE TABLE identity_password_reset_tokens (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id uuid NOT NULL REFERENCES identity_users(id) ON DELETE CASCADE,
|
||||
token_hash text NOT NULL UNIQUE,
|
||||
expires_at timestamptz NOT NULL,
|
||||
used_at timestamptz,
|
||||
request_ip text,
|
||||
request_user_agent text,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX identity_password_reset_tokens_user_idx ON identity_password_reset_tokens(user_id);
|
||||
CREATE INDEX identity_password_reset_tokens_expires_at_idx ON identity_password_reset_tokens(expires_at);
|
||||
```
|
||||
|
||||
**Domain**:
|
||||
- `PasswordResetToken` entity with `isExpired()`, `isUsed()`.
|
||||
- Errors: `InvalidResetTokenError`, `ExpiredResetTokenError`, `UsedResetTokenError`.
|
||||
|
||||
**Repository** (`PgPasswordResetTokenRepository`): `create`, `findValidByHash(hash)`, `markUsed(id)`. On create, invalidates all previous unused tokens for that user.
|
||||
|
||||
**Use cases** (mirror Login/RegisterUser):
|
||||
- `RequestPasswordReset({ email, ip, userAgent })`:
|
||||
- Normalise email; if user does not exist, return success silently (no enumeration).
|
||||
- Invalidate previous unused tokens for that user.
|
||||
- Generate cryptographically random token (32 bytes hex → 64 chars), store `hash(token)`.
|
||||
- Rate limit: per-IP bucket of 5/hour and per-email bucket of 3/hour. Returns success even when rate-limited (no enumeration).
|
||||
- Persist token row with `expires_at = now() + 1h`.
|
||||
- Emit `password_reset.requested` audit log entry.
|
||||
- Trigger email via `notifications.sendPasswordReset({ email, resetUrl, locale })`.
|
||||
- `ConfirmPasswordReset({ token, newPassword })`:
|
||||
- Hash incoming token, look up by hash.
|
||||
- Reject (HTTP 400) if not found / expired / used → same error code in all cases.
|
||||
- Update `users.passwordHash` via `PasswordHasher.hash(newPassword)`.
|
||||
- Mark token used.
|
||||
- Emit `password_reset.confirmed` audit log.
|
||||
|
||||
**Routes**:
|
||||
```
|
||||
POST /auth/password-reset/request { email } -> 200 { ok: true } always
|
||||
POST /auth/password-reset/confirm { token, password } -> 200 { ok: true } | 400
|
||||
```
|
||||
Both are public (no session). Request endpoint should be slow when user not found to equalise timing.
|
||||
|
||||
### Notifications
|
||||
Use existing `notifications.sendEmail` with a new template `password-reset`. Template renders the link with `{{ resetUrl }}`.
|
||||
|
||||
### Admin
|
||||
In `/customers` row, add a button "Enviar enlace de recuperación" that POSTs to `/auth/password-reset/request` with the row's email. Show toast on result.
|
||||
|
||||
### Storefront
|
||||
New page `/cuenta/restablecer` with form for token (from query) + new password + confirm. POSTs to `/auth/password-reset/confirm`.
|
||||
|
||||
## Risk
|
||||
|
||||
Medium. Touches auth & email. Mitigations:
|
||||
- Tokens are stored as hashes (not raw).
|
||||
- Single-use enforcement at DB level (mark used atomically in the same UPDATE that validates expiry).
|
||||
- Rate limit prevents brute force / abuse.
|
||||
- Always-200 on request prevents user enumeration.
|
||||
- Audit log for traceability.
|
||||
|
||||
## Acceptance mapping
|
||||
- "Backend POST /auth/password-reset/request always returns 200" → request handler always 200.
|
||||
- "Signed single-use token with TTL ≥30 min ≤24h" → 1h TTL, hash stored.
|
||||
- "Email sent with /cuenta/restablecer?token=..." → notifications.sendPasswordReset.
|
||||
- "POST /auth/password-reset/confirm rejects invalid/expired/used" → 400 from use case.
|
||||
- "New password works for login" → users.passwordHash updated.
|
||||
- "Admin row action triggers same flow" → POST /auth/password-reset/request.
|
||||
- "Rate limit per IP and per email" → dual-bucket limiter.
|
||||
- "Audit log entries" → security_audit_log rows on request and confirm.
|
||||
- "verify.sh is green" → typecheck + lint + tests.
|
||||
Reference in New Issue
Block a user