Skip to content

feat(auth): Phase 7a — email infra, password reset, change password - #173

Merged
alovladi007 merged 1 commit into
mainfrom
feat/account-lifecycle
Aug 4, 2026
Merged

feat(auth): Phase 7a — email infra, password reset, change password#173
alovladi007 merged 1 commit into
mainfrom
feat/account-lifecycle

Conversation

@alovladi007

Copy link
Copy Markdown
Owner

Why

Real users need account recovery before deploy: the platform had no email capability, no forgot-password, no change-password — a lost password was unrecoverable without SQL. First of two Phase 7 (account lifecycle) PRs; the second adds admin user management + invitations on this foundation.

What

Backend

  • services/shared/mailer.py — env-driven SMTP sender; without SMTP_HOST it logs the full message at WARNING and returns False (honest dev mode: the reset link is read from the service log). Named mailer.py deliberately — an email.py under services/shared shadows the stdlib email package on the containers' PYTHONPATH and crashes smtplib itself (caught live when the lims container died on reload).
  • password_reset_tokens (migration 0024, tenant_isolation RLS, registered in the tenancy audit): SHA-256-only storage, purposes reset (30 min) / invite (7 days), single-use.
  • POST /api/auth/forgot-password — enumeration-safe 204 always, login rate-limit tier, OIDC accounts excluded.
  • POST /api/auth/reset-password — one generic 400 for unknown/expired/used tokens; 422 under the 12-char floor (same policy as bootstrap_admin).
  • POST /api/auth/change-password — authed; verifies current password; explicit 400 for SSO-only accounts.

Frontend

  • /forgot-password + /reset-password (public paths; the latter serves future invite links via ?welcome=1), "Forgot your password?" on /login, and /system/account ("My Account") with the change-password form. authAPI handles 204s, surfaces backend detail messages, and doesn't treat change-password's 403 as a dead session.

Proof

  • 12 new API tests — token lifecycle, single-use, expiry, garbage tokens, enumeration safety, policy floors, wrong-current 403: lims suite 80 passed, shared 88 passed
  • Live round-trip on the dev stack: forgot 204 → link from service log → reset 204 → new password logs in (200), old password dead (401), token reuse rejected (400); change-password wrong-current 403 → 204 → login with new password 200
  • tsc --noEmit clean, smoke 4/4, all three new pages render 200

🤖 Generated with Claude Code

The platform had no self-service account recovery: no email capability,
no forgot-password, no change-password. After bootstrap_admin, a lost
password was unrecoverable without SQL.

Backend:
- services/shared/mailer.py: env-driven SMTP sender (SMTP_HOST/PORT/
  USERNAME/PASSWORD/STARTTLS/FROM, APP_BASE_URL). Without SMTP_HOST the
  message is logged at WARNING and send returns False — honest dev mode;
  the reset link is read from the service log. Named mailer.py, NOT
  email.py: the containers put services/shared on PYTHONPATH, where an
  email.py shadows the stdlib package and crashes smtplib itself
  (found live — the lims container died on reload).
- password_reset_tokens table (migration 0024, tenant_isolation RLS,
  registered in the test_tenancy audit): sha256-only storage, purpose
  reset|invite (30 min / 7 days), single-use.
- POST /api/auth/forgot-password: enumeration-safe 204 always, login
  rate-limit tier, OIDC accounts excluded (their IdP owns recovery).
- POST /api/auth/reset-password: one generic 400 for unknown/expired/
  used; consumes the token; 422 under the 12-char floor (shared with
  bootstrap_admin).
- POST /api/auth/change-password (authed): verify current, set new;
  explicit 400 for SSO-only accounts.

Frontend:
- /forgot-password + /reset-password pages (public paths), the latter
  serving both reset and future invite links (?welcome=1).
- "Forgot your password?" link on /login.
- /system/account: identity summary + change-password form ("My
  Account" sidebar entry); authAPI in api-client (204-aware, surfaces
  backend detail messages, and does NOT treat change-password's 403 as
  a dead session).

Proof: 12 new API tests (token lifecycle, reuse, expiry, enumeration
safety, policy floors) — lims suite 80 passed; shared 88 passed; LIVE
round-trip against the dev stack: forgot 204 → link from log → reset
204 → new password logs in, old 401, reuse 400; change-password 403 on
wrong current / 204 / login with new; tsc clean; smoke 4/4; new pages
render 200.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alovladi007
alovladi007 merged commit 7ad595c into main Aug 4, 2026
18 checks passed
@alovladi007
alovladi007 deleted the feat/account-lifecycle branch August 4, 2026 02:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant