From cf7a3bcc881230a6d8449518ea9defca0b76389e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 11 Oct 2026 08:46:43 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Anmeldung=20je=20Netz=20=E2=80=93=20ohn?= =?UTF-8?q?e=20Passwort,=20mit=20Passwort=20oder=20abgewiesen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zwei Netzlisten statt Modusauswahl; fremde Netze werden abgewiesen, wenn „mit Passwort“ ausgefüllt ist; /healthz bleibt erreichbar; Schutz vor Aussperren; reset-password hebt Sperren auf. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 15 +- DEVLOG.md | 18 ++- ROADMAP.md | 1 + compose.yml | 2 +- docs/ANLEITUNG.md | 34 +++-- src/netmover/__init__.py | 2 +- src/netmover/cli.py | 13 +- src/netmover/settings_store.py | 5 +- src/netmover/web/app.py | 92 ++++++++--- src/netmover/web/auth.py | 60 ++++++-- src/netmover/web/templates/settings.html | 21 +-- tests/test_auth_modes.py | 187 +++++++++++++++-------- 12 files changed, 315 insertions(+), 135 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c22d803..ebab0bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ Die Version steht an genau einer Stelle: `src/netmover/__init__.py`. ## [Unreleased] +## [0.6.0] – 2026-10-11 + +### Geändert +- **Anmeldung je Netz konfigurierbar** (*Einstellungen → Anmeldung*): „Ohne Passwort + aus diesen Netzen“ und „Mit Passwort aus diesen Netzen“ (leer = alle übrigen). + Ist die zweite Liste ausgefüllt, werden alle anderen Netze abgewiesen (403, auch die + Anmeldeseite); `/healthz` und statische Dateien bleiben erreichbar. Ersetzt die + Auswahl „immer / vertraute Netze / nie“ aus 0.5.0 (`0.0.0.0/0, ::/0` = ganz aus; + gespeicherter Modus „nie“ gilt weiter). +- Schutz vor Aussperren: Eine Einstellung, mit der die eigene Adresse keinen Zugang + mehr hätte, wird nicht gespeichert. `netmover reset-password` hebt auch Netz-Sperren auf. + ## [0.5.0] – 2026-10-11 ### Hinzugefügt @@ -234,7 +246,8 @@ Erste Version. - Löschen von Zugangsdaten, die noch von einem Mount benutzt werden, trennt den Mount nicht mehr stillschweigend ab, sondern wird von der Datenbank abgelehnt. -[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...HEAD +[Unreleased]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.6.0...HEAD +[0.6.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.5.0...v0.6.0 [0.5.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.4.0...v0.5.0 [0.4.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.3.0...v0.4.0 [0.3.0]: https://git.wilde-server.de/florian.wilde/netmover/compare/v0.2.1...v0.3.0 diff --git a/DEVLOG.md b/DEVLOG.md index 0b36104..e061a1f 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -19,10 +19,26 @@ Zeiten in Ortszeit (Europe/Berlin). | 2026-10-11 | 00:13 | 00:32 | 0:19 | – | Menü, Anleitung, Roadmap, Uptime Kuma (0.3.0) | | 2026-10-11 | 00:31 | 00:50 | 0:19 | – | Pausieren, Wartungsmodus, E-Mail (0.4.0) | | 2026-10-11 | 00:54 | 01:16 | 0:22 | – | SFTP-Quelle, Zugangsdaten, Anmeldung (0.5.0) | -| **Summe** | | | **3:17** | | | +| 2026-10-11 | 08:43 | 08:55 | 0:12 | – | Anmeldung je Netz (0.6.0) | +| **Summe** | | | **3:29** | | | --- +## 2026-10-11 · 08:43–08:55 · Anmeldung je Netz (0.6.0) + +**Anlass** – Wunsch: Anmeldung abschalten, und zwar konfigurierbar, aus welchen Netzen +mit und ohne Passwort. Zugriffe kamen aus 10.0.10.0/24 und 10.1.5.0/24 (Access-Log). + +**Erledigt** +- `auth.access_decision()` als einzige Entscheidung (ohne PW / mit PW / abgewiesen), + benutzt von Middleware (403 für fremde Netze), Guard und Anmeldeseite. +- Schutz vor Aussperren beim Speichern; `reset-password` hebt Sperren auf. +- 19 Tests für die Anmeldung (336 gesamt). + +**Entscheidungen** +- `/healthz` und `/static` nie sperren – Uptime Kuma kann in einem anderen Netz stehen. +- Nicht-IP-Clients (z. B. Unix-Socket) gelten als „in keinem Netz“. + ## 2026-10-11 · 00:54–01:16 · SFTP-Quelle, Zugangsdaten, Anmeldung abschaltbar (0.5.0) **Erledigt** diff --git a/ROADMAP.md b/ROADMAP.md index fc25e98..947a912 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -28,6 +28,7 @@ Legende: ✅ erledigt · 🔨 in Arbeit · 📋 geplant · 💡 Idee (noch nicht | Version | Thema | |---|---| +| 0.6.0 | Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen, Schutz vor Aussperren | | 0.5.0 | SFTP als Quelle; gespeicherte Zugangsdaten für SMB/SFTP-Passwörter (Seite „Zugangsdaten“); Anmeldung abschaltbar (vertrauenswürdige Netze / ganz) | | 0.4.0 | Automatisch pausieren/fortsetzen bei nicht erreichbaren Anbindungen; Wartungsmodus; E-Mail-Benachrichtigung; eine Meldung pro ausgefallener Anbindung | | 0.3.0 | Neues Menü (gruppierte Seitenleiste, Statusampel, Handy-Menü); Anleitung im Repo und als Hilfe-Seite; Roadmap; Uptime Kuma (Einzel-Endpunkte, Push-Monitor) | diff --git a/compose.yml b/compose.yml index 12ffedb..4b8ac39 100644 --- a/compose.yml +++ b/compose.yml @@ -4,7 +4,7 @@ services: netmover: build: . - image: netmover:0.5.0 + image: netmover:0.6.0 container_name: netmover restart: unless-stopped cap_add: diff --git a/docs/ANLEITUNG.md b/docs/ANLEITUNG.md index 5336a82..55fc328 100644 --- a/docs/ANLEITUNG.md +++ b/docs/ANLEITUNG.md @@ -333,25 +333,33 @@ dreimal hintereinander gescheitert. ### Anmeldung {#anmeldung} -Standardmäßig fragt NetMover immer nach dem Passwort. Unter *Einstellungen → -Anmeldung* lässt sich das ändern (Bestätigung mit dem aktuellen Passwort): +Unter *Einstellungen → Anmeldung* legst du fest, **aus welchen Netzen** es mit und +ohne Passwort geht (Bestätigung mit dem aktuellen Passwort): -| Einstellung | Wirkung | +| Feld | Wirkung | |---|---| -| **immer** (empfohlen) | Anmeldung bei jedem Zugriff | -| **nicht aus vertrauenswürdigen Netzen** | ohne Passwort aus den eingetragenen Netzen, z. B. `10.0.10.0/24`; von überall sonst mit Passwort | -| **nie** | keine Anmeldung – nur hinter einem Reverse Proxy mit eigener Anmeldung (z. B. Authelia, Authentik) | +| **Ohne Passwort aus diesen Netzen** | z. B. `10.0.10.0/24, 10.1.5.0/24` – von dort keine Anmeldung. Leer = niemand. `0.0.0.0/0, ::/0` = Anmeldung ganz aus. | +| **Mit Passwort aus diesen Netzen** | Leer = **alle übrigen** Netze mit Passwort. Ausgefüllt = nur von dort mit Passwort, **alle anderen Netze werden abgewiesen** (auch die Anmeldeseite). | -Solange ohne Anmeldung gearbeitet wird, steht oben gelb „ohne Anmeldung“. Der Schutz -gegen untergeschobene Formulare bleibt immer aktiv. +Beispiel: Ohne Passwort aus dem Heimnetz `10.0.10.0/24`, mit Passwort aus dem +VPN-Netz `10.8.0.0/24`, alles andere gesperrt. + +- Einzelne Adressen gehen auch (`192.168.1.20`). +- Die Seite zeigt deine aktuelle Adresse. Eine Einstellung, mit der du dich selbst + aussperren würdest, wird nicht gespeichert. +- `/healthz` bleibt für die Überwachung von überall erreichbar. +- Solange du ohne Passwort arbeitest, steht oben gelb „ohne Anmeldung“. Der Schutz + gegen untergeschobene Formulare bleibt immer aktiv. > NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die -> Oberfläche ohne Anmeldung erreicht, kann alles ändern. Läuft ein Reverse Proxy auf -> demselben Server, kommen alle Anfragen von `127.0.0.1` – dann `127.0.0.1` nicht als -> vertrauenswürdig eintragen und `NETMOVER_FORWARDED_ALLOW_IPS` setzen. +> Oberfläche ohne Passwort erreicht, kann alles ändern – Netze „ohne Passwort“ daher +> so eng wie möglich wählen. Läuft ein Reverse Proxy auf demselben Server, kommen +> alle Anfragen von `127.0.0.1`: dann `127.0.0.1` nicht eintragen und +> `NETMOVER_FORWARDED_ALLOW_IPS` setzen. -Zurück zur Anmeldung: dieselbe Einstellung auf „immer“, oder auf dem Server -`/opt/netmover/venv/bin/netmover reset-password` (setzt auch die Anmeldung zurück). +Ausgesperrt oder Passwort vergessen? Auf dem Server +`/opt/netmover/venv/bin/netmover reset-password` – setzt das Passwort zurück, schaltet +die Anmeldung überall wieder ein und hebt Netz-Sperren auf. ### Export und Import {#export-import} diff --git a/src/netmover/__init__.py b/src/netmover/__init__.py index 570a308..7ff7d1e 100644 --- a/src/netmover/__init__.py +++ b/src/netmover/__init__.py @@ -1,3 +1,3 @@ """NetMover – NFS/SMB-Mounts verwalten und Dateien zeitgesteuert verschieben.""" -__version__ = "0.5.0" +__version__ = "0.6.0" diff --git a/src/netmover/cli.py b/src/netmover/cli.py index 4319076..d7ca8f1 100644 --- a/src/netmover/cli.py +++ b/src/netmover/cli.py @@ -64,10 +64,15 @@ def main(argv: Sequence[str] | None = None) -> int: row = s.get(Setting, PASSWORD_KEY) if row is not None: s.delete(row) - # Anmeldung wieder einschalten (falls abgeschaltet) - mode = s.get(Setting, "app.auth_mode") - if mode is not None: - mode.value = '"always"' + # Anmeldung wieder überall einschalten und Netz-Sperren aufheben + for key, value in ( + ("app.auth_mode", '"always"'), + ("app.trusted_networks", '""'), + ("app.allowed_networks", '""'), + ): + setting = s.get(Setting, key) + if setting is not None: + setting.value = value s.commit() engine.dispose() print("Passwort gelöscht. Dienst neu starten; der Einrichtungscode steht im Log.") diff --git a/src/netmover/settings_store.py b/src/netmover/settings_store.py index 4ddde1b..4d3ec08 100644 --- a/src/netmover/settings_store.py +++ b/src/netmover/settings_store.py @@ -46,9 +46,12 @@ class AppSettings: smtp_password_enc: str = "" smtp_from: str = "" smtp_to: str = "" - # Anmeldung: "always" | "trusted" (ohne Anmeldung aus trusted_networks) | "off" + # Anmeldung: Netze ohne Passwort (trusted_networks), Netze mit Passwort + # (allowed_networks, leer = alle übrigen); alle anderen Netze werden abgewiesen. + # auth_mode "off" = ohne Anmeldung von überall (aus 0.5.0, weiterhin gültig). auth_mode: str = "always" trusted_networks: str = "" + allowed_networks: str = "" _PREFIX = "app." diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 0193037..2881995 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -269,7 +269,7 @@ async def guard(request: Request) -> None: if not auth.password_is_set(state.session_factory): raise auth.LoginRequired(setup=True) cfg = load_settings(state.session_factory) - bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks) + bypass = auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks, cfg.allowed_networks) if bypass: request.state.auth_bypass = bypass elif not auth.is_logged_in(request): @@ -364,7 +364,7 @@ async def login_form(request: Request) -> Response: if not auth.password_is_set(state.session_factory): return redirect("/setup") cfg = load_settings(state.session_factory) - if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks): + if auth.login_bypass(request, cfg.auth_mode, cfg.trusted_networks, cfg.allowed_networks): return redirect("/") return render(request, "login.html") @@ -1235,39 +1235,54 @@ async def settings_password(request: Request) -> Response: @protected.post("/settings/auth", response_class=HTMLResponse) async def settings_auth(request: Request) -> Response: - """Anmeldung abschalten bzw. auf vertrauenswürdige Netze beschränken. - Nur mit dem aktuellen Passwort.""" + """Netze ohne / mit Passwort festlegen. Nur mit dem aktuellen Passwort und nie so, + dass man sich mit der eigenen Adresse aussperrt.""" state = nm(request) form = _form_dict(await request.form()) + back = redirect("/settings#anmeldung") if not await run_in_threadpool( auth.verify_password, state.session_factory, form.get("current", "") ): flash(request, "Aktuelles Passwort falsch – Anmeldung unverändert.", "error") - return redirect("/settings#anmeldung") - mode = form.get("auth_mode", auth.AUTH_ALWAYS) - networks = (form.get("trusted_networks") or "").strip() - if mode not in auth.AUTH_MODES: - flash(request, "Ungültige Auswahl.", "error") - return redirect("/settings#anmeldung") + return back + trusted = (form.get("trusted_networks") or "").strip() + allowed = (form.get("allowed_networks") or "").strip() try: - nets = auth.parse_networks(networks) + auth.parse_networks(trusted) + auth.parse_networks(allowed) except ValueError as exc: flash(request, str(exc), "error") - return redirect("/settings#anmeldung") - if mode == auth.AUTH_TRUSTED and not nets: - flash(request, "Bitte mindestens ein vertrauenswürdiges Netz angeben.", "error") - return redirect("/settings#anmeldung") + return back + me = auth.client_host(request) + if auth.access_decision(me, auth.AUTH_ALWAYS, trusted, allowed) == auth.ACCESS_DENY: + flash( + request, + f"Nicht gespeichert: Damit wärst du selbst ausgesperrt (deine Adresse {me}). " + "Trage dein Netz bei „mit“ oder „ohne Passwort“ ein.", + "error", + ) + return back cfg = load_settings(state.session_factory) - cfg.auth_mode, cfg.trusted_networks = mode, networks + cfg.trusted_networks, cfg.allowed_networks = trusted, allowed + cfg.auth_mode = auth.AUTH_TRUSTED if trusted else auth.AUTH_ALWAYS save_settings(state.session_factory, cfg) - event(log, "auth.mode_changed", level=30, mode=mode, networks=networks) - msgs = { - auth.AUTH_ALWAYS: "Anmeldung ist wieder immer erforderlich.", - auth.AUTH_TRUSTED: f"Ohne Anmeldung aus: {networks}. Von überall sonst mit Passwort.", - auth.AUTH_OFF: "Anmeldung abgeschaltet – NetMover ist ohne Passwort bedienbar.", - } - flash(request, msgs[mode], "info" if mode == auth.AUTH_ALWAYS else "warn") - return redirect("/settings#anmeldung") + event( + log, + "auth.access_changed", + level=30, + without_password=trusted, + with_password=allowed or "alle übrigen", + ) + parts = [f"ohne Passwort aus: {trusted}" if trusted else "überall mit Passwort"] + parts.append( + f"mit Passwort nur aus: {allowed} – alle anderen Netze abgewiesen" + if allowed + else "alle übrigen Netze mit Passwort" + ) + flash( + request, "Anmeldung gespeichert: " + "; ".join(parts) + ".", "warn" if trusted else "info" + ) + return back @protected.get("/settings/export") @@ -1371,6 +1386,35 @@ def create_app(settings: Settings | None = None, state: NetMover | None = None) openapi_url=None, ) + @app.middleware("http") + async def network_access( + request: Request, call_next: Callable[[Request], Awaitable[Response]] + ) -> Response: + path = request.url.path + if not (path.startswith("/healthz") or path.startswith("/static/")): + app_state = getattr(request.app.state, "nm", None) + if app_state is not None: + cfg = load_settings(app_state.session_factory) + decision = auth.access_decision( + auth.client_host(request), + cfg.auth_mode, + cfg.trusted_networks, + cfg.allowed_networks, + ) + if decision == auth.ACCESS_DENY: + event( + log, + "auth.denied_network", + level=30, + client=auth.client_host(request) or "?", + ) + return HTMLResponse( + "NetMover" + "

