fix: stille Anmeldeschleife bei Secure-Cookie über HTTP erklären
CI / backend (push) Successful in 2m33s
Images bauen / build (backend) (push) Successful in 3m45s
Images bauen / build (frontend) (push) Successful in 4m23s
CI / frontend (push) Successful in 6m38s

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
This commit is contained in:
Jonas Menzel
2026-09-10 00:00:29 +02:00
co-authored by Claude Opus 5
parent c110340628
commit 998d5867df
6 changed files with 215 additions and 9 deletions
+33 -2
View File
@@ -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<User>("/auth/login", daten),
mutationFn: async (daten: { username: string; password: string }) => {
const benutzer = await api.post<User>("/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<User>("/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.";
}
+107
View File
@@ -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(<LoginPage />);
expect(screen.getByRole("button", { name: "Anmelden" })).toBeDisabled();
});
it("meldet ein falsches Passwort im Klartext", async () => {
mockApi({ loginOk: false });
renderWithProviders(<LoginPage />);
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(<LoginPage />);
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(<LoginPage />);
await anmelden();
await waitFor(() => {
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
});
});