From f5ea5840d4d9c369566c688152658db3319f5b3c Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 11 Oct 2026 01:07:09 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20gespeicherte=20Zugangsdaten=20f=C3=BCr?= =?UTF-8?q?=20SMB=20und=20SFTP-Passw=C3=B6rter?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seite „Zugangsdaten“ (vorher „Schlüssel“) mit Passwörtern und SSH-Schlüsseln; Anbindungen wählen gespeicherte Passwörter aus, ein neues Passwort gilt sofort für alle. /keys leitet auf /credentials um. Co-Authored-By: Claude Opus 5.5 --- src/netmover/admin.py | 50 ++++++++- src/netmover/{keys.py => credentials.py} | 117 +++++++++++++++++++- src/netmover/web/app.py | 110 ++++++++++++++---- src/netmover/web/templates/base.html | 2 +- src/netmover/web/templates/credentials.html | 97 ++++++++++++++++ src/netmover/web/templates/keys.html | 57 ---------- src/netmover/web/templates/mount_form.html | 15 ++- src/netmover/web/templates/mounts.html | 4 +- tests/{test_keys.py => test_credentials.py} | 110 +++++++++++++++++- tests/test_web.py | 62 +++++++++-- 10 files changed, 517 insertions(+), 107 deletions(-) rename src/netmover/{keys.py => credentials.py} (64%) create mode 100644 src/netmover/web/templates/credentials.html delete mode 100644 src/netmover/web/templates/keys.html rename tests/{test_keys.py => test_credentials.py} (70%) diff --git a/src/netmover/admin.py b/src/netmover/admin.py index c4e00a4..d0500c8 100644 --- a/src/netmover/admin.py +++ b/src/netmover/admin.py @@ -85,6 +85,7 @@ def _int( NEW_KEY = "new" +OWN = "own" @dataclass @@ -101,6 +102,8 @@ class MountInput: # nur sftp + Schlüssel: "new" = eigener Schlüssel nur für diese Anbindung, # sonst ID eines Schlüssels aus der Schlüsselverwaltung key_id: str = NEW_KEY + # Passwort-Anmeldung (SMB, SFTP): "own" = eigene Angaben, sonst ID gespeicherter Zugangsdaten + cred_id: str = OWN uid: int | None = None gid: int | None = None file_mode: str | None = None @@ -135,7 +138,12 @@ def mount_input_from_model(m: Mount) -> MountInput: username=m.username or (c.username if c else ""), domain=(c.domain or "") if c else "", auth=c.kind if c else CredentialKind.PASSWORD, - key_id=str(c.id) if c is not None and c.managed else NEW_KEY, + key_id=str(c.id) + if c is not None and c.managed and c.kind is CredentialKind.SSH_KEY + else NEW_KEY, + cred_id=str(c.id) + if c is not None and c.managed and c.kind is CredentialKind.PASSWORD + else OWN, uid=m.uid, gid=m.gid, file_mode=m.file_mode, @@ -159,10 +167,14 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]: auto_mount=_bool(form, "auto_mount"), enabled=_bool(form, "enabled"), key_id=(form.get("key_id") or NEW_KEY).strip(), + cred_id=(form.get("cred_id") or OWN).strip(), ) if data.key_id != NEW_KEY and not data.key_id.isdigit(): errors["key_id"] = "Ungültige Schlüsselauswahl." data.key_id = NEW_KEY + if data.cred_id != OWN and not data.cred_id.isdigit(): + errors["cred_id"] = "Ungültige Auswahl." + data.cred_id = OWN try: data.type = MountType(form.get("type", "cifs")) except ValueError: @@ -210,7 +222,14 @@ def parse_mount_form(form: Mapping[str, str]) -> tuple[MountInput, Errors]: data.username = check("username", lambda: validate_username(data.username), data.username) if data.domain: check("domain", lambda: validate_username(data.domain), "") - if data.type is MountType.SFTP and not data.username: + if data.auth is CredentialKind.SSH_KEY or data.type in (MountType.NFS3, MountType.NFS4): + data.cred_id = OWN + uses_stored = data.cred_id != OWN + if uses_stored: + data.username = data.domain = data.password = "" # kommen aus den Zugangsdaten + errors.pop("username", None) + errors.pop("password", None) + if data.type is MountType.SFTP and not data.username and not uses_stored: errors["username"] = "SFTP braucht einen Benutzernamen." if data.type is not MountType.SFTP: data.auth = CredentialKind.PASSWORD @@ -224,7 +243,19 @@ def credential_for_test( ) -> CredentialData | None: """Zugangsdaten für „Testen“ aus dem Formular – leeres Passwort = gespeichertes, gewählter Schlüssel aus der Schlüsselverwaltung.""" - if data.type in (MountType.NFS3, MountType.NFS4) or not data.username: + if data.type in (MountType.NFS3, MountType.NFS4): + return None + if data.cred_id != OWN and s is not None: + stored = s.get(Credential, int(data.cred_id)) + if stored is None or not stored.managed or stored.kind is not CredentialKind.PASSWORD: + return None + return CredentialData( + CredentialKind.PASSWORD, + stored.username, + secrets.decrypt(stored.secret_enc), + stored.domain, + ) + if not data.username: return None if data.auth is CredentialKind.SSH_KEY and data.key_id != NEW_KEY and s is not None: key = s.get(Credential, int(data.key_id)) @@ -321,8 +352,12 @@ def save_mount( # (geteilt, wird hier nie verändert) oder eigene Zugangsdaten nur dieser Anbindung. current = m.credential own = current if current is not None and not current.managed else None + stored_password = data.cred_id != OWN and ( + data.type is MountType.CIFS + or (data.type is MountType.SFTP and data.auth is CredentialKind.PASSWORD) + ) needs_cred = data.type is MountType.SFTP or ( - data.type is MountType.CIFS and bool(data.username) + data.type is MountType.CIFS and (bool(data.username) or stored_password) ) use_managed = ( data.type is MountType.SFTP @@ -332,6 +367,13 @@ def save_mount( if not needs_cred: m.credential = None m.username = None + elif stored_password: + stored = s.get(Credential, int(data.cred_id)) + if stored is None or not stored.managed or stored.kind is not CredentialKind.PASSWORD: + s.rollback() + return None, {"cred_id": "Zugangsdaten existieren nicht."} + m.credential = stored + m.username = None elif use_managed: key = s.get(Credential, int(data.key_id)) if key is None or not key.managed or key.kind is not CredentialKind.SSH_KEY: diff --git a/src/netmover/keys.py b/src/netmover/credentials.py similarity index 64% rename from src/netmover/keys.py rename to src/netmover/credentials.py index 8fc1183..f0943ca 100644 --- a/src/netmover/keys.py +++ b/src/netmover/credentials.py @@ -1,4 +1,9 @@ -"""Schlüsselverwaltung: gespeicherte SSH-Schlüssel für SFTP-Anbindungen. +"""Zugangsdaten-Verwaltung: gespeicherte Passwörter und SSH-Schlüssel. + +Passwörter (SMB und SFTP): Benutzer, Passwort, optional Domäne – einmal gepflegt, +von beliebig vielen Anbindungen genutzt. Ein neues Passwort gilt sofort für alle. + +SSH-Schlüssel: * Erzeugen (Ed25519) oder Importieren (Ed25519, ECDSA, RSA ≥ 2048 Bit; OpenSSH- oder PEM-Format, optional mit Passphrase). Eine Passphrase wird beim Import @@ -63,7 +68,7 @@ def _validate_name(s: Session, name: str, exclude_id: int | None = None) -> str: raise AdminError("Namen mit „mount:“ sind für NetMover reserviert.") dup = s.scalar(select(Credential.id).where(Credential.name == name)) if dup is not None and dup != exclude_id: - raise AdminError(f"Ein Schlüssel „{name}“ existiert bereits.") + raise AdminError(f"Zugangsdaten mit dem Namen „{name}“ existieren bereits.") return name @@ -153,9 +158,115 @@ def _users(s: Session, key_id: int) -> list[str]: return sorted(s.scalars(select(Mount.name).where(Mount.cred_id == key_id))) +# -- Passwörter ------------------------------------------------------------------------------- + + +@dataclass +class PasswordInfo: + id: int + name: str + username: str + domain: str | None + created_at: datetime + used_by: list[str] = field(default_factory=list) + + +def _validate_login(username: str, password: str | None, domain: str) -> None: + from netmover.validation import ValidationError, validate_secret, validate_username + + try: + validate_username(username) + if domain: + validate_username(domain) + if password is not None: + validate_secret(password) + except ValidationError as exc: + raise AdminError(str(exc)) from None + + +def create_password( + s: Session, secrets: SecretBox, name: str, username: str, password: str, domain: str = "" +) -> Credential: + name = _validate_name(s, name) + username, domain = username.strip(), domain.strip() + if not password: + raise AdminError("Passwort fehlt.") + _validate_login(username, password, domain) + cred = Credential( + name=name, + kind=CredentialKind.PASSWORD, + username=username, + domain=domain or None, + secret_enc=secrets.encrypt(password), + managed=True, + ) + s.add(cred) + s.flush() + return cred + + +def update_password( + s: Session, secrets: SecretBox, cred_id: int, username: str, password: str, domain: str = "" +) -> Credential: + """Benutzer/Domäne ändern; leeres Passwort = unverändert.""" + cred = managed_password(s, cred_id) + username, domain = username.strip(), domain.strip() + _validate_login(username, password or None, domain) + cred.username = username + cred.domain = domain or None + if password: + cred.secret_enc = secrets.encrypt(password) + return cred + + +def managed_password(s: Session, cred_id: int) -> Credential: + cred = s.get(Credential, cred_id) + if cred is None or not cred.managed or cred.kind is not CredentialKind.PASSWORD: + raise AdminError("Zugangsdaten existieren nicht.") + return cred + + +def list_passwords(s: Session) -> list[PasswordInfo]: + rows = s.scalars( + select(Credential) + .where(Credential.managed.is_(True), Credential.kind == CredentialKind.PASSWORD) + .order_by(Credential.name) + ).all() + return [ + PasswordInfo(c.id, c.name, c.username, c.domain, c.created_at, _users(s, c.id)) + for c in rows + ] + + +def delete_credential(s: Session, cred_id: int) -> str: + """Gespeicherte Zugangsdaten (Passwort oder Schlüssel) löschen – nur unbenutzt.""" + cred = s.get(Credential, cred_id) + if cred is None or not cred.managed: + raise AdminError("Zugangsdaten existieren nicht.") + users = _users(s, cred_id) + if users: + raise AdminError(f"„{cred.name}“ wird noch benutzt von: {', '.join(users)}") + name = cred.name + s.delete(cred) + s.flush() + return name + + +def rename_credential(s: Session, cred_id: int, name: str) -> None: + cred = s.get(Credential, cred_id) + if cred is None or not cred.managed: + raise AdminError("Zugangsdaten existieren nicht.") + cred.name = _validate_name(s, name, exclude_id=cred_id) + + +# -- SSH-Schlüssel ------------------------------------------------------------------------------ + + def list_keys(s: Session) -> list[KeyInfo]: keys = s.scalars( - select(Credential).where(Credential.managed.is_(True)).order_by(Credential.name) + select(Credential) + .where(Credential.managed.is_(True), Credential.kind == CredentialKind.SSH_KEY) + .order_by(Credential.name) ).all() result = [] for k in keys: diff --git a/src/netmover/web/app.py b/src/netmover/web/app.py index 642e8fe..b7d88ac 100644 --- a/src/netmover/web/app.py +++ b/src/netmover/web/app.py @@ -49,8 +49,17 @@ from netmover.admin import ( ) from netmover.app_state import NetMover from netmover.config import Settings, get_settings +from netmover.credentials import ( + create_password, + delete_credential, + generate_key, + import_key, + list_keys, + list_passwords, + rename_credential, + update_password, +) from netmover.db import session_scope -from netmover.keys import delete_key, generate_key, import_key, list_keys, rename_key from netmover.kuma import push as kuma_push from netmover.logging_setup import event, get_logger from netmover.mail import SECURITY_MODES, MailConfigError, parse_recipients, valid_address @@ -511,7 +520,7 @@ async def mounts_page(request: Request) -> Response: m.id: m.credential.name for m in mounts if m.credential is not None and m.credential.managed - } + } # Name gespeicherter Zugangsdaten (Passwort oder Schlüssel) return render( request, "mounts.html", @@ -533,6 +542,15 @@ def _report_data(raw: str | None) -> dict[str, Any]: return data +def _password_options(request: Request) -> list[tuple[str, str]]: + with nm(request).session_factory() as s: + stored = list_passwords(s) + return [("own", "eigene Angaben für diese Anbindung")] + [ + (str(p.id), f"{p.name} ({p.domain + chr(92) if p.domain else ''}{p.username})") + for p in stored + ] + + def _key_options(request: Request) -> list[tuple[str, str]]: with nm(request).session_factory() as s: keys = list_keys(s) @@ -566,6 +584,7 @@ def _mount_form( mount is not None and mount.credential is not None and not mount.credential.managed ), key_options=_key_options(request), + password_options=_password_options(request), ) @@ -702,26 +721,75 @@ async def mount_reset_hostkey(request: Request, mount_id: int) -> Response: return redirect(f"/mounts#mount-{mount_id}") -# -- Schlüsselverwaltung ---------------------------------------------------------------------- +# -- Zugangsdaten ------------------------------------------------------------------------------ -@protected.get("/keys", response_class=HTMLResponse) -async def keys_page(request: Request) -> Response: +@protected.get("/credentials", response_class=HTMLResponse) +async def credentials_page(request: Request) -> Response: with nm(request).session_factory() as s: - return render(request, "keys.html", active="keys", keys=list_keys(s)) + return render( + request, + "credentials.html", + active="keys", + keys=list_keys(s), + passwords=list_passwords(s), + ) -async def _key_action(request: Request, work: Callable[[Any], str]) -> Response: +@protected.get("/keys") +async def keys_redirect(request: Request) -> Response: + return RedirectResponse("/credentials", status_code=301) + + +async def _cred_action(request: Request, work: Callable[[Any], str]) -> Response: state = nm(request) try: with session_scope(state.session_factory) as s: flash(request, work(s)) except AdminError as exc: flash(request, str(exc), "error") - return redirect("/keys") + return redirect("/credentials") -@protected.post("/keys/generate") +@protected.post("/credentials/passwords") +async def password_create(request: Request) -> Response: + form = _form_dict(await request.form()) + secrets = nm(request).secrets + + def work(s: Any) -> str: + cred = create_password( + s, + secrets, + form.get("name", ""), + form.get("username", ""), + form.get("password", ""), + form.get("domain", ""), + ) + return f"Zugangsdaten „{cred.name}“ gespeichert." + + return await _cred_action(request, work) + + +@protected.post("/credentials/{cred_id}/password") +async def password_update(request: Request, cred_id: int) -> Response: + form = _form_dict(await request.form()) + secrets = nm(request).secrets + + def work(s: Any) -> str: + cred = update_password( + s, + secrets, + cred_id, + form.get("username", ""), + form.get("password", ""), + form.get("domain", ""), + ) + return f"Zugangsdaten „{cred.name}“ geändert – gilt ab sofort für alle Anbindungen." + + return await _cred_action(request, work) + + +@protected.post("/credentials/keys/generate") async def key_generate(request: Request) -> Response: form = _form_dict(await request.form()) secrets = nm(request).secrets @@ -730,10 +798,10 @@ async def key_generate(request: Request) -> Response: key = generate_key(s, secrets, form.get("name", "")) return f"Schlüssel „{key.name}“ erzeugt." - return await _key_action(request, work) + return await _cred_action(request, work) -@protected.post("/keys/import") +@protected.post("/credentials/keys/import") async def key_import(request: Request) -> Response: form = _form_dict(await request.form()) secrets = nm(request).secrets @@ -748,26 +816,26 @@ async def key_import(request: Request) -> Response: ) return f"Schlüssel „{key.name}“ importiert." - return await _key_action(request, work) + return await _cred_action(request, work) -@protected.post("/keys/{key_id}/rename") -async def key_rename(request: Request, key_id: int) -> Response: +@protected.post("/credentials/{cred_id}/rename") +async def credential_rename(request: Request, cred_id: int) -> Response: form = _form_dict(await request.form()) def work(s: Any) -> str: - rename_key(s, key_id, form.get("name", "")) - return "Schlüssel umbenannt." + rename_credential(s, cred_id, form.get("name", "")) + return "Umbenannt." - return await _key_action(request, work) + return await _cred_action(request, work) -@protected.post("/keys/{key_id}/delete") -async def key_delete(request: Request, key_id: int) -> Response: +@protected.post("/credentials/{cred_id}/delete") +async def credential_delete(request: Request, cred_id: int) -> Response: def work(s: Any) -> str: - return f"Schlüssel „{delete_key(s, key_id)}“ gelöscht." + return f"„{delete_credential(s, cred_id)}“ gelöscht." - return await _key_action(request, work) + return await _cred_action(request, work) # -- Regeln ----------------------------------------------------------------------------------- diff --git a/src/netmover/web/templates/base.html b/src/netmover/web/templates/base.html index 32554dd..5de82bd 100644 --- a/src/netmover/web/templates/base.html +++ b/src/netmover/web/templates/base.html @@ -36,7 +36,7 @@