"""Authentication routes: /login, /verify, /verify/resend, /logout. Cassandra is passwordless. Single auth flow: GET /login → enter email POST /login → get_or_create_user → issue OTP → send → 303 /verify GET /verify → enter 6-digit code (email shown from pending cookie) POST /verify → validate → set session → 303 / POST /verify/resend → reissue OTP (rate-limited) Signup and login are intentionally the same path — typing your email is sign-in if you've been here before, sign-up otherwise. No UI signal distinguishes the two, which also masks user-enumeration. The /signup endpoints from the previous auth scheme are gone. Anything that linked to /signup should now link to /login. """ from __future__ import annotations from urllib.parse import urlparse from fastapi import APIRouter, Depends, Form, Request from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.ext.asyncio import AsyncSession from app.auth import ( PENDING_COOKIE_NAME, PENDING_TTL_SECONDS, SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_pending, sign_session, verify_pending, ) from app.config import get_settings from app.db import get_session, utcnow from app.logging import get_logger from app.services.auth_service import AuthError, get_or_create_user, get_user from app.services import otp_service, referral_service from app.services.email_service import EmailSendError, send_otp from app.templates_env import templates log = get_logger("auth_router") router = APIRouter(tags=["auth"]) def _safe_next(next_value: str | None) -> str: """Only allow same-origin relative paths to prevent open-redirect.""" if not next_value or not next_value.startswith("/") or next_value.startswith("//"): return "/" if urlparse(next_value).netloc: return "/" return next_value def _set_session_cookie(response: RedirectResponse, user_id: int) -> None: response.set_cookie( key=SESSION_COOKIE_NAME, value=sign_session(user_id), max_age=SESSION_TTL_SECONDS, httponly=True, samesite="lax", secure=False, path="/", ) def _set_pending_cookie( response: RedirectResponse, email: str, user_id: int, ref: str | None = None, ) -> None: response.set_cookie( key=PENDING_COOKIE_NAME, value=sign_pending(email, user_id, ref=ref), max_age=PENDING_TTL_SECONDS, httponly=True, samesite="lax", secure=False, path="/", ) def _clear_pending_cookie(response) -> None: response.delete_cookie(PENDING_COOKIE_NAME, path="/") async def _issue_and_send_otp(session: AsyncSession, email: str) -> bool: """Generate a code, persist its hash, send the email. Returns True on success. Returns False (and logs) if SMTP submission fails — the OTP row is still created so the user can hit /verify/resend.""" code = await otp_service.issue(session, email, purpose="auth") try: await send_otp(email, code, otp_service.OTP_TTL_MINUTES) return True except EmailSendError: return False # --------------------------------------------------------------------------- # Login (email entry) # --------------------------------------------------------------------------- @router.get("/login", response_class=HTMLResponse) async def login_page( request: Request, next: str | None = None, error: str | None = None, ref: str | None = None, session: AsyncSession = Depends(get_session), ): # If a valid referral code is supplied, surface a small "invited" # banner. We resolve it server-side so the banner can show the # referrer's actual greeting (and a bad code silently degrades). ref_norm = referral_service.normalise_code(ref) if ref else None referrer = ( await referral_service.lookup_referrer(session, ref_norm) if ref_norm else None ) return templates.TemplateResponse( request, "login.html", { "next_path": _safe_next(next), "error": error, "ref": ref_norm if referrer else None, "referrer_present": referrer is not None, }, ) @router.post("/login") async def login_submit( request: Request, email: str = Form(...), next: str | None = Form(default=None), ref: str | None = Form(default=None), session: AsyncSession = Depends(get_session), ): s = get_settings() # Look up the referrer up front so a bad code doesn't pollute the # rest of the flow. Self-referral protection lives in # referral_service.link_new_user. ref_norm = referral_service.normalise_code(ref) if ref else None referrer = ( await referral_service.lookup_referrer(session, ref_norm) if ref_norm else None ) # Track whether THIS request creates the user row (i.e. a referral # capture window). Cleanest way: probe for existence first. from app.services.auth_service import get_user_by_email was_new = (await get_user_by_email(session, email)) is None try: user = await get_or_create_user( session, email, create_if_missing=s.CASSANDRA_SIGNUP_ENABLED, ) except AuthError as e: return templates.TemplateResponse( request, "login.html", {"next_path": _safe_next(next), "error": str(e), "email": email, "ref": ref_norm if referrer else None, "referrer_present": referrer is not None}, status_code=400, ) # First-time signup with a valid referrer → persist the linkage now. # We do this BEFORE OTP-verify because the row is already created; # if the user abandons OTP we'll have an orphan link but that's # harmless audit data. if was_new and referrer is not None: await referral_service.link_new_user(session, user, referrer) # Issue OTP only if cooldown allows; if a fresh one was sent in the # last 60s we just reuse the existing one (silently) to avoid # spamming the user's inbox on a refreshed form submit. allowed, _ = await otp_service.can_request_new(session, user.email) if allowed: await _issue_and_send_otp(session, user.email) resp = RedirectResponse(url="/verify", status_code=303) # Stash the referral code on the pending cookie too — handy for # showing the "invited" badge on the /verify page so the friend # knows the discount is still tracking. _set_pending_cookie( resp, user.email, user.id, ref=ref_norm if referrer is not None else None, ) return resp # --------------------------------------------------------------------------- # Verify (code entry) # --------------------------------------------------------------------------- @router.get("/verify", response_class=HTMLResponse) async def verify_page(request: Request, error: str | None = None, sent: str | None = None): cookie = request.cookies.get(PENDING_COOKIE_NAME) pending = verify_pending(cookie) if cookie else None if pending is None: return RedirectResponse(url="/login", status_code=303) return templates.TemplateResponse( request, "verify.html", {"email": pending["email"], "error": error, "sent": sent, "ttl_minutes": otp_service.OTP_TTL_MINUTES, "resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS}, ) @router.post("/verify") async def verify_submit( request: Request, code: str = Form(...), session: AsyncSession = Depends(get_session), ): cookie = request.cookies.get(PENDING_COOKIE_NAME) pending = verify_pending(cookie) if cookie else None if pending is None: return RedirectResponse(url="/login", status_code=303) email = pending["email"] try: await otp_service.verify(session, email, code) except otp_service.OTPError as e: return templates.TemplateResponse( request, "verify.html", {"email": email, "error": str(e), "ttl_minutes": otp_service.OTP_TTL_MINUTES, "resend_cooldown": otp_service.RESEND_COOLDOWN_SECONDS}, status_code=400, ) user = await get_user(session, pending["uid"]) if user is None: # User row vanished between cookie issue and verify. Restart flow. return RedirectResponse(url="/login", status_code=303) user.last_login_at = utcnow() await session.commit() log.info("user.login", user_id=user.id, email=email) resp = RedirectResponse(url="/", status_code=303) _set_session_cookie(resp, user.id) _clear_pending_cookie(resp) return resp @router.post("/verify/resend") async def verify_resend( request: Request, session: AsyncSession = Depends(get_session), ): cookie = request.cookies.get(PENDING_COOKIE_NAME) pending = verify_pending(cookie) if cookie else None if pending is None: return RedirectResponse(url="/login", status_code=303) email = pending["email"] allowed, wait = await otp_service.can_request_new(session, email) if not allowed: return RedirectResponse( url=f"/verify?error=Please+wait+{wait}s+before+requesting+another+code", status_code=303, ) ok = await _issue_and_send_otp(session, email) msg = "A new code has been sent" if ok else "Could not send email — try again shortly" return RedirectResponse(url=f"/verify?sent={msg}", status_code=303) # --------------------------------------------------------------------------- # Logout # --------------------------------------------------------------------------- @router.post("/logout") async def logout(request: Request): resp = RedirectResponse(url="/login", status_code=303) resp.delete_cookie(SESSION_COOKIE_NAME, path="/") _clear_pending_cookie(resp) return resp @router.get("/logout") async def logout_get(request: Request): return await logout(request)