"""`diora-tui login`: sign in once like the apps do and keep what a sync needs. The password goes through the web login form (CSRF + session) only to read the account's existing API token off the settings page, and to derive the book key (PBKDF2, as the web client's "unlock with password"). The password is not stored; the session is logged out again. A new token is never created here: that would sign out the apps using the current one. """ from __future__ import annotations import base64 import getpass import html import re import requests from . import api, crypto from .config import RemoteConfig DEFAULT_SERVER = "https://diora.creamfresh.xyz" _CSRF = re.compile(r'name="csrfmiddlewaretoken" value="([^"]+)"') _TOKEN = re.compile(r'\s*([A-Za-z0-9_-]+)\s*') _USERNAME = re.compile(r'') class LoginError(Exception): pass def web_login(server: str, username: str, password: str) -> tuple[str, str]: """(API token, canonical user name) via the web login.""" s = requests.Session() s.headers["User-Agent"] = api.USER_AGENT def get(path): return s.get(server + path, headers={"Referer": server + path}, allow_redirects=False, timeout=30) def post(path, form): return s.post(server + path, data=form, headers={"Referer": server + path, "Origin": server}, allow_redirects=False, timeout=30) try: page = get("/accounts/login/") m = _CSRF.search(page.text) if page.status_code != 200 or not m: raise LoginError(f"Login-Seite nicht lesbar ({page.status_code}).") r = post("/accounts/login/", {"csrfmiddlewaretoken": m.group(1), "username": username, "password": password}) if r.status_code == 200: raise LoginError("Benutzername oder Passwort falsch.") if r.status_code != 302: raise LoginError(f"Anmeldung fehlgeschlagen ({r.status_code}).") try: t = _TOKEN.search(get("/accounts/settings/").text) if not t: raise LoginError("Kein API-Token gefunden. Melde dich einmal in einer diora-App an, dann noch mal.") name = _USERNAME.search(get("/").text) return t.group(1), html.unescape(name.group(1)) if name else username finally: c = _CSRF.search(get("/").text) if c: post("/accounts/logout/", {"csrfmiddlewaretoken": c.group(1)}) except requests.RequestException as e: raise LoginError(f"Verbindung zu {server} fehlgeschlagen: {e}") from e def _books_open(server: str, token: str, key_b64: str) -> tuple[int, int]: """(books, books the key opens).""" books, _, _ = api.fetch_books(server, token) ok = 0 for b in books: try: crypto.decrypt(key_b64, b["meta_iv"], b["meta_ct"]) ok += 1 except crypto.DecryptError: pass return len(books), ok def login(server: str | None = None, *, paste_key: bool = False) -> RemoteConfig: server = (server or DEFAULT_SERVER).rstrip("/") username = input("diora-Benutzername: ").strip() password = getpass.getpass("Passwort (wird nicht gespeichert, nicht angezeigt): ") if not username or not password: raise LoginError("Abgebrochen.") token, username = web_login(server, username, password) key = "" if paste_key else crypto.derive_key_b64(username, password) del password if key: total, ok = _books_open(server, token, key) if total and not ok: print("Der Schlüssel aus dem Passwort öffnet die Bücher nicht (Passwort seit dem Hochladen geändert?).") key = "" if not key: key = getpass.getpass("Schlüssel einfügen (diora: Einstellungen → Schlüssel kopieren, Base64; leer = abbrechen): ").strip() if not key: raise LoginError("Abgebrochen, nichts gespeichert.") try: if len(base64.b64decode(key)) != 32: raise ValueError except ValueError: raise LoginError("Das ist kein gültiger Schlüssel (32 Byte, Base64).") from None total, ok = _books_open(server, token, key) if total and not ok: raise LoginError("Auch dieser Schlüssel passt nicht. Nichts gespeichert.") print(f"Angemeldet als {username} ({total} Bücher).") return RemoteConfig(server_url=server, api_token=token, enc_key_b64=key, username=username)