From 998d5867df581c3e9651448a5bd9624662ba92e4 Mon Sep 17 00:00:00 2001 From: Jonas Menzel Date: Thu, 10 Sep 2026 00:00:29 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20stille=20Anmeldeschleife=20bei=20Secure-?= =?UTF-8?q?Cookie=20=C3=BCber=20HTTP=20erkl=C3=A4ren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH --- CHANGELOG.md | 8 ++ backend/app/api/routes/auth.py | 14 +++- backend/app/core/cookies.py | 37 ++++++++- docs/deployment.md | 23 +++++- frontend/src/hooks/useAuth.ts | 35 ++++++++- frontend/src/pages/LoginPage.test.tsx | 107 ++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 9 deletions(-) create mode 100644 frontend/src/pages/LoginPage.test.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 1002c19..51aad9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,14 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). Benutzertabelle greift und ein nachträglich geändertes `MONEYFY_ADMIN_PASSWORD` danach wirkungslos bleibt. +### Behoben + +- Eine Anmeldung über HTTP bei aktivem `COOKIE_SECURE` fiel stillschweigend auf + die Anmeldemaske zurück: Der Browser verwirft das `Secure`-Cookie, die + Anmeldung selbst meldet aber Erfolg. Die Oberfläche prüft die Sitzung jetzt + unmittelbar nach der Anmeldung und erklärt den Fall samt beider Auswege; das + Backend protokolliert dieselbe Kombination als Warnung. + ## [0.1.1] – 2026-09-09 Politur und ein Fehler, den erst die CI zutage gefördert hat. diff --git a/backend/app/api/routes/auth.py b/backend/app/api/routes/auth.py index 5b82808..de0fe30 100644 --- a/backend/app/api/routes/auth.py +++ b/backend/app/api/routes/auth.py @@ -3,7 +3,12 @@ from fastapi import APIRouter, Request, Response, status from app.api.deps import CurrentUser, DbSession -from app.core.cookies import REFRESH_COOKIE, clear_auth_cookies, set_auth_cookies +from app.core.cookies import ( + REFRESH_COOKIE, + clear_auth_cookies, + set_auth_cookies, + warn_if_cookie_unusable, +) from app.core.errors import AuthError from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut from app.schemas.common import ErrorResponse, MessageResponse @@ -29,7 +34,12 @@ UNAUTHORIZED = {status.HTTP_401_UNAUTHORIZED: {"model": ErrorResponse}} description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als " "httpOnly-Cookies ab.", ) -async def login(payload: LoginRequest, response: Response, session: DbSession) -> UserOut: +async def login( + payload: LoginRequest, request: Request, response: Response, session: DbSession +) -> UserOut: + # Meldet die Kombination, bei der der Browser das Cookie stillschweigend verwirft. + warn_if_cookie_unusable(request) + provider = get_auth_provider("local") user = await provider.authenticate( session, Credentials(username=payload.username, password=payload.password) diff --git a/backend/app/core/cookies.py b/backend/app/core/cookies.py index 66d0d8d..7af47a4 100644 --- a/backend/app/core/cookies.py +++ b/backend/app/core/cookies.py @@ -1,11 +1,14 @@ """Setzen und Löschen der Authentifizierungs-Cookies.""" +import logging from datetime import UTC, datetime -from fastapi import Response +from fastapi import Request, Response from app.core.config import settings +logger = logging.getLogger(__name__) + ACCESS_COOKIE = "moneyfy_access" REFRESH_COOKIE = "moneyfy_refresh" @@ -13,6 +16,38 @@ REFRESH_COOKIE = "moneyfy_refresh" 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, diff --git a/docs/deployment.md b/docs/deployment.md index 4ac66c1..985dc09 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -212,10 +212,25 @@ Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen: docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD ``` -3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt und erreichen den Server - über eine reine HTTP-Verbindung nicht. Typisches Bild: die Anmeldung meldet - keinen Fehler, landet aber sofort wieder auf der Anmeldemaske. Entweder über - NPMplus zugreifen oder für einen Test `COOKIE_SECURE=false` setzen. +3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt; über eine reine + HTTP-Verbindung verwirft der Browser sie. Typisches Bild: **der Bildschirm + flackert kurz und die Anmeldemaske erscheint erneut, ohne Fehlermeldung.** + Seit 0.1.2 erklärt die Oberfläche das ausdrücklich, und im Log steht: + + > `Anmeldung über … ohne HTTPS bei COOKIE_SECURE=true – der Browser verwirft + > das Sitzungs-Cookie …` + + Abhilfe: über NPMplus mit HTTPS zugreifen, oder für einen Test auf HTTP: + + ```bash + # in der .env + COOKIE_SECURE=false + ``` + + ```bash + # restart genügt nicht – nur up -d übernimmt die geänderte Umgebung + docker compose up -d --force-recreate moneyfy-backend + ``` Welcher Fall vorliegt, verrät das Log: diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index 42af832..8a51f10 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -24,11 +24,41 @@ export function useMe() { }); } +/** + * Wird ausgelöst, wenn die Anmeldedaten stimmen, die Sitzung aber nicht hält. + * + * Ursache ist praktisch immer ein `Secure`-Cookie über eine HTTP-Verbindung: + * Der Browser verwirft es stillschweigend, die Anmeldung meldet Erfolg und die + * nächste Anfrage gilt wieder als nicht angemeldet. + */ +export class SessionNotEstablishedError extends Error { + constructor() { + super( + "Die Anmeldung hat geklappt, aber der Browser hat das Sitzungs-Cookie verworfen. " + + "Das passiert bei Zugriff über HTTP, solange COOKIE_SECURE aktiv ist – " + + "greife über HTTPS zu oder setze COOKIE_SECURE=false.", + ); + this.name = "SessionNotEstablishedError"; + } +} + export function useLogin() { const client = useQueryClient(); return useMutation({ - mutationFn: (daten: { username: string; password: string }) => - api.post("/auth/login", daten), + mutationFn: async (daten: { username: string; password: string }) => { + const benutzer = await api.post("/auth/login", daten); + + // Gegenprobe: hält die Sitzung wirklich? Ohne sie liefe der Nutzer in eine + // stille Schleife zurück zur Anmeldemaske, ohne je einen Grund zu sehen. + try { + await api.get("/me"); + } catch (fehler) { + if (isUnauthorized(fehler)) throw new SessionNotEstablishedError(); + throw fehler; + } + + return benutzer; + }, onSuccess: (benutzer) => { client.setQueryData(keys.me, benutzer); void client.invalidateQueries(); @@ -64,6 +94,7 @@ export function useChangePassword() { /** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */ export function loginErrorMessage(error: unknown): string { + if (error instanceof SessionNotEstablishedError) return error.message; if (error instanceof ApiError) return error.message; return "Der Server ist nicht erreichbar."; } diff --git a/frontend/src/pages/LoginPage.test.tsx b/frontend/src/pages/LoginPage.test.tsx new file mode 100644 index 0000000..aee472f --- /dev/null +++ b/frontend/src/pages/LoginPage.test.tsx @@ -0,0 +1,107 @@ +/** Tests der Anmeldemaske, insbesondere der stillen Cookie-Falle. */ + +import { screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { LoginPage } from "@/pages/LoginPage"; +import { renderWithProviders } from "@/test/utils"; + +const BENUTZER = { + id: 1, + username: "admin", + email: null, + must_change_password: false, + last_login_at: null, +}; + +/** + * `meAntwortet` steuert, ob die Sitzung nach der Anmeldung hält – genau der + * Unterschied zwischen „Passwort falsch" und „Cookie verworfen". + */ +function mockApi({ + loginOk = true, + meAntwortet = true, +}: { loginOk?: boolean; meAntwortet?: boolean } = {}) { + const json = (daten: unknown, status = 200) => + new Response(JSON.stringify(daten), { + status, + headers: { "content-type": "application/json" }, + }); + + vi.stubGlobal( + "fetch", + vi.fn(async (eingabe: RequestInfo | URL) => { + const url = typeof eingabe === "string" ? eingabe : eingabe.toString(); + + if (url.includes("/auth/login")) { + return loginOk + ? json(BENUTZER) + : json({ detail: "Benutzername oder Passwort ist falsch.", code: "invalid_credentials" }, 401); + } + if (url.includes("/auth/refresh")) { + return json({ detail: "Nicht angemeldet.", code: "not_authenticated" }, 401); + } + if (url.includes("/me")) { + return meAntwortet + ? json(BENUTZER) + : json({ detail: "Nicht angemeldet.", code: "not_authenticated" }, 401); + } + return json({}); + }), + ); +} + +async function anmelden() { + const nutzer = userEvent.setup(); + await nutzer.type(screen.getByLabelText(/Benutzername/), "admin"); + await nutzer.type(screen.getByLabelText(/Passwort/), "geheim12345"); + await nutzer.click(screen.getByRole("button", { name: "Anmelden" })); +} + +describe("Anmeldemaske", () => { + beforeEach(() => { + mockApi(); + }); + + it("verlangt Benutzername und Passwort", () => { + renderWithProviders(); + + expect(screen.getByRole("button", { name: "Anmelden" })).toBeDisabled(); + }); + + it("meldet ein falsches Passwort im Klartext", async () => { + mockApi({ loginOk: false }); + renderWithProviders(); + + await anmelden(); + + expect( + await screen.findByText("Benutzername oder Passwort ist falsch."), + ).toBeInTheDocument(); + }); + + it("erklärt eine verworfene Sitzung, statt still zurückzufallen", async () => { + // Anmeldung erfolgreich, aber das Cookie kommt nicht an. + mockApi({ loginOk: true, meAntwortet: false }); + renderWithProviders(); + + await anmelden(); + + const meldung = await screen.findByRole("alert"); + expect(meldung).toHaveTextContent(/Sitzungs-Cookie verworfen/); + // Der Hinweis nennt die Ursache und beide Auswege. + expect(meldung).toHaveTextContent(/HTTPS/); + expect(meldung).toHaveTextContent(/COOKIE_SECURE=false/); + }); + + it("meldet keinen Fehler, wenn die Sitzung hält", async () => { + renderWithProviders(); + + await anmelden(); + + await waitFor(() => { + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + }); +});