feat: gespeicherte Zugangsdaten für SMB und SFTP-Passwörter

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 <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 01:07:09 +02:00
co-authored by Claude Opus 5.5
parent 893fe0c95d
commit f5ea5840d4
10 changed files with 517 additions and 107 deletions
@@ -13,9 +13,16 @@ from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from netmover.admin import AdminError, MountInput, delete_mount, save_mount
from netmover.credentials import (
delete_key,
fingerprint,
generate_key,
import_key,
list_keys,
rename_key,
)
from netmover.crypto import SecretBox, generate_ssh_keypair
from netmover.db import session_scope
from netmover.keys import delete_key, fingerprint, generate_key, import_key, list_keys, rename_key
from netmover.models import Credential, CredentialKind, Mount, MountType
from netmover.mounting.manager import MountConfig, MountManager
from tests.sftp_stub import StubSFTPServer
@@ -142,7 +149,7 @@ def test_import_errors(session: Session, secretbox: SecretBox) -> None:
def test_names(session: Session, secretbox: SecretBox) -> None:
generate_key(session, secretbox, "eins")
with pytest.raises(AdminError, match="existiert bereits"):
with pytest.raises(AdminError, match="existieren bereits"):
generate_key(session, secretbox, "eins")
with pytest.raises(AdminError, match="reserviert"):
generate_key(session, secretbox, "mount:x")
@@ -275,3 +282,102 @@ def test_list_keys_ignores_per_mount_credentials(
save_mount(session, secretbox, manager, _sftp_input())
assert list_keys(session) == []
assert session.scalars(select(Credential)).one().managed is False
# -- Gespeicherte Passwörter (0.5.0) ----------------------------------------------------------
def _cifs(**kw: object) -> MountInput:
data: dict[str, object] = {
"name": "nas",
"type": MountType.CIFS,
"server": "10.0.10.6",
"share": "scans",
}
data.update(kw)
return MountInput(**data) # type: ignore[arg-type]
def test_stored_password_shared_by_mounts(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
from netmover.credentials import create_password, list_passwords, update_password
cred = create_password(session, secretbox, "NAS-Scanner", "scanner", "Pa$$,wort", "WG")
a, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)))
assert not err and a is not None
b, err = save_mount(
session, secretbox, manager, _cifs(name="archiv", share="archiv", cred_id=str(cred.id))
)
assert not err and b is not None
assert a.credential is cred and b.credential is cred and a.username is None
assert list_passwords(session)[0].used_by == ["archiv", "nas"]
# ein neues Passwort gilt für beide
update_password(session, secretbox, cred.id, "scanner", "neu!", "WG")
from netmover.admin import credential_for_test, mount_input_from_model
for m in (a, b):
data = mount_input_from_model(m)
assert data.cred_id == str(cred.id) and data.username == "scanner"
c = credential_for_test(data, m, secretbox, session)
assert c is not None and c.secret == "neu!" and c.domain == "WG"
# Anbindung löschen lässt die gespeicherten Zugangsdaten stehen
delete_mount(session, manager, a.id)
from netmover.credentials import delete_credential
with pytest.raises(AdminError, match="noch benutzt"):
delete_credential(session, cred.id)
delete_mount(session, manager, b.id)
assert delete_credential(session, cred.id) == "NAS-Scanner"
def test_switch_from_own_to_stored_password_cleans_up(
session: Session, secretbox: SecretBox, manager: MountManager
) -> None:
from netmover.credentials import create_password
m, _ = save_mount(session, secretbox, manager, _cifs(username="alt", password="pw"))
assert m is not None and m.credential is not None
own_id = m.credential.id
cred = create_password(session, secretbox, "geteilt", "scanner", "x")
m, err = save_mount(session, secretbox, manager, _cifs(cred_id=str(cred.id)), m.id)
assert not err and m is not None and m.credential is cred
assert session.get(Credential, own_id) is None
# zurück auf eigene Angaben: gespeicherter Eintrag bleibt unverändert
m, err = save_mount(session, secretbox, manager, _cifs(username="eigen", password="pw2"), m.id)
assert not err and m is not None and not m.credential.managed # type: ignore[union-attr]
assert cred.username == "scanner" and secretbox.decrypt(cred.secret_enc) == "x"
def test_stored_password_validation(session: Session, secretbox: SecretBox) -> None:
from netmover.credentials import create_password, update_password
with pytest.raises(AdminError, match="Passwort fehlt"):
create_password(session, secretbox, "a", "u", "")
with pytest.raises(AdminError):
create_password(session, secretbox, "a", "u", "zeile\numbruch")
cred = create_password(session, secretbox, "a", "u", "pw")
update_password(session, secretbox, cred.id, "u2", "") # leer = unverändert
assert cred.username == "u2" and secretbox.decrypt(cred.secret_enc) == "pw"
def test_stored_password_mount_actually_mounts(
session_factory: sessionmaker[Session],
secretbox: SecretBox,
manager: MountManager,
fake_backend: object,
) -> None:
from netmover.credentials import create_password
fake_backend.export("10.0.10.6", "scans") # type: ignore[attr-defined]
with session_scope(session_factory) as s:
cred = create_password(s, secretbox, "NAS", "scanner", "Pa$$wort", "WG")
m, _ = save_mount(s, secretbox, manager, _cifs(cred_id=str(cred.id)))
assert m is not None
mid = m.id
manager.mount(mid)
spec = fake_backend.calls[-1].spec # type: ignore[attr-defined]
assert spec.credentials.username == "scanner" and spec.credentials.password == "Pa$$wort"
assert spec.credentials.domain == "WG"
+51 -11
View File
@@ -512,12 +512,16 @@ def test_export_excludes_secrets_and_import_roundtrip(
def test_keys_page_generate_import_select_delete(client: TestClient, state: NetMover) -> None:
from netmover.crypto import generate_ssh_keypair
r = post(client, "/keys/generate", {"name": "paperless-lxc"})
r = post(client, "/credentials/keys/generate", {"name": "paperless-lxc"})
assert r.status_code == 303
private, public = generate_ssh_keypair()
r = post(client, "/keys/import", {"name": "alt", "private_key": private, "passphrase": ""})
r = post(
client,
"/credentials/keys/import",
{"name": "alt", "private_key": private, "passphrase": ""},
)
assert r.status_code == 303
page = client.get("/keys").text
page = client.get("/credentials").text
assert "paperless-lxc" in page and "alt" in page
assert public.split()[1] in page # öffentlicher Teil sichtbar
for line in private.splitlines()[1:-1]: # keine Zeile des privaten Schlüssels
@@ -546,27 +550,31 @@ def test_keys_page_generate_import_select_delete(client: TestClient, state: NetM
)
assert r.status_code == 303, r.text
mounts = client.get("/mounts").text
assert "gespeichertem Schlüssel" in mounts and "„alt“" in mounts
assert "gespeicherten Zugangsdaten" in mounts and "„alt“" in mounts
edit = client.get(f"/mounts/{_mount_id(state, 'paperless')}/edit").text
assert f'<option value="{key_id}" selected>' in edit and 'value="root"' in edit
post(client, f"/keys/{key_id}/delete")
assert "noch benutzt" in client.get("/keys").text
post(client, f"/credentials/{key_id}/delete")
assert "noch benutzt" in client.get("/credentials").text
post(client, f"/mounts/{_mount_id(state, 'paperless')}/delete")
post(client, f"/keys/{key_id}/delete")
page = client.get("/keys").text
post(client, f"/credentials/{key_id}/delete")
page = client.get("/credentials").text
assert "gelöscht" in page and ">alt<" not in page
def test_key_import_error_is_shown(client: TestClient) -> None:
post(client, "/keys/import", {"name": "x", "private_key": "kein schlüssel", "passphrase": ""})
assert "Kein lesbarer privater Schlüssel" in client.get("/keys").text
post(
client,
"/credentials/keys/import",
{"name": "x", "private_key": "kein schlüssel", "passphrase": ""},
)
assert "Kein lesbarer privater Schlüssel" in client.get("/credentials").text
def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) -> None:
"""Attribute in {{ … }} werden von Jinja maskiert – dann landet &#34; im HTML."""
post(client, "/keys/generate", {"name": "k"})
post(client, "/credentials/keys/generate", {"name": "k"})
for url in (
"/",
"/mounts",
@@ -581,3 +589,35 @@ def test_no_escaped_attributes_in_pages(client: TestClient, state: NetMover) ->
html = client.get(url).text
broken = re.findall(r"<[a-z][^<>]*=&#34;[^<>]*>", html) # nur in Tags, nicht im Text
assert not broken, (url, broken[:2])
def test_credentials_page_passwords(
client: TestClient, state: NetMover, backend: FakeMountBackend
) -> None:
r = post(
client,
"/credentials/passwords",
{"name": "NAS-Scanner", "username": "scanner", "password": "Geheim,123", "domain": ""},
)
assert r.status_code == 303
page = client.get("/credentials").text
assert "NAS-Scanner" in page and "scanner" in page and "Geheim,123" not in page
with state.session_factory() as s:
cid = s.scalar(select(Credential.id).where(Credential.name == "NAS-Scanner"))
form = client.get("/mounts/new").text
assert f'<option value="{cid}"' in form and "eigene Angaben für diese Anbindung" in form
backend.export("nas", "scans")
r = post(
client, "/mounts/new", {**MOUNT_FORM, "username": "", "password": "", "cred_id": str(cid)}
)
assert r.status_code == 303, r.text
assert "gespeicherten Zugangsdaten" in client.get("/mounts").text
mid = _mount_id(state, "Synology Scans")
assert "Quelle mit Löschen" in post(client, f"/mounts/{mid}/test").text
post(
client,
f"/credentials/{cid}/password",
{"username": "scanner2", "domain": "", "password": ""},
)
assert "gilt ab sofort für alle" in client.get("/credentials").text
assert client.get("/keys").headers["location"] == "/credentials"