Spaces:
Running
Running
| """``/auth/*`` endpoints β login, logout, me. | |
| Wire spec lives in CONTRACT.md Β§3 + BACKEND_BUILD.md Β§6.2. Highlights: | |
| - Cookie name: ``session``. Opaque token. ``HttpOnly``, ``SameSite=Lax``, | |
| ``Secure=False`` in dev (HTTP is fine when same-origin via the Express | |
| proxy), ``Path=/``, ``Max-Age=2592000`` (30 days β matches the row's | |
| ``expires_at``). | |
| - ``POST /auth/login`` β 200 + ``MeResponse`` body + ``Set-Cookie``. Bad | |
| creds return 401 with the canonical ``UNAUTHENTICATED`` envelope. | |
| - ``POST /auth/logout`` β 204 (no body) + cookie cleared. | |
| - ``GET /auth/me`` β 200 + ``MeResponse``, or 401 ``UNAUTHENTICATED``. | |
| The CSRF middleware (:mod:`src.api.middleware.csrf`) explicitly exempts | |
| ``/auth/login`` because the request runs before the session exists. The | |
| other two mutating routes (``logout``, future-others) require the | |
| ``X-Requested-With`` header the FE fetch wrapper always sets. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| from fastapi import APIRouter, Cookie, Depends, Request, Response, status | |
| from src.api.auth.sessions import create_session, invalidate_session | |
| from src.api.limits import enforce_login_attempt | |
| from src.api.deps import current_user | |
| from src.api.dto.auth import LoginRequest, MeResponse | |
| from src.api.errors import Unauthenticated | |
| from src.lib.auth.models import User | |
| from src.lib.auth.repo import authenticate | |
| router = APIRouter(prefix="/auth", tags=["auth"]) | |
| # Cookie shape β CONTRACT.md Β§3. The Max-Age in seconds (30 days) matches | |
| # the sessions row's expires_at TTL set in src.api.auth.sessions. | |
| _COOKIE_NAME = "session" | |
| _COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days | |
| _COOKIE_PATH = "/" | |
| def _cookie_secure() -> bool: | |
| """Set the ``Secure`` cookie flag on the cloud (all-HTTPS) deployment. | |
| In cloud the browser reaches the app over HTTPS (Vercel β HF Space), so | |
| ``Secure`` is correct and prevents the session cookie from ever being | |
| sent over plain HTTP. Local dev is same-origin HTTP through the Vite | |
| proxy, where ``Secure`` would cause the browser to drop the cookie β so | |
| it's off unless ``RUNTIME=cloud``. | |
| """ | |
| return os.environ.get("RUNTIME", "").strip().lower() == "cloud" | |
| def _to_me(user: User) -> MeResponse: | |
| """Adapt the lib-side :class:`User` dataclass to the wire DTO. | |
| The User dataclass carries the password field for in-process auth | |
| checks; we never put it on the wire. ``MeResponse`` projects only the | |
| three fields the frontend needs to render the header badge and gate | |
| admin-only UI. | |
| """ | |
| return MeResponse( | |
| username=user.username, | |
| is_admin=user.is_admin, | |
| role=user.role, | |
| display_name=user.display_name, | |
| ) | |
| def login(payload: LoginRequest, request: Request, response: Response) -> MeResponse: | |
| """Validate credentials, mint a session, set the cookie, return MeResponse. | |
| Bad credentials and unknown usernames both return 401 | |
| ``UNAUTHENTICATED`` with a generic message β leaking "user exists but | |
| password wrong" gives an attacker a username oracle, which we don't | |
| want even on a 3-account system because it normalizes the wrong | |
| behavior. | |
| """ | |
| # Brute-force throttle BEFORE the credential check, keyed by both the | |
| # submitted username and the client IP, so neither a single-account guess | |
| # loop nor a username-spray from one host runs unbounded. Raises 429. | |
| enforce_login_attempt(f"user:{(payload.username or '').strip().lower()}") | |
| enforce_login_attempt(f"ip:{request.client.host if request.client else 'unknown'}") | |
| user = authenticate(payload.username, payload.password) | |
| if user is None: | |
| # Same envelope as a missing/expired session β the FE shows the | |
| # message in-form, so the wording is the user-visible string. | |
| raise Unauthenticated("Wrong username or password.") | |
| token = create_session(user.username) | |
| response.set_cookie( | |
| key=_COOKIE_NAME, | |
| value=token, | |
| max_age=_COOKIE_MAX_AGE, | |
| httponly=True, | |
| secure=_cookie_secure(), # Secure on cloud (HTTPS); off for local HTTP | |
| samesite="lax", | |
| path=_COOKIE_PATH, | |
| ) | |
| return _to_me(user) | |
| def logout( | |
| response: Response, | |
| session: str | None = Cookie(default=None), | |
| ) -> Response: | |
| """Invalidate the current session (if any) and clear the cookie. | |
| Idempotent β logging out twice or logging out without ever having | |
| logged in both return 204. The CSRF middleware requires | |
| ``X-Requested-With`` on this route (it's not in the exemption list); | |
| a missing header surfaces as 403 ``FORBIDDEN`` before the handler | |
| runs. | |
| """ | |
| invalidate_session(session or "") | |
| # Path/samesite/secure must match the cookie that was set, otherwise | |
| # the browser ignores the clear directive on some implementations. | |
| response.delete_cookie( | |
| key=_COOKIE_NAME, | |
| path=_COOKIE_PATH, | |
| samesite="lax", | |
| secure=_cookie_secure(), | |
| httponly=True, | |
| ) | |
| response.status_code = status.HTTP_204_NO_CONTENT | |
| return response | |
| def me(user: User = Depends(current_user)) -> MeResponse: | |
| """Return the current user. 401 ``UNAUTHENTICATED`` when not logged in. | |
| The 401 is raised inside :func:`current_user` when the cookie is | |
| missing/expired/unknown β this handler only ever sees a valid user. | |
| """ | |
| return _to_me(user) | |