patristic-be / src /api /auth /router.py
Mario33333's picture
deploy: reader-access security hardening (feature/audiobook@06a5ed8)
7c2d250 verified
Raw
History Blame Contribute Delete
5.67 kB
"""``/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,
)
@router.post("/login", response_model=MeResponse)
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)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
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
@router.get("/me", response_model=MeResponse)
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)