diff --git a/CHANGELOG.md b/CHANGELOG.md index b72f1f2..1002c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,12 @@ die Versionierung folgt [Semantic Versioning](https://semver.org/lang/de/). ## [Unreleased] -Noch keine Änderungen seit 0.1.1. +### Hinzugefügt + +- `python -m app.scripts.reset_password` setzt das Passwort eines Benutzers oder + legt ihn an. Nötig, weil die Erstanlage beim Start nur bei leerer + Benutzertabelle greift und ein nachträglich geändertes + `MONEYFY_ADMIN_PASSWORD` danach wirkungslos bleibt. ## [0.1.1] – 2026-09-09 diff --git a/Makefile b/Makefile index 2b807ec..52ada9e 100644 --- a/Makefile +++ b/Makefile @@ -32,10 +32,18 @@ seed: ## Stammdaten (Kategoriebaum, Benachrichtigungsregeln) anlegen vendor-icons: ## simple-icons-Index neu erzeugen (benötigt npm und Netzzugang) cd $(BACKEND) && .venv/bin/python scripts/vendor_simple_icons.py +.PHONY: reset-password +reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin + cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),) + .PHONY: seed-demo seed-demo: ## Realistische Demodaten anlegen (vorhandene Daten bleiben, sonst --reset) cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo +.PHONY: reset-password +reset-password: ## Passwort setzen oder Administrator anlegen: make reset-password u=admin + cd $(BACKEND) && .venv/bin/python -m app.scripts.reset_password $(if $(u),--username $(u),) + .PHONY: seed-demo-reset seed-demo-reset: ## Demodaten neu aufsetzen; löscht Konten, Firmen, Posten und Buchungen cd $(BACKEND) && .venv/bin/python -m app.scripts.seed_demo --reset diff --git a/README.md b/README.md index f54578b..3bff324 100644 --- a/README.md +++ b/README.md @@ -134,7 +134,7 @@ vollständige, kommentierte Liste. Die wichtigsten: |---|---|---| | `DATABASE_URL` | – | PostgreSQL-DSN, `postgresql://` wird auf asyncpg umgestellt | | `SECRET_KEY` | – | Signaturschlüssel für JWTs, mind. 32 Zeichen (`openssl rand -hex 32`) | -| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer | +| `MONEYFY_ADMIN_USER` / `MONEYFY_ADMIN_PASSWORD` | `admin` / – | Beim Erststart angelegter Benutzer. Es gibt **kein** eingebautes Standardpasswort; ohne diesen Wert wird niemand angelegt. Die Anlage greift nur, solange die Benutzertabelle leer ist – danach hilft `app.scripts.reset_password`. | | `TIMEZONE` | `Europe/Berlin` | Zeitzone der gesamten Anwendung | | `HOLIDAY_REGION` | `DE-NW` | Feiertagsregion für Werktagsverschiebungen | | `LOGO_STORAGE_DIR` | `/data/logos` | Verzeichnis des Logo-Caches | diff --git a/backend/app/scripts/reset_password.py b/backend/app/scripts/reset_password.py new file mode 100644 index 0000000..d9dd45b --- /dev/null +++ b/backend/app/scripts/reset_password.py @@ -0,0 +1,108 @@ +"""CLI: Passwort setzen oder Administrator anlegen. + + python -m app.scripts.reset_password + python -m app.scripts.reset_password --username admin --password geheim123456 + +Nötig, weil die Erstanlage beim Start nur greift, solange noch kein Benutzer +existiert – ein nachträglich geändertes `MONEYFY_ADMIN_PASSWORD` bleibt danach +wirkungslos. +""" + +import argparse +import asyncio +import getpass +import os +import sys + +from sqlalchemy import select + +from app.core.config import settings +from app.core.security import hash_password +from app.db.session import SessionLocal, engine +from app.models import AppUser +from app.services.auth import ( + MIN_PASSWORD_LENGTH, + get_user_by_username, + revoke_all_refresh_tokens, +) + + +def _passwort_ermitteln(vorgabe: str | None) -> str: + """Reihenfolge: Argument, Umgebungsvariable, interaktive Eingabe.""" + if vorgabe: + return vorgabe + + aus_umgebung = os.environ.get("MONEYFY_ADMIN_PASSWORD") + if aus_umgebung: + print("Verwende MONEYFY_ADMIN_PASSWORD aus der Umgebung.") + return aus_umgebung + + if not sys.stdin.isatty(): + raise SystemExit( + "Kein Passwort angegeben. Entweder --password nutzen oder " + "MONEYFY_ADMIN_PASSWORD setzen." + ) + + erste = getpass.getpass("Neues Passwort: ") + zweite = getpass.getpass("Wiederholen: ") + if erste != zweite: + raise SystemExit("Die Eingaben stimmen nicht überein.") + return erste + + +async def main(username: str, passwort: str, force_change: bool) -> int: + if len(passwort) < MIN_PASSWORD_LENGTH: + print( + f"Das Passwort muss mindestens {MIN_PASSWORD_LENGTH} Zeichen lang sein.", + file=sys.stderr, + ) + return 1 + + async with SessionLocal() as session: + benutzer = await get_user_by_username(session, username) + + if benutzer is None: + vorhandene = (await session.execute(select(AppUser.username))).scalars().all() + benutzer = AppUser(username=username, must_change_password=force_change) + session.add(benutzer) + aktion = "angelegt" + if vorhandene: + print(f"Vorhandene Benutzer: {', '.join(vorhandene)}") + else: + aktion = "geändert" + benutzer.must_change_password = force_change + # Ein Passwortwechsel beendet alle offenen Sitzungen. + await revoke_all_refresh_tokens(session, benutzer.id) + + benutzer.password_hash = hash_password(passwort) + await session.commit() + + await engine.dispose() + print(f"Passwort für '{username}' {aktion}.") + if force_change: + print("Beim ersten Anmelden wird ein neues Passwort verlangt.") + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--username", + default=settings.moneyfy_admin_user, + help=f"Benutzername (Vorgabe: {settings.moneyfy_admin_user}).", + ) + parser.add_argument( + "--password", + help="Neues Passwort. Ohne Angabe wird MONEYFY_ADMIN_PASSWORD genutzt " + "oder interaktiv gefragt.", + ) + parser.add_argument( + "--force-change", + action="store_true", + help="Verlangt beim nächsten Anmelden erneut einen Passwortwechsel.", + ) + args = parser.parse_args() + + raise SystemExit( + asyncio.run(main(args.username, _passwort_ermitteln(args.password), args.force_change)) + ) diff --git a/docs/deployment.md b/docs/deployment.md index d249abc..4ac66c1 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -188,10 +188,40 @@ Die Anwendung verweigert in Produktion den Start mit dem Platzhalterwert. `openssl rand -hex 32` erzeugt einen passenden Schlüssel. Wird er später geändert, sind alle Sitzungen ungültig – das ist beabsichtigt. -**Anmeldung schlägt fehl, obwohl das Passwort stimmt** -Meist fehlt HTTPS: Die Cookies sind `Secure` gesetzt und erreichen den Server -über eine reine HTTP-Verbindung nicht. Entweder über NPMplus zugreifen oder -`COOKIE_SECURE=false` setzen. +**Anmeldung schlägt fehl** + +Drei Ursachen kommen in Frage, in dieser Reihenfolge prüfen: + +1. **Der Benutzer wurde mit einem anderen Passwort angelegt.** Die Erstanlage + greift nur, solange die Benutzertabelle leer ist. Wer `.env.example` zuerst + unverändert übernimmt und startet, bekommt einen Benutzer mit dem Platzhalter + – ein späteres Ändern von `MONEYFY_ADMIN_PASSWORD` bleibt dann wirkungslos. + Abhilfe: + + ```bash + docker compose exec moneyfy-backend python -m app.scripts.reset_password \ + --username admin --password "neues-passwort" + ``` + +2. **Der Container kennt den neuen Wert noch nicht.** `docker compose restart` + behält die alte Umgebung. Nach einer Änderung an der `.env` muss der + Container neu erstellt werden: + + ```bash + docker compose up -d --force-recreate moneyfy-backend + docker compose exec moneyfy-backend printenv MONEYFY_ADMIN_PASSWORD + ``` + +3. **Kein HTTPS.** Die Cookies sind `Secure` gesetzt und erreichen den Server + über eine reine HTTP-Verbindung nicht. Typisches Bild: die Anmeldung meldet + keinen Fehler, landet aber sofort wieder auf der Anmeldemaske. Entweder über + NPMplus zugreifen oder für einen Test `COOKIE_SECURE=false` setzen. + +Welcher Fall vorliegt, verrät das Log: + +```bash +docker compose logs moneyfy-backend | grep -iE "administrator|MONEYFY_ADMIN" +``` **Firmen bekommen kein Logo** Ohne Netzzugang findet nur der lokale Markenkatalog etwas; unbekannte Firmen