feat: password reset flow via Gmail OAuth #133
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!133
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "132-password-reset-oauth"
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
Changes
src/basketball_api/routes/password_reset.py(new): POST /api/password-reset/request generates a 1-hour token and sends a branded reset email; POST /api/password-reset/confirm validates the token and resets the Keycloak password via admin APIsrc/basketball_api/models.py: Added PasswordResetToken model (id, email, token, created_at, expires_at, used)alembic/versions/014_add_password_reset_tokens.py(new): Migration to create password_reset_tokens table with indexessrc/basketball_api/services/email.py: Added send_password_reset_email() using existing _brand_wrapper() and Gmail OAuth client with Westside brandingsrc/basketball_api/main.py: Registered password reset router at /api/password-resettests/test_password_reset.py(new): 7 tests covering happy path, enumeration prevention, expiry, reuse, and Keycloak failure handlingTest Plan
pytest tests/test_password_reset.py -v-- all 7 passpytest-- all 456 passruff checkandruff formatcleanReview Checklist
Related
PR #133 Review
DOMAIN REVIEW
Tech stack: Python / FastAPI / SQLAlchemy / Alembic / Pydantic / Keycloak Admin API / Gmail OAuth
Security (OWASP)
User enumeration prevention: Correctly implemented. Both the found and not-found email paths return the identical
"If that email is registered, a reset link has been sent."message with HTTP 200. No timing side-channel concern since the DB lookup is fast relative to the email send, and the email send happens after the response message is determined.Token security:
secrets.token_urlsafe(32)produces 256 bits of entropy -- cryptographically strong and URL-safe. Tokens are single-use (usedflag) and expire after 1 hour. Good.HTML injection in email:
reset_urlis passed throughescape()(fromhtmlstdlib) in the email template before being rendered in HTML attributes and text. The<a href="{escape(reset_url)}">pattern is correct.Keycloak admin API: Uses the existing
reset_parent_passwordservice function which setstemporary: False-- correct for a self-service flow where the user chose their own password.SQLAlchemy / Alembic
Migration 014: Chain is correct (013 -> 014). Raw SQL migration uses
sa.text()wrapper, includes proper indexes onemailandtokencolumns, anddowngrade()drops with CASCADE. Clean.Model:
PasswordResetTokenmodel mirrors the migration correctly.server_default=func.now()forcreated_atandserver_default=text("false")forusedmatch the SQL DDL.FastAPI / Pydantic
Schemas: Clean Pydantic models.
PasswordResetRequest,PasswordResetConfirm,PasswordResetResponseare well-defined.Router registration: Mounted at
/api/password-resetinmain.py-- consistent with the/api/prefix pattern used by other API routers.CORS: The existing CORS middleware in
main.pyalready allowshttps://westsidekingsandqueens.tail5b443a.ts.net-- no additional CORS config needed.Email service
send_password_reset_email: Follows the existing pattern from other email functions -- usesget_gmail_client(tenant),_brand_wrapper(), and provides both HTML and plain text bodies. Returnsgmail_message_idorNoneon failure. Exception is caught and logged, not re-raised -- consistent with the non-blocking email pattern.Test coverage
conftest.pytenant,client, anddbfixtures.BLOCKERS
None.
NITS
Double Keycloak user lookup: In
confirm_password_reset,get_existing_user()is called at line 159 to verify the user exists, thenreset_parent_password()is called at line 167 which internally callsget_existing_user()again (keycloak.py line 197). This is two HTTP roundtrips to Keycloak for the same email. Not a blocker since the function signatures are already established and used byadmin.py, but worth noting for a future refactor (e.g., areset_parent_password_by_idvariant that accepts the user ID directly).Duplicated tenant slug constant:
_DEFAULT_TENANT_SLUG = "westside-kings-queens"inpassword_reset.pyduplicatesDEFAULT_TENANT_SLUGfromservices/registration.py. Consider importing from a single source to avoid drift. Not a blocker since the slug is stable and the duplication is in different modules, not in auth/security paths.Confirm endpoint returns 200 for all outcomes: Invalid token, expired token, used token, and Keycloak failures all return HTTP 200 with different messages. The frontend will need to inspect the message string to determine success vs failure. A more conventional approach would use HTTP 400/422 for client errors and 503 for Keycloak failures. This is a design choice that works for the current frontend, but makes API semantics less discoverable. Not a blocker.
No new-password validation: The
new_passwordfield inPasswordResetConfirmaccepts any string with no minimum length or complexity check. Keycloak itself may enforce password policies at the realm level, which would causereset_parent_passwordto returnFalse. If Keycloak does not have a policy, a user could set a 1-character password. Consider adding a Pydanticmin_lengthconstraint (e.g.,new_password: str = Field(min_length=8)). Not a blocker since Keycloak is the ultimate gatekeeper.No rate limiting on
/request: The request endpoint has no rate limiting, so an attacker could trigger mass email sends. The existing codebase has no rate limiting anywhere, so this is consistent with the current state. Worth tracking as a future issue for all public endpoints.SOP COMPLIANCE
132-password-reset-oauthreferences issue #132feedback_basketball_hands_off.md)PROCESS OBSERVATIONS
main.pyandemail.py). Thereset_parent_passwordfunction inkeycloak.pyalready existed and is battle-tested via the admin route.VERDICT: APPROVED