69 lines
2.7 KiB
Python
69 lines
2.7 KiB
Python
"""Session bootstrap: the one endpoint reachable without a session (US07-02).
|
|
|
|
It sets the ``HttpOnly``/``SameSite=Strict`` session cookie and returns the CSRF
|
|
token in the body. A foreign page can call this — it just cannot read the answer,
|
|
because the app sends no CORS headers — and the cookie it received is never attached
|
|
to a request that foreign page initiated.
|
|
|
|
When an access secret is configured (mandatory as soon as the app is reachable from
|
|
another machine, US08-01) this is also the authentication gate: the secret buys the
|
|
cookie, and every route behind it keeps asking for exactly the session and CSRF token
|
|
it asked for before. Wrong secrets are counted, and a burst of them stops being
|
|
answered — otherwise a proxy-exposed deployment could be guessed at indefinitely.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import secrets
|
|
|
|
from fastapi import APIRouter, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from photo_pipeline.api.security import ACCESS_SECRET_HEADER, SESSION_COOKIE
|
|
|
|
router = APIRouter(tags=["session"])
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
def _refuse(status: int, code: str, message: str) -> JSONResponse:
|
|
return JSONResponse(status_code=status, content={"error": {"code": code, "message": message}})
|
|
|
|
|
|
@router.get("/session")
|
|
def start_session(request: Request) -> JSONResponse:
|
|
config = request.app.state.config
|
|
secret = config.access_secret
|
|
if secret is not None:
|
|
limiter = request.app.state.access_limiter
|
|
if limiter.blocked():
|
|
return _refuse(429, "too_many_attempts", "too many failed attempts; retry later")
|
|
offered = request.headers.get(ACCESS_SECRET_HEADER, "")
|
|
if not secrets.compare_digest(offered, secret.get_secret_value()):
|
|
limiter.record_failure()
|
|
# The client address is the whole record: the offered secret, the issued
|
|
# session, and the request body all stay out of the log.
|
|
log.warning(
|
|
"access secret rejected", extra={"client": _client(request), "path": "/session"}
|
|
)
|
|
return _refuse(401, "access_denied", "a valid access secret is required")
|
|
|
|
session = request.app.state.session
|
|
response = JSONResponse({"csrf_token": session.csrf_token})
|
|
response.set_cookie(
|
|
SESSION_COOKIE,
|
|
session.id,
|
|
httponly=True,
|
|
samesite="strict",
|
|
# HTTPS outside means the cookie must never travel over a plain hop, even one
|
|
# this process cannot see. Loopback http keeps working unchanged.
|
|
secure=request.scope.get("state", {}).get("external_scheme") == "https",
|
|
path="/",
|
|
)
|
|
return response
|
|
|
|
|
|
def _client(request: Request) -> str:
|
|
return request.client.host if request.client else "unknown"
|