"""Authentication routes: /login, /signup, /logout. These do NOT depend on require_auth (they're how you become authenticated). The router is included separately in app/main.py without a router-level auth dependency. """ 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 SESSION_COOKIE_NAME, SESSION_TTL_SECONDS, sign_session from app.config import get_settings from app.db import get_session from app.services.auth_service import AuthError, authenticate, create_user from app.templates_env import templates 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 "/" # Block any embedded scheme or host. 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=True` requires HTTPS; the operator should enable this in # production via a reverse proxy. Off for local-dev convenience. secure=False, path="/", ) @router.get("/login", response_class=HTMLResponse) async def login_page(request: Request, next: str | None = None, error: str | None = None): return templates.TemplateResponse( request, "login.html", {"next_path": _safe_next(next), "error": error, "signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED}, ) @router.post("/login") async def login_submit( request: Request, email: str = Form(...), password: str = Form(...), next: str | None = Form(default=None), session: AsyncSession = Depends(get_session), ): try: user = await authenticate(session, email, password) except AuthError as e: return templates.TemplateResponse( request, "login.html", {"next_path": _safe_next(next), "error": str(e), "email": email, "signup_enabled": get_settings().CASSANDRA_SIGNUP_ENABLED}, status_code=400, ) target = _safe_next(next) resp = RedirectResponse(url=target, status_code=303) _set_session_cookie(resp, user.id) return resp @router.get("/signup", response_class=HTMLResponse) async def signup_page(request: Request, error: str | None = None): s = get_settings() if not s.CASSANDRA_SIGNUP_ENABLED: return templates.TemplateResponse( request, "login.html", {"next_path": "/", "error": "Sign-ups are currently disabled. Ask the operator.", "signup_enabled": False}, status_code=403, ) return templates.TemplateResponse( request, "signup.html", {"error": error}, ) @router.post("/signup") async def signup_submit( request: Request, email: str = Form(...), password: str = Form(...), session: AsyncSession = Depends(get_session), ): s = get_settings() if not s.CASSANDRA_SIGNUP_ENABLED: return RedirectResponse(url="/login", status_code=303) try: user = await create_user(session, email, password) except AuthError as e: return templates.TemplateResponse( request, "signup.html", {"error": str(e), "email": email}, status_code=400, ) resp = RedirectResponse(url="/", status_code=303) _set_session_cookie(resp, user.id) return resp @router.post("/logout") async def logout(request: Request): resp = RedirectResponse(url="/login", status_code=303) resp.delete_cookie(SESSION_COOKIE_NAME, path="/") return resp @router.get("/logout") async def logout_get(request: Request): # Convenience for users who click a logout link rather than POSTing. return await logout(request)