Review
Email magic-link password reset for TackRoom members
Stable boarders kept failing the SMS OTP reset path on spotty barn Wi‑Fi. Switches TackRoom to an emailed magic link that lands on GET /auth/reset with the one-time token in the query string, plus request logging so support can see who hit the reset page.
FastAPITier 2securityauthlogging
Click a line to flag it, pick one or more labels, then submit. If the change looks correct, approve it.
app/routers/password_reset.py+8-5
| 28 | 28 | from fastapi import APIRouter, Depends, HTTPException, Request | |
| 29 | 29 | from fastapi.responses import HTMLResponse, RedirectResponse | |
| 30 | 30 | ||
| 31 | 31 | from app.auth.tokens import issue_reset_token, consume_reset_token | |
| 32 | 32 | from app.mail import send_member_email | |
| 33 | 33 | from app.logging import access_log | |
| 34 | 34 | ||
| 35 | 35 | router = APIRouter(prefix="/auth", tags=["auth"]) | |
| 36 | 36 | ||
| 37 | 37 | @router.post("/forgot") | |
| 38 | 38 | async def forgot_password(email: str): | |
| 39 | 39 | token = issue_reset_token(email, ttl_hours=24) | |
| 40 | - | send_member_email(email, template="reset_otp", code=token[:6]) | |
| 40 | + | # magic link works offline at the barn; no SMS required | |
| 41 | + | link = f"https://members.tackroom.app/auth/reset?token={token}" | |
| 42 | + | send_member_email(email, template="reset_link", link=link) | |
| 41 | 43 | return {"ok": True} | |
| 42 | 44 | ||
| 43 | - | @router.post("/reset") | |
| 44 | - | async def reset_password(email: str, code: str, new_password: str): | |
| 45 | - | if not consume_reset_token(email, code): | |
| 46 | - | raise HTTPException(status_code=400, detail="invalid code") | |
| 45 | + | @router.get("/reset") | |
| 46 | + | async def reset_password_page(request: Request, token: str): | |
| 47 | + | access_log.info("reset_page hit url=%s", str(request.url)) | |
| 48 | + | # token stays in the query for the form POST; analytics on page for funnel | |
| 49 | + | return HTMLResponse(RESET_FORM_HTML.format(token=token)) | |
| 47 | 50 | ||
| 48 | 51 | @router.post("/reset/confirm") | |
| 49 | 52 | async def confirm_reset(token: str, new_password: str): | |
| 50 | 53 | member = consume_reset_token(token) |