Vollstaendiger Neubau der Anwendung. Der vorherige Stand bleibt unveraendert im Zweig SONNET5 erhalten. Aufbau: apps/tesm-license (Anwendung), packages/tesm-core (gemeinsamer Kern), packages/tesm-licensing (Lizenzprotokoll), deploy (Installation, systemd, privilegierter Helfer), docs, tests. Die verwaltete Anwendung liegt in ihrem eigenen Repository; beide Repositorien bringen die gemeinsamen Pakete mit, damit sich jedes allein installieren laesst. Die wichtigsten Unterschiede zum Vorgaenger: * Keine doppelte licensing.py -- ein Paket, das beide Anwendungen installieren, statt zweier Dateien, die byte-identisch bleiben sollen. * Der Webprozess laeuft unprivilegiert; alles, was Root braucht, geht ueber einen einzigen Helfer mit Positivlisten fuer jedes Argument. * CSRF-Schutz ueberhaupt -- der Vorgaenger hatte keinen. * Rechte werden serverseitig geprueft, nicht nur im Template ausgeblendet. * Keine Lizenz ohne master_endpoint: eine Ausstellung ohne Endpunkt wird abgelehnt statt eine Lizenz zu erzeugen, die sich nie aktivieren kann. * Offline-Aktivierung in beide Richtungen; die Lizenz bleibt als "Aktivierung offen" markiert, bis sie zurueckkommt. * Getrennte Signaturkontexte je Nachrichtenart, Nonce gegen Wiedereinspielung, seq gegen das Zurueckrollen auf eine aeltere Lizenz. * Kein Hostname im Maschinen-Fingerabdruck. * Verschachtelte Datenbankverbindungen sind ein Fehler, kein Deadlock. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
722 lines
27 KiB
Python
722 lines
27 KiB
Python
"""Tests der Sicherheitsgrenzen des Kerns.
|
|
|
|
Jeder Test hier deckt eine Grenze ab, die im Vorgaengerprojekt entweder fehlte
|
|
oder nur im Template durchgesetzt wurde.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import sqlite3
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from tesm_core import audit
|
|
from tesm_core.db import Database, NestedConnectionError
|
|
from tesm_core.extension import core
|
|
from tesm_core.keystore import DecryptionError, Keystore
|
|
from tesm_core.migrations import split_statements
|
|
from tesm_core.rbac import service as rbac
|
|
from tesm_core.rbac.model import Action, Area, PermissionTree, Resource, RolePreset
|
|
from tesm_core.security import passwords, totp
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Rechtebaum
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def _tree() -> PermissionTree:
|
|
return PermissionTree(
|
|
[
|
|
Area(
|
|
key="ops",
|
|
label="Betrieb",
|
|
icon="cpu",
|
|
resources=(
|
|
Resource(
|
|
"devices",
|
|
"Geraete",
|
|
(Action.VIEW, Action.CREATE, Action.EDIT, Action.DELETE, Action.EXECUTE),
|
|
),
|
|
Resource("switches", "Switche", (Action.VIEW, Action.EDIT)),
|
|
),
|
|
),
|
|
Area(
|
|
key="admin",
|
|
label="Verwaltung",
|
|
icon="sliders",
|
|
resources=(Resource("users", "Benutzer", (Action.VIEW, Action.CREATE)),),
|
|
),
|
|
]
|
|
)
|
|
|
|
|
|
def test_area_columns_only_contain_used_actions():
|
|
tree = _tree()
|
|
ops = tree.area("ops")
|
|
admin = tree.area("admin")
|
|
assert Action.EXECUTE in ops.columns()
|
|
assert Action.EXECUTE not in admin.columns()
|
|
assert Action.DELETE not in admin.columns()
|
|
|
|
|
|
def test_sanitize_adds_view_and_area_switch():
|
|
tree = _tree()
|
|
result = tree.sanitize(["devices.delete"])
|
|
assert result == ["devices.delete", "devices.view", "ops.view"]
|
|
|
|
|
|
def test_sanitize_drops_unknown_permissions():
|
|
tree = _tree()
|
|
assert tree.sanitize(["gibtsnicht.view", "devices.view"]) == ["devices.view", "ops.view"]
|
|
|
|
|
|
def test_kill_switch_disables_all_children():
|
|
tree = _tree()
|
|
granted = {"devices.view", "devices.edit", "switches.view"}
|
|
assert tree.effective(granted) == set() # ohne ops.view greift nichts
|
|
assert tree.effective(granted | {"ops.view"}) == granted | {"ops.view"}
|
|
|
|
|
|
def test_duplicate_resource_keys_rejected():
|
|
with pytest.raises(ValueError):
|
|
PermissionTree(
|
|
[
|
|
Area("a", "A", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
|
Area("b", "B", "grid", (Resource("x", "X", (Action.VIEW,)),)),
|
|
]
|
|
)
|
|
|
|
|
|
def test_resource_without_view_rejected():
|
|
with pytest.raises(ValueError):
|
|
PermissionTree([Area("a", "A", "grid", (Resource("x", "X", (Action.EDIT,)),))])
|
|
|
|
|
|
def test_preset_expansion():
|
|
tree = _tree()
|
|
preset = RolePreset(
|
|
key="ops-read",
|
|
name="Betrieb lesen",
|
|
description="",
|
|
grants=(("devices", (Action.VIEW,)), ("switches", "*")),
|
|
)
|
|
assert tree.expand_preset(preset) == [
|
|
"devices.view",
|
|
"ops.view",
|
|
"switches.edit",
|
|
"switches.view",
|
|
]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Eskalationsschutz
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
class _Actor:
|
|
def __init__(self, is_admin: bool, permissions: set[str]):
|
|
self.is_admin = is_admin
|
|
self.id = 42
|
|
self._permissions = permissions
|
|
|
|
def has(self, *permissions: str) -> bool:
|
|
return self.is_admin or all(p in self._permissions for p in permissions)
|
|
|
|
|
|
def test_no_amplification_blocks_unknown_permission():
|
|
tree = _tree()
|
|
actor = _Actor(False, {"ops.view", "devices.view"})
|
|
with pytest.raises(rbac.PermissionDenied) as exc:
|
|
rbac.guard_no_amplification(actor, tree, ["devices.delete"])
|
|
assert "nicht besitzen" in str(exc.value)
|
|
|
|
|
|
def test_no_amplification_allows_own_permissions():
|
|
tree = _tree()
|
|
actor = _Actor(False, {"ops.view", "devices.view"})
|
|
rbac.guard_no_amplification(actor, tree, ["devices.view", "ops.view"])
|
|
|
|
|
|
def test_admin_may_grant_anything():
|
|
tree = _tree()
|
|
rbac.guard_no_amplification(_Actor(True, set()), tree, ["devices.delete"])
|
|
|
|
|
|
def test_group_editor_cannot_escalate_via_new_group(core_app, make_user):
|
|
"""Der zentrale Fall: ein Gruppenverwalter darf sich nicht selbst hoeherstufen."""
|
|
from tesm_core.auth.service import load_user
|
|
|
|
extension = core(core_app)
|
|
row = make_user("gruppenchef", "Ein-gutes-Passwort-1", permissions=("groups.create", "groups.view"))
|
|
with core_app.app_context(), extension.database.session() as conn:
|
|
actor = load_user(conn, int(row["id"]))
|
|
assert actor is not None
|
|
with extension.database.transaction(conn):
|
|
with pytest.raises(rbac.PermissionDenied):
|
|
rbac.create_group(
|
|
conn,
|
|
name="Hintertuer",
|
|
description="",
|
|
permissions=["users.create", "administration.view"],
|
|
tree=extension.permissions,
|
|
actor=actor,
|
|
)
|
|
|
|
|
|
def test_last_admin_cannot_be_demoted(core_app, make_user):
|
|
from tesm_core.auth.service import load_user
|
|
|
|
extension = core(core_app)
|
|
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
with core_app.app_context(), extension.database.session() as conn:
|
|
actor = load_user(conn, int(admin_row["id"]))
|
|
second = conn.execute(
|
|
"INSERT INTO users (username, password_hash, is_admin, auth_source, created_at, updated_at) "
|
|
"VALUES ('zweiter','x',1,'local',datetime('now'),datetime('now'))"
|
|
)
|
|
second_id = int(second.lastrowid or 0)
|
|
with extension.database.transaction(conn):
|
|
# Solange zwei Admins existieren, ist das Herabstufen erlaubt.
|
|
rbac.update_user(
|
|
conn, second_id, actor=actor, tree=extension.permissions, is_admin=False
|
|
)
|
|
with extension.database.transaction(conn):
|
|
with pytest.raises(rbac.PermissionDenied):
|
|
rbac.guard_last_admin(conn, int(admin_row["id"]), action="Herabstufen")
|
|
|
|
|
|
def test_cannot_change_own_admin_flag(core_app, make_user):
|
|
from tesm_core.auth.service import load_user
|
|
|
|
extension = core(core_app)
|
|
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
with core_app.app_context(), extension.database.session() as conn:
|
|
actor = load_user(conn, int(row["id"]))
|
|
with extension.database.transaction(conn):
|
|
with pytest.raises(rbac.PermissionDenied):
|
|
rbac.update_user(
|
|
conn, int(row["id"]), actor=actor, tree=extension.permissions, is_admin=False
|
|
)
|
|
|
|
|
|
def test_non_admin_cannot_touch_admin_account(core_app, make_user):
|
|
from tesm_core.auth.service import load_user
|
|
|
|
extension = core(core_app)
|
|
admin_row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
editor_row = make_user("helfer", "Ein-gutes-Passwort-1", permissions=("users.edit", "users.view"))
|
|
with core_app.app_context(), extension.database.session() as conn:
|
|
actor = load_user(conn, int(editor_row["id"]))
|
|
with extension.database.transaction(conn):
|
|
with pytest.raises(rbac.PermissionDenied):
|
|
rbac.update_user(
|
|
conn,
|
|
int(admin_row["id"]),
|
|
actor=actor,
|
|
tree=extension.permissions,
|
|
is_locked=True,
|
|
)
|
|
|
|
|
|
def test_system_group_permissions_locked(core_app, make_user):
|
|
from tesm_core.auth.service import load_user
|
|
|
|
extension = core(core_app)
|
|
row = make_user("root", "Ein-gutes-Passwort-1", is_admin=True)
|
|
with core_app.app_context(), extension.database.session() as conn:
|
|
actor = load_user(conn, int(row["id"]))
|
|
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
|
with extension.database.transaction(conn):
|
|
with pytest.raises(rbac.PermissionDenied):
|
|
rbac.update_group(
|
|
conn,
|
|
default.id,
|
|
permissions=[],
|
|
tree=extension.permissions,
|
|
actor=actor,
|
|
)
|
|
# Mit ausdruecklicher Freischaltung geht es.
|
|
rbac.update_group(
|
|
conn,
|
|
default.id,
|
|
permissions=[],
|
|
tree=extension.permissions,
|
|
actor=actor,
|
|
unlock_system=True,
|
|
)
|
|
|
|
|
|
def test_presets_are_not_reset_on_restart(core_app):
|
|
"""Anders als im Vorgaenger bleiben Anpassungen an Systemgruppen erhalten."""
|
|
extension = core(core_app)
|
|
with extension.database.session() as conn:
|
|
default = next(g for g in rbac.list_groups(conn) if g.is_default)
|
|
with extension.database.transaction(conn):
|
|
conn.execute("DELETE FROM group_permissions WHERE group_id=?", (default.id,))
|
|
created = rbac.ensure_presets(conn, extension.permissions, extension.role_presets)
|
|
assert created == []
|
|
assert rbac.group_permissions(conn, default.id) == []
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Datenbank
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_second_connection_during_write_transaction_raises(tmp_path):
|
|
database = Database(tmp_path / "test.db")
|
|
with database.session() as conn:
|
|
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
with database.transaction(conn):
|
|
conn.execute("INSERT INTO t VALUES (1)")
|
|
with pytest.raises(NestedConnectionError) as exc:
|
|
database.connect()
|
|
assert "Schreibtransaktion" in str(exc.value)
|
|
|
|
|
|
def test_nested_transactions_commit_once(tmp_path):
|
|
database = Database(tmp_path / "test.db")
|
|
with database.session() as conn:
|
|
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
with database.transaction(conn):
|
|
conn.execute("INSERT INTO t VALUES (1)")
|
|
with database.transaction(conn):
|
|
conn.execute("INSERT INTO t VALUES (2)")
|
|
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 2
|
|
|
|
|
|
def test_inner_rollback_keeps_outer(tmp_path):
|
|
database = Database(tmp_path / "test.db")
|
|
with database.session() as conn:
|
|
conn.execute("CREATE TABLE t (a INTEGER)")
|
|
with database.transaction(conn):
|
|
conn.execute("INSERT INTO t VALUES (1)")
|
|
try:
|
|
with database.transaction(conn):
|
|
conn.execute("INSERT INTO t VALUES (2)")
|
|
raise RuntimeError("abbruch")
|
|
except RuntimeError:
|
|
pass
|
|
assert Database.value(conn, "SELECT COUNT(*) FROM t") == 1
|
|
|
|
|
|
def test_foreign_keys_enforced(tmp_path):
|
|
database = Database(tmp_path / "test.db")
|
|
with database.session() as conn:
|
|
conn.execute("CREATE TABLE parent (id INTEGER PRIMARY KEY)")
|
|
conn.execute("CREATE TABLE child (p INTEGER REFERENCES parent(id))")
|
|
with pytest.raises(sqlite3.IntegrityError):
|
|
conn.execute("INSERT INTO child VALUES (99)")
|
|
|
|
|
|
def test_wal_mode_active(tmp_path):
|
|
database = Database(tmp_path / "test.db")
|
|
with database.session() as conn:
|
|
assert Database.value(conn, "PRAGMA journal_mode").lower() == "wal"
|
|
|
|
|
|
def test_split_statements_handles_comments_and_strings():
|
|
script = """
|
|
-- ein Kommentar mit ; Semikolon
|
|
CREATE TABLE t (a TEXT DEFAULT 'x;y');
|
|
INSERT INTO t VALUES ('a;b'); /* Block; Kommentar */
|
|
"""
|
|
statements = split_statements(script)
|
|
assert len(statements) == 2
|
|
assert "x;y" in statements[0]
|
|
assert "a;b" in statements[1]
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Schluessel und Verschluesselung
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_keystore_roundtrip_and_aad_binding(tmp_path):
|
|
keystore = Keystore(tmp_path / "data.keys")
|
|
token = keystore.encrypt("geheim", aad="credentials.password:1")
|
|
assert keystore.decrypt(token, aad="credentials.password:1") == "geheim"
|
|
# Dasselbe Chiffrat an anderer Stelle eingespielt -> Fehler.
|
|
with pytest.raises(DecryptionError):
|
|
keystore.decrypt(token, aad="credentials.password:2")
|
|
|
|
|
|
def test_keystore_rotation_keeps_old_values_readable(tmp_path):
|
|
keystore = Keystore(tmp_path / "data.keys")
|
|
old_token = keystore.encrypt("alt", aad="x")
|
|
new_id = keystore.rotate()
|
|
assert new_id == "2"
|
|
assert keystore.decrypt(old_token, aad="x") == "alt"
|
|
assert keystore.needs_reencrypt(old_token) is True
|
|
new_token = keystore.encrypt("neu", aad="x")
|
|
assert keystore.needs_reencrypt(new_token) is False
|
|
|
|
|
|
def test_keystore_file_is_private(tmp_path):
|
|
import os
|
|
import stat
|
|
|
|
path = tmp_path / "data.keys"
|
|
Keystore(path)
|
|
if os.name == "posix":
|
|
mode = path.stat().st_mode
|
|
assert not mode & (stat.S_IRGRP | stat.S_IROTH)
|
|
|
|
|
|
def test_tampered_ciphertext_rejected(tmp_path):
|
|
keystore = Keystore(tmp_path / "data.keys")
|
|
token = keystore.encrypt("geheim", aad="x")
|
|
prefix, key_id, nonce, ciphertext = token.split(".", 3)
|
|
broken = f"{prefix}.{key_id}.{nonce}.{ciphertext[:-4]}AAAA"
|
|
with pytest.raises(DecryptionError):
|
|
keystore.decrypt(broken, aad="x")
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Passwoerter und TOTP
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_argon2_hash_and_verify():
|
|
stored = passwords.hash_password("Ein-gutes-Passwort-1")
|
|
assert stored.startswith("$argon2id$")
|
|
assert passwords.verify_password(stored, "Ein-gutes-Passwort-1") is True
|
|
assert passwords.verify_password(stored, "falsch") is False
|
|
assert passwords.needs_rehash(stored) is False
|
|
|
|
|
|
def test_bcrypt_legacy_hash_accepted_and_flagged_for_rehash():
|
|
bcrypt = pytest.importorskip("bcrypt")
|
|
legacy = bcrypt.hashpw(b"Altes-Passwort-1", bcrypt.gensalt(rounds=4)).decode()
|
|
assert passwords.is_legacy_hash(legacy) is True
|
|
assert passwords.verify_password(legacy, "Altes-Passwort-1") is True
|
|
assert passwords.needs_rehash(legacy) is True
|
|
|
|
|
|
def test_password_normalisation_across_platforms():
|
|
import unicodedata
|
|
|
|
nfd = unicodedata.normalize("NFD", "Grüße-aus-München-1")
|
|
nfc = unicodedata.normalize("NFC", "Grüße-aus-München-1")
|
|
assert nfd != nfc
|
|
stored = passwords.hash_password(nfd)
|
|
assert passwords.verify_password(stored, nfc) is True
|
|
|
|
|
|
def test_password_policy_rules():
|
|
policy = passwords.PasswordPolicy(min_length=12)
|
|
assert policy.check("Ein-gutes-Passwort-1") == []
|
|
assert any("Zeichen erforderlich" in p for p in policy.check("kurz"))
|
|
assert any("Zeichenarten" in p for p in policy.check("aaaaaaaaaaaaaaa"))
|
|
assert any("Benutzernamen" in p for p in policy.check("Tester-Passwort-1", username="tester"))
|
|
assert any("haeufig" in p for p in policy.check("passwort1234"))
|
|
|
|
|
|
def test_totp_generates_and_verifies():
|
|
secret = totp.generate_secret()
|
|
code = totp.current_code(secret)
|
|
assert totp.verify_code(secret, code) is True
|
|
assert totp.verify_code(secret, "000000") is False
|
|
assert totp.verify_code(secret, code, now=0) is False
|
|
|
|
|
|
def test_totp_drift_tolerance():
|
|
secret = totp.generate_secret()
|
|
now = 1_700_000_000
|
|
previous = totp.code_at(secret, int(now // totp.PERIOD) - 1)
|
|
assert totp.verify_code(secret, previous, now=now) is True
|
|
far = totp.code_at(secret, int(now // totp.PERIOD) - 5)
|
|
assert totp.verify_code(secret, far, now=now) is False
|
|
|
|
|
|
def test_totp_uri_contains_issuer_and_secret():
|
|
secret = totp.generate_secret()
|
|
uri = totp.provisioning_uri(secret, account="anna", issuer="TESM Test")
|
|
assert uri.startswith("otpauth://totp/")
|
|
assert secret in uri
|
|
assert "issuer=TESM%20Test" in uri
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Aenderungsprotokoll
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_audit_chain_detects_modification(core_app):
|
|
extension = core(core_app)
|
|
with extension.database.session() as conn:
|
|
with extension.database.transaction(conn):
|
|
for index in range(5):
|
|
audit.log(conn, actor="tester", action="test.event", target=f"ziel-{index}")
|
|
assert audit.verify_chain(conn)["ok"] is True
|
|
|
|
with extension.database.transaction(conn):
|
|
conn.execute("UPDATE audit_log SET detail='manipuliert' WHERE id=3")
|
|
result = audit.verify_chain(conn)
|
|
assert result["ok"] is False
|
|
assert result["broken_at"] == 3
|
|
assert "Hash" in result["reason"]
|
|
|
|
|
|
def test_audit_chain_detects_deletion(core_app):
|
|
extension = core(core_app)
|
|
with extension.database.session() as conn:
|
|
with extension.database.transaction(conn):
|
|
for index in range(5):
|
|
audit.log(conn, actor="tester", action="test.event", target=str(index))
|
|
with extension.database.transaction(conn):
|
|
conn.execute("DELETE FROM audit_log WHERE id=3")
|
|
result = audit.verify_chain(conn)
|
|
assert result["ok"] is False
|
|
|
|
|
|
def test_audit_archiving_moves_full_days(core_app, tmp_path):
|
|
extension = core(core_app)
|
|
old_day = (datetime.now(timezone.utc) - timedelta(days=3)).replace(microsecond=0)
|
|
with extension.database.session() as conn:
|
|
with extension.database.transaction(conn):
|
|
for index in range(40):
|
|
audit.log(
|
|
conn,
|
|
actor="tester",
|
|
action="test.old",
|
|
target=str(index),
|
|
ts=old_day + timedelta(seconds=index),
|
|
)
|
|
for index in range(5):
|
|
audit.log(conn, actor="tester", action="test.new", target=str(index))
|
|
with extension.database.transaction(conn):
|
|
written = audit.archive_old_rows(conn, tmp_path / "archiv", threshold=10, target=5)
|
|
assert written
|
|
# Uebrig bleiben ausschliesslich Zeilen des laufenden Tages (inkl. der
|
|
# Startzeile, die die App beim Migrieren selbst geschrieben hat).
|
|
today = datetime.now(timezone.utc).date().isoformat()
|
|
remaining = Database.all(conn, "SELECT DISTINCT substr(ts,1,10) AS day FROM audit_log")
|
|
assert [row["day"] for row in remaining] == [today]
|
|
lines = (tmp_path / "archiv" / written[0].name).read_text(encoding="utf-8").splitlines()
|
|
assert len(lines) == 40
|
|
assert json.loads(lines[0])["action"] == "test.old"
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Sitzungen
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_session_id_rotates_on_login(client, make_user, login):
|
|
make_user("anna", "Ein-gutes-Passwort-1")
|
|
client.get("/login")
|
|
before = client.get_cookie("tesm_session")
|
|
login("anna", "Ein-gutes-Passwort-1")
|
|
after = client.get_cookie("tesm_session")
|
|
assert after is not None
|
|
if before is not None:
|
|
assert before.value != after.value
|
|
|
|
|
|
def test_session_revocation_takes_effect_immediately(client, core_app, make_user, login):
|
|
from tesm_core.security import sessions as session_store
|
|
|
|
row = make_user("anna", "Ein-gutes-Passwort-1")
|
|
login("anna", "Ein-gutes-Passwort-1")
|
|
assert client.get("/konto/").status_code == 200
|
|
|
|
extension = core(core_app)
|
|
with extension.database.session() as conn, extension.database.transaction(conn):
|
|
session_store.revoke_all_for_user(conn, int(row["id"]), reason="test")
|
|
|
|
response = client.get("/konto/")
|
|
assert response.status_code == 302
|
|
assert "/login" in response.headers["Location"]
|
|
|
|
|
|
def test_password_change_revokes_other_sessions(core_app, make_user):
|
|
from tesm_core.security import sessions as session_store
|
|
|
|
extension = core(core_app)
|
|
row = make_user("anna", "Ein-gutes-Passwort-1")
|
|
first = core_app.test_client()
|
|
second = core_app.test_client()
|
|
for client_instance in (first, second):
|
|
page = client_instance.get("/login")
|
|
body = page.get_data(as_text=True)
|
|
marker = 'name="csrf_token" value="'
|
|
start = body.index(marker) + len(marker)
|
|
token = body[start : body.index('"', start)]
|
|
client_instance.post(
|
|
"/login",
|
|
data={"username": "anna", "password": "Ein-gutes-Passwort-1", "csrf_token": token},
|
|
follow_redirects=True,
|
|
)
|
|
with extension.database.session() as conn:
|
|
assert len(session_store.active_for_user(conn, int(row["id"]))) == 2
|
|
|
|
page = first.get("/konto/sicherheit")
|
|
body = page.get_data(as_text=True)
|
|
marker = 'name="csrf_token" value="'
|
|
start = body.index(marker) + len(marker)
|
|
token = body[start : body.index('"', start)]
|
|
first.post(
|
|
"/konto/passwort",
|
|
data={
|
|
"current_password": "Ein-gutes-Passwort-1",
|
|
"new_password": "Neues-Passwort-2026",
|
|
"repeat_password": "Neues-Passwort-2026",
|
|
"csrf_token": token,
|
|
},
|
|
follow_redirects=True,
|
|
)
|
|
assert second.get("/konto/").status_code == 302
|
|
assert first.get("/konto/").status_code == 200
|
|
|
|
|
|
def test_reauth_required_for_sensitive_action(client, make_user, login, csrf_token):
|
|
make_user("anna", "Ein-gutes-Passwort-1")
|
|
login("anna", "Ein-gutes-Passwort-1")
|
|
|
|
# Frisch angemeldet gilt die Bestaetigung -- danach kuenstlich altern lassen.
|
|
from flask import session as flask_session
|
|
|
|
with client.session_transaction() as sess:
|
|
sess["_reauth_at"] = (datetime.now(timezone.utc) - timedelta(hours=2)).isoformat(
|
|
timespec="seconds"
|
|
)
|
|
token = csrf_token("/konto/sicherheit")
|
|
response = client.post("/konto/zwei-faktor/starten", data={"csrf_token": token})
|
|
assert response.status_code == 302
|
|
assert "/bestaetigen" in response.headers["Location"]
|
|
assert flask_session is not None
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Der Rechteweg muss tatsaechlich begehbar sein
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
def test_units_do_not_disable_the_privilege_path():
|
|
"""``NoNewPrivileges`` schaltet setuid ab -- und damit sudo, und damit den Helfer.
|
|
|
|
Auf beiden Testhosts stand "Helfer erreichbar", waehrend in Wahrheit jede
|
|
Systemaktion an ``sudo: the "no new privileges" flag is set`` scheiterte:
|
|
nginx uebernehmen, Netzwerk aendern, Zertifikat anfordern, Kea schreiben.
|
|
Die Haertung hatte genau den Mechanismus abgeschaltet, den sie schuetzen
|
|
sollte.
|
|
"""
|
|
from pathlib import Path
|
|
|
|
root = Path(__file__).resolve().parent.parent / "deploy" / "systemd"
|
|
units = list(root.glob("*.service"))
|
|
assert units
|
|
for unit in units:
|
|
text = unit.read_text(encoding="utf-8")
|
|
assert "NoNewPrivileges=yes" not in text, (
|
|
f"{unit.name}: verhindert sudo und damit jede Systemaktion."
|
|
)
|
|
# Gemessen: auch ein eingeschraenktes Bounding Set bricht sudo mit
|
|
# "unable to change to root gid" -- inklusive CapabilityBoundingSet=CAP_NET_RAW.
|
|
for line in text.splitlines():
|
|
if line.startswith("CapabilityBoundingSet="):
|
|
raise AssertionError(
|
|
f"{unit.name}: {line!r} nimmt sudo CAP_SETUID/CAP_SETGID."
|
|
)
|
|
# Die eigentliche Grenze bleibt bestehen.
|
|
# "strict" sperrt auch den Helfer aus /etc aus -- gemessen.
|
|
assert "ProtectSystem=full" in text, unit.name
|
|
assert "ProtectSystem=strict" not in text, (
|
|
f"{unit.name}: strict sperrt den Helfer aus /etc aus."
|
|
)
|
|
assert "PrivateTmp=yes" in text, unit.name
|
|
assert "User=root" not in text, unit.name
|
|
|
|
|
|
def test_the_helper_is_probed_not_just_looked_for():
|
|
""""Datei liegt da" ist keine Auskunft darueber, ob der Aufruf gelingt."""
|
|
import inspect
|
|
|
|
from tesm_core.sysops import SysOps
|
|
|
|
assert hasattr(SysOps, "selftest")
|
|
source = inspect.getsource(SysOps.selftest)
|
|
assert 'self.run("service-status", "nginx")' in source
|
|
assert 'self.run("nginx-test")' not in source, (
|
|
"Ein fehlgeschlagenes nginx -t heisst 'Konfiguration kaputt', nicht 'Helfer kaputt'."
|
|
)
|
|
# Die beiden Fehlerbilder, die im Betrieb wirklich vorkommen, werden benannt.
|
|
assert "no new privileges" in source
|
|
assert "a password is required" in source
|
|
|
|
|
|
def test_selftest_explains_a_blocked_sudo(monkeypatch):
|
|
from tesm_core.sysops import Result, SysOps
|
|
|
|
class Blocked(SysOps):
|
|
@property
|
|
def available(self) -> bool:
|
|
return True
|
|
|
|
def run(self, verb, *args, stdin="", timeout=None):
|
|
return Result(
|
|
False, "", 'sudo: The "no new privileges" flag is set, which prevents sudo', 1
|
|
)
|
|
|
|
result = Blocked(app_key="tesm").selftest()
|
|
assert not result.ok
|
|
assert "NoNewPrivileges" in result.message
|
|
assert "systemctl" in result.message
|
|
|
|
|
|
def test_selftest_reports_a_missing_sudoers_rule():
|
|
from tesm_core.sysops import Result, SysOps
|
|
|
|
class NeedsPassword(SysOps):
|
|
@property
|
|
def available(self) -> bool:
|
|
return True
|
|
|
|
def run(self, verb, *args, stdin="", timeout=None):
|
|
return Result(False, "", "sudo: a password is required", 1)
|
|
|
|
result = NeedsPassword(app_key="tesm").selftest()
|
|
assert not result.ok
|
|
assert "sudoers" in result.message
|
|
|
|
|
|
def test_selftest_says_so_when_the_helper_is_absent(tmp_path):
|
|
from tesm_core.sysops import SysOps
|
|
|
|
result = SysOps(app_key="tesm", helper=tmp_path / "gibt-es-nicht").selftest()
|
|
assert not result.ok
|
|
assert "fehlt" in result.message
|
|
|
|
|
|
def test_authenticate_keeps_directory_groups(core_app, monkeypatch):
|
|
"""Die Verzeichnisgruppen duerfen auf dem Weg nach oben nicht verlorengehen.
|
|
|
|
``_verify_credentials`` hat sie korrekt geliefert, ``authenticate`` hat aus
|
|
dem Ergebnis aber ein neues Objekt gebaut und das Feld dabei fallen lassen.
|
|
Sichtbar war davon nichts: die Anmeldung gelang, nur band sich keine
|
|
Freigabe mehr ein. Genau solche stillen Verluste faengt dieser Test.
|
|
"""
|
|
from tesm_core.auth import directory, service
|
|
from tesm_core.extension import core
|
|
|
|
profil = directory.DirectoryProfile(
|
|
username="mitarbeiter",
|
|
dn="CN=mitarbeiter,OU=Benutzer,DC=firma,DC=local",
|
|
group_dns=("CN=GG_Technik,OU=Gruppen,DC=firma,DC=local",),
|
|
)
|
|
monkeypatch.setattr(directory, "is_enabled", lambda conn: True)
|
|
monkeypatch.setattr(
|
|
directory, "authenticate", lambda conn, name, pw: directory.DirectoryResult("ok", profile=profil)
|
|
)
|
|
|
|
with core_app.test_request_context("/login"):
|
|
with core(core_app).database.session() as conn:
|
|
outcome = service.authenticate(conn, "mitarbeiter", "egal", ip="127.0.0.1")
|
|
|
|
assert outcome.ok, outcome.message
|
|
assert outcome.group_dns == profil.group_dns, "die Gruppen sind unterwegs verlorengegangen"
|