Bei COOKIE_SECURE=true und Zugriff über HTTP verwirft der Browser das Sitzungs-Cookie. Die Anmeldung selbst meldet Erfolg, die nächste Anfrage gilt aber als nicht angemeldet – der Nutzer landet ohne jede Meldung wieder auf der Anmeldemaske und hält es für ein falsches Passwort. - Das Frontend prüft die Sitzung unmittelbar nach der Anmeldung und nennt bei einem Fehlschlag die Ursache samt beider Auswege - Das Backend protokolliert dieselbe Kombination als Warnung und wertet dabei X-Forwarded-Proto aus, weil hinter einem Reverse Proxy immer http ankommt - docs/deployment.md beschreibt das Symptom wörtlich Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
100 lines
3.1 KiB
Python
100 lines
3.1 KiB
Python
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
||
|
||
import logging
|
||
from datetime import UTC, datetime
|
||
|
||
from fastapi import Request, Response
|
||
|
||
from app.core.config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
ACCESS_COOKIE = "moneyfy_access"
|
||
REFRESH_COOKIE = "moneyfy_refresh"
|
||
|
||
# Der Refresh-Cookie wird nur an die Endpunkte geschickt, die ihn wirklich brauchen.
|
||
REFRESH_COOKIE_PATH = "/api/auth"
|
||
|
||
|
||
def request_is_secure(request: Request) -> bool:
|
||
"""Kam die Anfrage über HTTPS?
|
||
|
||
Hinter einem Reverse Proxy steht das echte Schema in `X-Forwarded-Proto`;
|
||
das Schema der Verbindung zum Container ist dort immer `http`.
|
||
"""
|
||
weitergeleitet = request.headers.get("x-forwarded-proto", "")
|
||
if weitergeleitet:
|
||
# Bei mehreren Proxies steht das ursprüngliche Schema vorne.
|
||
return weitergeleitet.split(",")[0].strip().lower() == "https"
|
||
return request.url.scheme == "https"
|
||
|
||
|
||
def warn_if_cookie_unusable(request: Request) -> bool:
|
||
"""Meldet die Kombination, bei der der Browser das Cookie verwirft.
|
||
|
||
`Secure`-Cookies über eine reine HTTP-Verbindung werden stillschweigend
|
||
weggeworfen: Die Anmeldung meldet Erfolg, die nächste Anfrage gilt aber
|
||
wieder als nicht angemeldet. Ohne diesen Hinweis ist das kaum zu erkennen.
|
||
"""
|
||
if not settings.cookie_secure or request_is_secure(request):
|
||
return False
|
||
|
||
logger.warning(
|
||
"Anmeldung über %s ohne HTTPS bei COOKIE_SECURE=true – der Browser "
|
||
"verwirft das Sitzungs-Cookie und die Anmeldemaske erscheint erneut. "
|
||
"Entweder über HTTPS zugreifen oder COOKIE_SECURE=false setzen.",
|
||
request.url.hostname or "unbekannt",
|
||
)
|
||
return True
|
||
|
||
|
||
def set_auth_cookies(
|
||
response: Response,
|
||
access_token: str,
|
||
access_expires_at: datetime,
|
||
refresh_token: str,
|
||
refresh_expires_at: datetime,
|
||
) -> None:
|
||
"""Legt beide Cookies als httpOnly/SameSite=Lax ab."""
|
||
now = datetime.now(UTC)
|
||
response.set_cookie(
|
||
ACCESS_COOKIE,
|
||
access_token,
|
||
max_age=max(int((access_expires_at - now).total_seconds()), 0),
|
||
httponly=True,
|
||
secure=settings.cookie_secure,
|
||
samesite="lax",
|
||
path="/",
|
||
domain=settings.cookie_domain,
|
||
)
|
||
response.set_cookie(
|
||
REFRESH_COOKIE,
|
||
refresh_token,
|
||
max_age=max(int((refresh_expires_at - now).total_seconds()), 0),
|
||
httponly=True,
|
||
secure=settings.cookie_secure,
|
||
samesite="lax",
|
||
path=REFRESH_COOKIE_PATH,
|
||
domain=settings.cookie_domain,
|
||
)
|
||
|
||
|
||
def clear_auth_cookies(response: Response) -> None:
|
||
"""Entfernt beide Cookies – muss dieselben Attribute wie beim Setzen verwenden."""
|
||
response.delete_cookie(
|
||
ACCESS_COOKIE,
|
||
path="/",
|
||
domain=settings.cookie_domain,
|
||
httponly=True,
|
||
secure=settings.cookie_secure,
|
||
samesite="lax",
|
||
)
|
||
response.delete_cookie(
|
||
REFRESH_COOKIE,
|
||
path=REFRESH_COOKIE_PATH,
|
||
domain=settings.cookie_domain,
|
||
httponly=True,
|
||
secure=settings.cookie_secure,
|
||
samesite="lax",
|
||
)
|