feat: enterprise login -- Keycloak self-service password reset #167
No reviewers
Labels
No labels
domain:backend
domain:devops
domain:frontend
status:approved
status:in-progress
status:needs-fix
status:qa
type:bug
type:devops
type:feature
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
forgejo_admin/basketball-api!167
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "129-enterprise-login"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Stop generating plaintext passwords for Keycloak accounts. Create accounts without passwords and use Keycloak's execute-actions-email API to trigger "set your password" emails. Parents use the "Forgot Password" flow on the login page to set/reset their own password via Keycloak SMTP.
Changes
src/basketball_api/services/keycloak.py--create_keycloak_user()no longer sets password credentials; newtrigger_set_password_email()calls Keycloak's execute-actions-email endpoint withUPDATE_PASSWORDaction;create_account_for_parent()triggers set-password email after account creation instead of returning plaintext passwordsrc/basketball_api/services/email.py--send_tryout_announcement_email()no longer acceptscredentialsparam; removed all plaintext password rendering from HTML and plain-text templates; replaced "Login Credentials" section with "Login Info" showing email + "Forgot Password" instructionssrc/basketball_api/routes/admin.py--admin_send_tryout_announcementno longer generates/resets passwords; instead looks up existing Keycloak user and triggers set-password email; removedreset_parent_password,generate_password,extract_first_nameimportsscripts/backfill_password_reset.py-- New one-time migration script that queries all paid parents, looks up their Keycloak account, and triggers the set-password email; supports--dry-runflagscripts/create_keycloak_accounts.py-- Updated to use new passwordlesscreate_keycloak_user()signature and trigger set-password email instead of generating passwordstests/test_keycloak_integration.py-- Updated existing tests for new signatures; addedTestTriggerSetPasswordEmailclass with 3 tests covering success, failure, and correct realm/URLtests/test_jersey.py-- UpdatedTestTryoutAnnouncementEndpointandTestTryoutAnnouncementEmailContentto verify no credentials param, no plaintext passwords, and "Forgot Password" instructions presentTest Plan
pytest tests/ -v-- 531 passed, 1 pre-existing failure (unrelatedtest_temp_team_dedup)pytest tests/test_keycloak_integration.py -v-- 18 passed (includes 3 new trigger_set_password_email tests)pytest tests/test_jersey.py -k announcement -v-- 6 passed (announcement endpoint + email content)create_keycloak_userpayload has nocredentialskeytrigger_set_password_emailcalls/execute-actions-emailwith["UPDATE_PASSWORD"]--dry-run, then withoutReview Checklist
create_keycloak_usercreates accounts without credentialstrigger_set_password_emailuses correct Keycloak API endpoint--dry-runfor safe testingregister.pyandwebhooks.pycallersauth.pyandroutes/register.pynot touched per issue constraintsRelated
plan-wkqPhase 11 (parents can't login)QA Review -- PR #167
Parent issue: #129
Plan:
plan-wkqPhase 11Issue Acceptance Criteria
create_keycloak_user()no longer sets passwordtrigger_set_password_email()uses execute-actions-emailadmin_send_tryout_announcementstops generating passwordsauth.pynot touchedroutes/register.pynot touchedFindings
BUG FOUND AND FIXED (
38bb3f2):get_existing_user()in the announcement loop (admin.py) was called without try/except. Sinceget_existing_usercallsraise_for_status(), a Keycloak HTTP error for one parent would crash the entire loop and prevent remaining parents from receiving emails. The oldreset_parent_passwordhad internal error handling; the replacement code needed explicit protection. Fixed by wrapping in try/except with a log-and-continue pattern.Backward compatibility note:
create_account_for_parent()returns{"email": ..., "password": ""}(empty string) for backward compatibility withregister.pyandwebhooks.pywhich accesscredentials["password"]. This means the registration confirmation page will show a "Login Credentials" section with an empty password field -- cosmetically imperfect but functionally safe. A follow-up issue for register.py cleanup would be appropriate.Test Results
test_temp_team_dedupintest_admin_teams.py)trigger_set_password_email(success, failure, correct realm)VERDICT: APPROVE
PR #167 Review
Title: feat: enterprise login -- Keycloak self-service password reset
Branch:
129-enterprise-login->mainIssue: Closes #129
Diff: +393 / -111 across 7 files
DOMAIN REVIEW
Stack: Python / FastAPI / SQLAlchemy / Keycloak Admin API / httpx
Security (OWASP / credential handling) -- the core of this PR:
Plaintext password elimination -- VERIFIED. The primary goal of this PR is to stop generating, transmitting, or displaying plaintext passwords in announcement emails. In the announcement flow (
admin.py+email.py), thecredentialsparameter has been completely removed fromsend_tryout_announcement_email(). Nogenerate_passwordorreset_parent_passwordcalls remain in the announcement path. The email HTML now shows "Forgot Password" instructions instead of a password field. Tests explicitly assert"Westside-" not in htmland"Your password:" not in html. This is a clean, complete removal.Keycloak execute-actions-email API usage -- CORRECT.
trigger_set_password_email()at/home/ldraney/basketball-api/src/basketball_api/services/keycloak.py:137constructs the correct URL:/admin/realms/{REALM}/users/{user_id}/execute-actions-emailwithPUTmethod and["UPDATE_PASSWORD"]body. This matches the Keycloak Admin REST API specification exactly. The function useshttpx.put(correct HTTP method for this endpoint), includes proper Bearer token auth, has a 30-second timeout, and returnsboolfor resilient error handling.create_keycloak_user()no longer sets credentials -- VERIFIED. Thecredentialskey has been removed from the user creation payload at/home/ldraney/basketball-api/src/basketball_api/services/keycloak.py:101-108. Thepasswordparameter was removed from the function signature. Users are created withenabled: TrueandemailVerified: Truebut no password, which is the correct state for the execute-actions-email flow.Backward compatibility with
register.py-- VERIFIED NOT TOUCHED.register.pyis NOT modified by this PR (confirmed by diff and file inspection). Thecreate_account_for_parent()function returns{"email": parent_email, "password": ""}with an empty string for backward compatibility.register.pyat line 317 still readskeycloak_credentials["password"], but since the value is now always"", the confirmation page will show an empty password field. This is the intended transitional behavior -- the "Your Login Credentials" section on the confirmation page will display the email but an empty password string.reset_parent_password()retained -- CORRECT. The function at line 200 in keycloak.py is kept becausepassword_reset.pystill uses it for the manual password reset flow. No dead code here.Keycloak service correctness:
Announcement flow resilience. In
admin.pylines 555-566, theget_existing_user+trigger_set_password_emailcalls are wrapped in atry/except Exceptionblock. If Keycloak is down or the user has no account, the email still sends. This matches the previous behavior where Keycloak failure did not block email delivery. The testtest_keycloak_failure_still_sends_emailverifies this.Backfill script safety.
/home/ldraney/basketball-api/scripts/backfill_password_reset.pyhas:--dry-runflag that logs what would happen without sending emails (line 134)create_keycloak_accounts.pyupdated correctly. Password generation removed from the main loop.trigger_set_password_emailcalled after account creation and role assignment. CSV output drops the password column. Thepassword_genimport is retained with# noqa: F401fortest_account_creation.pybackward compatibility.PEP compliance: Type hints present on all new functions. Docstrings follow PEP 257 (imperative mood, Args/Returns sections). No PEP 8 violations visible.
Test coverage:
3 new tests in
TestTriggerSetPasswordEmail:test_trigger_set_password_email_calls_execute_actions-- verifies URL, HTTP method, payload["UPDATE_PASSWORD"], and Bearer authtest_trigger_set_password_email_handles_failure-- verifies gracefulFalsereturn on exceptiontest_trigger_set_password_email_uses_correct_realm-- verifieswestside-basketballrealm in URLUpdated tests in
TestTryoutAnnouncementEndpoint:test_sends_announcement_to_all_parents-- now mocksget_existing_user+trigger_set_password_emailinstead ofreset_parent_password, asserts"credentials" not in call_kwargstest_keycloak_failure_still_sends_email-- tests the case whereget_existing_userreturnsNone(no KC account), verifiestriggernot called but email still senttest_test_email_filters_to_single_recipient-- updated mocksUpdated tests in
TestTryoutAnnouncementEmailContent:test_email_contains_all_sections-- no longer passescredentialsparam, asserts"Forgot Password"present and"Westside-"not presenttest_announcement_email_has_no_password_field-- explicitly verifies both HTML and plain-text contain "Forgot Password" and do NOT contain "Your password:" or "Password:"Updated tests in
TestKeycloakServiceModuleandTestKeycloakScriptIncludesNames:test_create_account_for_parent_returns_email_only-- verifiesresult["password"] == "",trigger_set_password_emailcalled, nopasswordkwarg tocreate_keycloak_usertest_create_keycloak_user_includes_names-- verifies"credentials" not in payloadtest_register_auto_keycloak_account-- verifiesmock_trigger_email.assert_called_once()BLOCKERS
None.
NITS
Hardcoded
KEYCLOAK_BASE_URLin both scripts. Bothbackfill_password_reset.py(line 50) andcreate_keycloak_accounts.py(line 53) hardcode"https://keycloak.tail5b443a.ts.net". This is pre-existing (not introduced by this PR) but worth noting: if the Keycloak URL ever changes, two scripts need manual updates. AKEYCLOAK_BASE_URLenv var override would be more resilient. Non-blocking since these are one-time/operational scripts.Backward-compatible
password: ""increate_account_for_parentreturn value. At keycloak.py line 330, the function returns{"email": parent_email, "password": ""}. Theregister.pyconfirmation page at line 317 will render an empty password field (cred_password=""), showing "Your Login Credentials" with Email but blank Password. This is functional but slightly confusing UX on the registration confirmation page. Sinceregister.pyis explicitly out of scope per #129 constraints, this is acceptable as-is, but should be a follow-up ticket to update the confirmation page to show "Forgot Password" instructions instead of empty credentials.parent_nameunused inget_paid_parent_emailsloop. Inbackfill_password_reset.pyline 71, the function returns(email, name)tuples, and line 124 unpacks asparent_email, parent_name, butparent_nameis never used in the loop body. Could beparent_email, _for clarity. Non-blocking._build_credentials_htmlstill renders password field. Inemail.pylines 143-167, the_build_credentials_htmlhelper still renders a "Password:" row. This is used only by the registration confirmation email (not the announcement email), which is out of scope for this PR. Follow-up ticket territory.SOP COMPLIANCE
129-enterprise-loginreferences issue #129plan-wkqPhase 11, pal-e-platform #122register.pyandauth.pynot touched per issue constraintsPROCESS OBSERVATIONS
Deployment Frequency: This is a self-contained, well-scoped feature that can ship independently. No database migrations required -- purely application-level changes.
Change Failure Risk: LOW. The announcement email flow is isolated from registration. The backward-compatible
password: ""return value meansregister.pycontinues to work without modification. The backfill script has--dry-runfor safe rollout. Keycloak failures are handled gracefully (emails still send).Follow-up work identified:
register.pyconfirmation page to replace "Your Login Credentials" with "Forgot Password" instructions (currently shows empty password)send_registration_email()inemail.pyto remove plaintext password from registration confirmation emailsKEYCLOAK_BASE_URLconfigurable via env var in scriptsVERDICT: APPROVED
Clean security improvement. Plaintext passwords completely removed from the announcement flow. Correct Keycloak API usage. Comprehensive test coverage (3 new tests, 6+ updated tests). Backward compatibility maintained for out-of-scope callers. Backfill script is safe with
--dry-run. No blockers.