fix: stille Anmeldeschleife bei Secure-Cookie über HTTP erklären
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:
co-authored by
Claude Opus 5
parent
c110340628
commit
998d5867df
@@ -14,6 +14,14 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/).
|
|||||||
Benutzertabelle greift und ein nachträglich geändertes
|
Benutzertabelle greift und ein nachträglich geändertes
|
||||||
`MONEYFY_ADMIN_PASSWORD` danach wirkungslos bleibt.
|
`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
|
## [0.1.1] – 2026-09-09
|
||||||
|
|
||||||
Politur und ein Fehler, den erst die CI zutage gefördert hat.
|
Politur und ein Fehler, den erst die CI zutage gefördert hat.
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
from fastapi import APIRouter, Request, Response, status
|
from fastapi import APIRouter, Request, Response, status
|
||||||
|
|
||||||
from app.api.deps import CurrentUser, DbSession
|
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.core.errors import AuthError
|
||||||
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
|
from app.schemas.auth import ChangePasswordRequest, LoginRequest, UserOut
|
||||||
from app.schemas.common import ErrorResponse, MessageResponse
|
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 "
|
description="Prüft die Zugangsdaten und legt Access- und Refresh-Token als "
|
||||||
"httpOnly-Cookies ab.",
|
"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")
|
provider = get_auth_provider("local")
|
||||||
user = await provider.authenticate(
|
user = await provider.authenticate(
|
||||||
session, Credentials(username=payload.username, password=payload.password)
|
session, Credentials(username=payload.username, password=payload.password)
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
"""Setzen und Löschen der Authentifizierungs-Cookies."""
|
||||||
|
|
||||||
|
import logging
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import Response
|
from fastapi import Request, Response
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ACCESS_COOKIE = "moneyfy_access"
|
ACCESS_COOKIE = "moneyfy_access"
|
||||||
REFRESH_COOKIE = "moneyfy_refresh"
|
REFRESH_COOKIE = "moneyfy_refresh"
|
||||||
|
|
||||||
@@ -13,6 +16,38 @@ REFRESH_COOKIE = "moneyfy_refresh"
|
|||||||
REFRESH_COOKIE_PATH = "/api/auth"
|
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(
|
def set_auth_cookies(
|
||||||
response: Response,
|
response: Response,
|
||||||
access_token: str,
|
access_token: str,
|
||||||
|
|||||||
+19
-4
@@ -212,10 +212,25 @@ Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen:
|
|||||||
docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD
|
docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt und erreichen den Server
|
3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt; über eine reine
|
||||||
über eine reine HTTP-Verbindung nicht. Typisches Bild: die Anmeldung meldet
|
HTTP-Verbindung verwirft der Browser sie. Typisches Bild: **der Bildschirm
|
||||||
keinen Fehler, landet aber sofort wieder auf der Anmeldemaske. Entweder über
|
flackert kurz und die Anmeldemaske erscheint erneut, ohne Fehlermeldung.**
|
||||||
NPMplus zugreifen oder für einen Test `COOKIE_SECURE=false` setzen.
|
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:
|
Welcher Fall vorliegt, verrät das Log:
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
export function useLogin() {
|
||||||
const client = useQueryClient();
|
const client = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (daten: { username: string; password: string }) =>
|
mutationFn: async (daten: { username: string; password: string }) => {
|
||||||
api.post<User>("/auth/login", daten),
|
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) => {
|
onSuccess: (benutzer) => {
|
||||||
client.setQueryData(keys.me, benutzer);
|
client.setQueryData(keys.me, benutzer);
|
||||||
void client.invalidateQueries();
|
void client.invalidateQueries();
|
||||||
@@ -64,6 +94,7 @@ export function useChangePassword() {
|
|||||||
|
|
||||||
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
|
/** Übersetzt einen Anmeldefehler in eine Meldung für das Formular. */
|
||||||
export function loginErrorMessage(error: unknown): string {
|
export function loginErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof SessionNotEstablishedError) return error.message;
|
||||||
if (error instanceof ApiError) return error.message;
|
if (error instanceof ApiError) return error.message;
|
||||||
return "Der Server ist nicht erreichbar.";
|
return "Der Server ist nicht erreichbar.";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user