diora-web/tui/diora_tui/login.py

110 lines
4.4 KiB
Python
Raw Normal View History

"""`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'<code style="user-select:all[^"]*">\s*([A-Za-z0-9_-]+)\s*</code>')
_USERNAME = re.compile(r'<meta name="username" content="([^"]*)">')
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)