feat(auth): Phase 7a — email infra, password reset, change password - #173
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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; withoutSMTP_HOSTit logs the full message at WARNING and returns False (honest dev mode: the reset link is read from the service log). Namedmailer.pydeliberately — anemail.pyunder services/shared shadows the stdlibemailpackage 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, purposesreset(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.authAPIhandles 204s, surfaces backenddetailmessages, and doesn't treat change-password's 403 as a dead session.Proof
tsc --noEmitclean, smoke 4/4, all three new pages render 200🤖 Generated with Claude Code