fix(auth): password policy + current-password check for the superuser (#1285) - #1319
Conversation
…er (#1285) The bootstrap account (superuser, terminal access) could be registered with 'a', emptied via PATCH /users/me {"password":""}, and have its password or email changed with nothing but a JWT - which logout does not revoke. - UserManager.validate_password: >= 12 chars and not the email. fastapi-users calls it on register and on every password PATCH, including ''. - UserManager.update: a password or email change needs current_password, verified against the target user on both /users/me and /users/{id} (exempting the superuser route would let the sole superuser address itself). UserUpdate.current_password is never written to the row. - cf auth set-password applies the same policy (otherwise it is the way round). - The register form gets minLength=12; sign-in does not (legacy passwords). - cf auth register shows the policy reason instead of a dict. Test data: registration fixtures used 'secret123' (9 chars) and now use a 12+ char value; the login page tests likewise. No assertion changed.
Mutation check: removing it changed no outcome. SQLAlchemyUserDatabase.update setattr()s an unmapped attribute, and UserRead never returns it.
…CH (#1285) Codex review (P2): fastapi-users validates against the old row, so setting email and password to the same value in one request passed the email rule.
Cross-family review (codex, 3 rounds)
|
|
Claude finished @frankbria's task in 12m 30s —— View job Bug-hunting review complete
Method note: read-only review — no builds/tests executed (per review guardrails).
Verified clean (candidates investigated and discarded):
The "Test Suite (Unit + E2E)" CI run was still in progress at review time; nothing above depends on its outcome. |
ReviewScope: password policy (>=12 chars, not-the-email) plus a Code quality / correctness
Minor / non-blocking
Test coverageThorough — 21 new tests across registration, both PATCH routes ( SecurityThis closes a real hole (permanent account takeover via a long-lived, non-revocable JWT once one is leaked) and the fix is appropriately scoped — no over-reach into unrelated auth surface. The known limitations called out in the PR description (no rate limiting on Overall: solid, well-tested, well-documented security fix. Nothing blocking; the null-password edge case above is worth a quick manual check but not a required change before merge. |
claude-review: normalizeAuthError mapped every register 400 to 'email already
exists', so a password==email rejection (which passes minLength) read as a
duplicate account. Use detail.reason when the server sends one.
Also pins that an explicit {"password": null} PATCH is a no-op (200), not a
bypass of the current-password check or a 500.
|
Response to the claude-review summary:
|
|
Claude finished @frankbria's task in 8m 42s —— View job Bug-hunting review — re-run on the latest pushScope: the two changes in
✅ GLM review: no defects found.
What was verified (candidates investigated and discarded):
Not defects, already documented by the author in the PR/known limitations: |
ReviewScope: password policy (at least 12 chars, not-the-email) on register + both PATCH routes, plus a current_password gate for changing password/email, mirrored in cf auth set-password. This PR already went through three codex rounds and two prior claude reviews (bug-hunting + general), and every point raised there has actually been addressed in the current diff:
I re-read manager.py, schemas.py, auth_commands.py, and the web UI changes independently and did not find a new correctness or security issue beyond what has already been raised and fixed. A few smaller notes:
Test coverage: thorough and well-organized -- registration, both PATCH routes, the email-change interaction, the offline CLI path, and the web UI minLength/error-mapping behavior are all covered (21 backend tests + web tests). The TestOfflineSetPassword test asserting get_db_for_cli is never called on a bad password is a nice way to pin fail before touching the DB. Security: the fix is scoped correctly and closes a real hole (a leaked, non-revocable 24h JWT converting into a permanent account takeover). Known limitations (no rate limit on current_password guessing, /users/{id} checking the target's password rather than an admin's until a second real user exists) are accurately documented rather than silently left out. Overall: solid, and consistent with the extensive review already done on this PR. Nothing here should block merge; the audit-logging gap is worth a follow-up issue rather than scope creep on this one. |
Summary
Implements #1285: [P2.49] a password policy for the sole superuser, and a current-password check before its password or email can change.
UserManager.validate_password→password_policy_error(): at least 12 characters, and not the email (case-insensitive). fastapi-users calls it on register and on every password PATCH,""included, so one hook covers all three routes.UserManager.update: changing the password or email requirescurrent_password, checked against the target user on/users/meand on/users/{id}. When one PATCH changes both email and password, the new password is also checked against the new email (codex review P2).UserUpdate.current_passwordis an optional field. It is not a column, andUserReadnever returns it.cf auth set-passwordapplies the same rule; without it, it would be the way around the policy.minLength={12}on register only. Sign-in has none, because an existing account may predate the policy.cf auth registerand the web sign-up form both show the policy reason. The web form used to map every register 400 to "email already exists" (claude-review).Acceptance Criteria
validate_passwordrequires a minimum length of 12 and rejects a password equal to the email: register + both PATCH routestests/auth/test_password_policy_1285.py, 21 tests)Demo evidence (live
codeframe serveon a fresh DB,tasks/demo_1285.sh)Browser (Playwright on
next dev): sign-up with a 5-character password shows "Please lengthen this text to 12 characters or more", and no/auth/registerrequest is sent.Test Plan
tests/auth, the auth/login/rate-limit/enforcement UI tests, audit and disabled-account tests): all pass after the fixture bumplogin.test.tsx, 8 passing, including a new minLength assertioncreate_update_dictoverride strippingcurrent_password, which turned out to be dead code, so it was removed.Known Limitations / Intentionally Deferred
/users/{id}checks the target user's own current password. Today there is only one real account (a second user is [P2.67] Hosted-launch prerequisites (not needed for self-host): no way to create a second user, no per-tenant spend cap, no tenant-owned LLM keys #1303), so an admin resetting someone else's password is not a flow that exists. When [P2.67] Hosted-launch prerequisites (not needed for self-host): no way to create a second user, no per-tenant spend cap, no tenant-owned LLM keys #1303 adds one, it should check the acting admin's password on that route.current_passwordguessing on PATCH is not rate-limited by this PR. Guessing needs a valid JWT for that account./users/*has no auth rate limit dependency; the users router could getenforce_auth_rate_limitif that matters.is_active/is_superuserchanges through/users/{id}need nocurrent_password. That route is already superuser-only, and the AC covers the password and email only.Implementation Notes
test_registration_bootstrap.pyandtest_v2_auth_enforcement.pyusedsecret123(9 characters) and now use a 12+ character value. The login page tests likewise. No assertion changed.test_atomic_writes_954::...[rotate]fails on this machine with and without this branch: the real OS keyring leaks into it, and it passes underCODEFRAME_DISABLE_KEYRING=1. It is unrelated to this change and will be filed separately.Closes #1285