Zugriff aus diesem Netz ist nicht erlaubt.

", + status_code=403, + ) + return await call_next(request) + @app.middleware("http") async def security_headers( request: Request, call_next: Callable[[Request], Awaitable[Response]] diff --git a/src/netmover/web/auth.py b/src/netmover/web/auth.py index 26e35e5..f4849c2 100644 --- a/src/netmover/web/auth.py +++ b/src/netmover/web/auth.py @@ -51,25 +51,59 @@ def parse_networks(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Netwo return nets -def login_bypass(request: Request, mode: str, trusted: str) -> str | None: - """Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``. +ACCESS_NOPW, ACCESS_PW, ACCESS_DENY = "nopw", "pw", "deny" + + +def _address(host: str | None) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None: + if not host: + return None + try: + return ipaddress.ip_address(host) + except ValueError: + return None + + +def _networks_or_empty(raw: str) -> list[ipaddress.IPv4Network | ipaddress.IPv6Network]: + try: + return parse_networks(raw) + except ValueError: + return [] # gespeicherte Werte sind beim Speichern geprüft + + +def access_decision(host: str | None, mode: str, trusted: str, allowed: str) -> str: + """Wie darf diese Adresse auf die Oberfläche zugreifen? + + 1. aus ``trusted`` (oder Modus „off“): **ohne Passwort**, + 2. ``allowed`` leer oder Adresse darin: **mit Passwort**, + 3. sonst: **abgewiesen**. Hinter einem Reverse Proxy ist die Client-Adresse nur dann echt, wenn ``NETMOVER_FORWARDED_ALLOW_IPS`` den Proxy kennt – sonst sähe jede Anfrage aus, als käme sie vom Proxy selbst.""" if mode == AUTH_OFF: - return AUTH_OFF - if mode != AUTH_TRUSTED or request.client is None: + return ACCESS_NOPW + addr = _address(host) + if addr is not None and any(addr in n for n in _networks_or_empty(trusted)): + return ACCESS_NOPW + allowed_nets = _networks_or_empty(allowed) + if allowed_nets and (addr is None or not any(addr in n for n in allowed_nets)): + return ACCESS_DENY + return ACCESS_PW + + +def client_host(request: Request) -> str | None: + return request.client.host if request.client else None + + +def login_bypass(request: Request, mode: str, trusted: str, allowed: str = "") -> str | None: + """Grund, warum keine Anmeldung nötig ist (``"off"``/``"trusted"``), sonst ``None``.""" + if access_decision(client_host(request), mode, trusted, allowed) != ACCESS_NOPW: return None - try: - addr = ipaddress.ip_address(request.client.host) - except ValueError: - return None - try: - nets = parse_networks(trusted) - except ValueError: - return None - return AUTH_TRUSTED if any(addr in n for n in nets) else None + return AUTH_OFF if mode == AUTH_OFF or _covers_everything(trusted) else AUTH_TRUSTED + + +def _covers_everything(raw: str) -> bool: + return any(n.prefixlen == 0 for n in _networks_or_empty(raw)) def includes_loopback(raw: str) -> bool: diff --git a/src/netmover/web/templates/settings.html b/src/netmover/web/templates/settings.html index a1d0003..f0cfc5f 100644 --- a/src/netmover/web/templates/settings.html +++ b/src/netmover/web/templates/settings.html @@ -66,20 +66,23 @@

