feat: Anmeldung je Netz – ohne Passwort, mit Passwort oder abgewiesen

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 08:46:43 +02:00
co-authored by Claude Opus 5.5
parent 579a2c164f
commit cf7a3bcc88
12 changed files with 315 additions and 135 deletions
+120 -67
View File
@@ -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</strong> 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 == ""