Anmeldung ?

-
+ {{ csrf_field(csrf) }} +

Deine Adresse gerade: {{ client_ip }}

- {{ select('auth_mode', 'Anmeldung erforderlich', [('always', 'immer (empfohlen)'), ('trusted', 'nicht aus vertrauten Netzen'), ('off', 'nie')], cfg.auth_mode) }} -
- - Mit Komma getrennt. Deine Adresse gerade: {{ client_ip }}. Von allen anderen Adressen wird weiter nach dem Passwort gefragt.
+
+ + Mit Komma getrennt; einzelne Adressen gehen auch. Leer = niemand ohne Passwort. 0.0.0.0/0, ::/0 = Anmeldung ganz aus.
+
+ + Leer = von überall sonst mit Passwort. Ausgefüllt = alle anderen Netze werden abgewiesen (auch die Anmeldeseite). /healthz bleibt für die Überwachung erreichbar.
-

NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. - Ohne Anmeldung kann jeder, der die Oberfläche erreicht, Anbindungen und Regeln ändern. „Nie“ nur hinter einem Reverse Proxy mit eigener Anmeldung (z. B. Authelia) verwenden. - Hinter einem Proxy außerdem NETMOVER_FORWARDED_ALLOW_IPS setzen, sonst sieht jede Anfrage aus, als käme sie vom Proxy.

- {% if loopback_trusted %}

Die Liste enthält 127.0.0.1/::1. Läuft ein Reverse Proxy auf diesem Server, gilt dann jede Anfrage über den Proxy als vertrauenswürdig.

{% endif %} +

NetMover läuft als root und kennt die Zugangsdaten deiner Freigaben. Wer die Oberfläche ohne Passwort erreicht, + kann alles ändern – Netze „ohne Passwort“ daher so eng wie möglich wählen. Hinter einem Reverse Proxy NETMOVER_FORWARDED_ALLOW_IPS setzen, + sonst sieht jede Anfrage aus, als käme sie vom Proxy. Eine Einstellung, mit der du dich selbst aussperren würdest, wird nicht gespeichert.

+ {% if loopback_trusted %}

„Ohne Passwort“ enthält 127.0.0.1/::1. Läuft ein Reverse Proxy auf diesem Server, gilt dann jede Anfrage über den Proxy als vertrauenswürdig.

{% endif %}
diff --git a/tests/test_auth_modes.py b/tests/test_auth_modes.py index 20b1ef6..33825c0 100644 --- a/tests/test_auth_modes.py +++ b/tests/test_auth_modes.py @@ -1,4 +1,4 @@ -"""0.5.0: Anmeldung abschaltbar – immer / nicht aus vertrauenswürdigen Netzen / nie.""" +"""Anmeldung je Netz: ohne Passwort / mit Passwort / abgewiesen.""" from __future__ import annotations @@ -7,15 +7,16 @@ from typing import Any import pytest from fastapi.testclient import TestClient -from netmover.settings_store import load_settings -from netmover.web.auth import includes_loopback, parse_networks -from tests.webutil import PASSWORD, csrf_of, post - - -def _set_mode(client: TestClient, mode: str, nets: str = "", current: str = PASSWORD) -> Any: - return post( - client, "/settings/auth", {"auth_mode": mode, "trusted_networks": nets, "current": current} - ) +from netmover.settings_store import load_settings, save_settings +from netmover.web.auth import ( + ACCESS_DENY, + ACCESS_NOPW, + ACCESS_PW, + access_decision, + includes_loopback, + parse_networks, +) +from tests.webutil import PASSWORD, csrf_of def _other(client: TestClient, host: str) -> TestClient: @@ -23,70 +24,124 @@ def _other(client: TestClient, host: str) -> TestClient: return TestClient(client.app, follow_redirects=False, client=(host, 51000)) -@pytest.fixture -def lan_client(client: TestClient) -> TestClient: - return _other(client, "10.0.10.50") - - -def test_default_requires_login(client: TestClient, lan_client: TestClient) -> None: - assert lan_client.get("/").status_code == 303 - - -def test_mode_change_needs_current_password(client: TestClient, state: Any) -> None: - _set_mode(client, "off", current="falsch") - assert load_settings(state.session_factory).auth_mode == "always" - assert "Aktuelles Passwort falsch" in client.get("/settings").text - - -def test_trusted_networks( - client: TestClient, lan_client: TestClient, settings: Any, state: Any -) -> None: - _set_mode(client, "trusted", "10.0.10.0/24") - r = lan_client.get("/") - assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text - assert "Abmelden" not in r.text - assert lan_client.get("/login").headers["location"] == "/" - # CSRF gilt weiterhin - assert lan_client.post("/maintenance").status_code == 403 - token = csrf_of(lan_client.get("/").text) - assert lan_client.post("/maintenance", data={"csrf_token": token}).status_code == 303 - # andere Netze brauchen weiter das Passwort - assert _other(client, "192.168.5.5").get("/").status_code == 303 - - -def test_off_and_back_on(client: TestClient, lan_client: TestClient) -> None: - _set_mode(client, "off") - r = lan_client.get("/rules") - assert r.status_code == 200 and "ohne Anmeldung" in r.text - token = csrf_of(r.text) - lan_client.post( +def _save(c: TestClient, trusted: str = "", allowed: str = "", current: str = PASSWORD) -> Any: + token = ( + csrf_of(c.get("/settings").text) + if "X-Test-CSRF" not in c.headers + else c.headers["X-Test-CSRF"] + ) + return c.post( "/settings/auth", data={ "csrf_token": token, - "auth_mode": "always", - "trusted_networks": "", - "current": PASSWORD, + "trusted_networks": trusted, + "allowed_networks": allowed, + "current": current, }, ) - assert lan_client.get("/").status_code == 303 + + +def _set(state: Any, **kw: str) -> None: + cfg = load_settings(state.session_factory) + for k, v in kw.items(): + setattr(cfg, k, v) + save_settings(state.session_factory, cfg) + + +# -- Entscheidung ------------------------------------------------------------------------------ + + +@pytest.mark.parametrize( + ("host", "trusted", "allowed", "expected"), + [ + ("10.0.10.50", "", "", ACCESS_PW), # Standard: überall mit Passwort + ("10.0.10.50", "10.0.10.0/24", "", ACCESS_NOPW), + ("10.1.5.9", "10.0.10.0/24", "", ACCESS_PW), # übrige Netze mit Passwort + ("10.1.5.9", "10.0.10.0/24", "10.1.5.0/24", ACCESS_PW), + ("192.168.5.5", "10.0.10.0/24", "10.1.5.0/24", ACCESS_DENY), + ("10.0.10.50", "10.0.10.0/24", "10.1.5.0/24", ACCESS_NOPW), # ohne PW schlägt Liste + ("2001:db8::5", "0.0.0.0/0, ::/0", "", ACCESS_NOPW), # Anmeldung ganz aus + ("testclient", "", "10.0.0.0/8", ACCESS_DENY), # keine IP-Adresse + ], +) +def test_access_decision(host: str, trusted: str, allowed: str, expected: str) -> None: + assert access_decision(host, "always", trusted, allowed) == expected + + +def test_legacy_off_mode_still_works() -> None: + assert access_decision("192.168.5.5", "off", "", "10.1.5.0/24") == ACCESS_NOPW + + +# -- Oberfläche -------------------------------------------------------------------------------- + + +def test_default_requires_login(client: TestClient) -> None: + assert _other(client, "10.0.10.50").get("/").status_code == 303 + + +def test_change_needs_current_password(client: TestClient, state: Any) -> None: + _save(client, trusted="10.0.10.0/24", current="falsch") + assert load_settings(state.session_factory).trusted_networks == "" + assert "Aktuelles Passwort falsch" in client.get("/settings").text + + +def test_without_password_from_trusted_network(client: TestClient, state: Any) -> None: + _save(client, trusted="10.0.10.0/24") + lan = _other(client, "10.0.10.50") + r = lan.get("/") + assert r.status_code == 200 and "ohne Anmeldung (vertrautes Netz)" in r.text + assert "Abmelden" not in r.text and lan.get("/login").headers["location"] == "/" + assert lan.post("/maintenance").status_code == 403 # CSRF gilt weiterhin + assert ( + lan.post("/maintenance", data={"csrf_token": csrf_of(lan.get("/").text)}).status_code == 303 + ) + assert _other(client, "10.1.5.9").get("/").status_code == 303 # übrige mit Passwort + + +def test_other_networks_are_rejected_when_password_list_is_set( + client: TestClient, state: Any +) -> None: + _save(client, trusted="0.0.0.0/0") # damit der Test-Client (keine IP) weiterkommt … + _set(state, trusted_networks="10.0.10.0/24", allowed_networks="10.1.5.0/24") # … dann scharf + assert _other(client, "10.0.10.50").get("/").status_code == 200 + assert _other(client, "10.1.5.9").get("/").headers["location"].startswith("/login") + blocked = _other(client, "192.168.5.5") + for url in ("/", "/login", "/setup", "/mounts"): + assert blocked.get(url).status_code == 403, url + assert blocked.get("/healthz").status_code == 200 # Überwachung bleibt erreichbar + assert blocked.get("/static/app.css").status_code == 200 + + +def test_cannot_lock_yourself_out(client: TestClient, state: Any) -> None: + lan = _other(client, "10.0.10.50") + _save(client, trusted="10.0.10.0/24") + # aus 10.0.10.50 eine Liste speichern, in der das eigene Netz fehlt + r = _save(lan, trusted="", allowed="10.1.5.0/24") + assert r.status_code == 303 + assert "ausgesperrt" in lan.get("/settings").text + cfg = load_settings(state.session_factory) + assert cfg.trusted_networks == "10.0.10.0/24" and cfg.allowed_networks == "" + # eigenes Netz bei „mit Passwort“ – erlaubt + _save(lan, trusted="10.0.10.0/24", allowed="10.1.5.0/24") + assert load_settings(state.session_factory).allowed_networks == "10.1.5.0/24" + + +def test_everything_without_password(client: TestClient) -> None: + _save(client, trusted="0.0.0.0/0, ::/0") + r = _other(client, "192.168.77.1").get("/rules") + assert r.status_code == 200 and ">ohne Anmeldung<" in r.text def test_setup_still_required_without_password(raw_client: TestClient, state: Any) -> None: - from netmover.settings_store import save_settings - - cfg = load_settings(state.session_factory) - cfg.auth_mode = "off" - save_settings(state.session_factory, cfg) + _set(state, trusted_networks="0.0.0.0/0") assert raw_client.get("/").headers["location"] == "/setup" -def test_validation(client: TestClient, state: Any) -> None: - _set_mode(client, "trusted", "") - assert "mindestens ein" in client.get("/settings").text - _set_mode(client, "trusted", "10.0.10.0/24, quatsch") +def test_validation_and_loopback_hint(client: TestClient, state: Any) -> None: + _save(client, trusted="10.0.10.0/24, quatsch") assert "kein gültiges Netz" in client.get("/settings").text - assert load_settings(state.session_factory).auth_mode == "always" - _set_mode(client, "trusted", "127.0.0.1") + assert load_settings(state.session_factory).trusted_networks == "" + _save(client, trusted="127.0.0.1") assert "jede Anfrage über den Proxy" in client.get("/settings").text @@ -102,13 +157,11 @@ def test_reset_password_turns_login_back_on( state: Any, settings: Any, monkeypatch: pytest.MonkeyPatch ) -> None: from netmover import cli - from netmover.settings_store import save_settings from netmover.web.auth import set_password set_password(state.session_factory, PASSWORD) - cfg = load_settings(state.session_factory) - cfg.auth_mode = "off" - save_settings(state.session_factory, cfg) + _set(state, auth_mode="off", trusted_networks="0.0.0.0/0", allowed_networks="10.1.5.0/24") monkeypatch.setattr(cli, "get_settings", lambda: settings) assert cli.main(["reset-password"]) == 0 - assert load_settings(state.session_factory).auth_mode == "always" + cfg = load_settings(state.session_factory) + assert cfg.auth_mode == "always" and cfg.trusted_networks == "" and cfg.allowed_networks == ""