diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2606e3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.env +after-login.html +__pycache__/ + +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..626bfb6 --- /dev/null +++ b/README.md @@ -0,0 +1,94 @@ +# eZICOM Login Helper + +Dieses Verzeichnis enthaelt ein kleines Python-Script, das den Login in die +eZICOM5-Weboberflaeche automatisiert. + +Das Script macht keinen API-Login. Es ahmt den Browser-Login nach: + +1. `GET /` zum Aufbau der Session-Cookie +2. `POST /user` mit den Formularfeldern `action=login`, `uid` und `pw0` + +## Dateien + +- `ezicom_login.py`: Login-Script +- `plan.copilot`: kurze Untersuchungsnotizen +- `Firefox Connection.html`: gespeicherte Firefox-Profiler-Ausgabe + +## Voraussetzungen + +- Python 3.10 oder neuer +- Keine externen Python-Pakete erforderlich +- Netzwerkzugriff auf `https://zm.sufw.de:31443` + +## Nutzung + +Am sichersten ist der interaktive Modus. Dabei werden keine Zugangsdaten in der +Shell-History abgelegt: + +```bash +./ezicom_login.py --insecure +``` + +Das Script fragt dann nach Benutzername und Passwort. + +Optional kann die HTML-Antwort nach dem Login gespeichert werden: + +```bash +./ezicom_login.py --insecure --save-response after-login.html +``` + +Wenn du Zugangsdaten ueber Environment-Variablen setzen willst: + +```bash +EZICOM_USERNAME='dein-user' EZICOM_PASSWORD='dein-passwort' ./ezicom_login.py --insecure +``` + +Oder trage sie in `.env` ein: + +```dotenv +EZICOM_USERNAME=dein-user +EZICOM_PASSWORD=dein-passwort +``` + +Danach reicht: + +```bash +./ezicom_login.py --insecure +``` + +Alternativ per CLI-Argument: + +```bash +./ezicom_login.py -u 'dein-user' -p 'dein-passwort' --insecure +``` + +Hinweis: CLI-Argumente koennen in der Shell-History und kurzzeitig in der +Prozessliste sichtbar sein. Fuer echte Nutzung ist der interaktive Modus oder +ein sauber geschuetzter Secret-Store besser. + +## TLS / Zertifikat + +Bei lokalen Tests konnte Python die Zertifikatskette der eZICOM-Installation +nicht verifizieren. Deshalb gibt es die Option: + +```bash +--insecure +``` + +Diese Option deaktiviert die TLS-Zertifikatspruefung. Sauberer waere es, die +ausstellende CA lokal zu installieren und das Script ohne `--insecure` zu +verwenden. + +## Exit-Codes + +- `0`: Login sieht erfolgreich aus +- `1`: Login wurde vom Server abgelehnt, zum Beispiel HTTP 403 +- `2`: technischer Fehler, zum Beispiel Netzwerk- oder TLS-Problem + +## Bekannte Grenzen + +Das Script loggt nur ein und prueft die unmittelbare Antwort. Es bucht noch +keine Zeiten und ruft keine eZICOM-API auf. + +Fuer den naechsten Schritt muessen wir nach erfolgreichem Login untersuchen, +welche Requests beim eigentlichen Buchen von Zeiten gesendet werden. diff --git a/ezicom_login.py b/ezicom_login.py new file mode 100755 index 0000000..31d64b8 --- /dev/null +++ b/ezicom_login.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Small eZICOM5 login helper. + +Credentials are read from environment variables, command line arguments, or +interactive prompts. They are never written to disk by this script. +""" + +from __future__ import annotations + +import argparse +import getpass +import http.cookiejar +import os +import ssl +import sys +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +DEFAULT_BASE_URL = "https://zm.sufw.de:31443" +DEFAULT_USER_AGENT = ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:151.0) " + "Gecko/20100101 Firefox/151.0" +) + + +def load_dotenv(path: Path = Path(".env")) -> None: + if not path.exists(): + return + + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("'\"") + if key and key not in os.environ: + os.environ[key] = value + + +def make_opener(insecure: bool) -> urllib.request.OpenerDirector: + cookie_jar = http.cookiejar.CookieJar() + handlers: list[urllib.request.BaseHandler] = [ + urllib.request.HTTPCookieProcessor(cookie_jar) + ] + if insecure: + context = ssl._create_unverified_context() + handlers.append(urllib.request.HTTPSHandler(context=context)) + return urllib.request.build_opener(*handlers) + + +def request( + opener: urllib.request.OpenerDirector, + url: str, + *, + method: str = "GET", + data: dict[str, str] | None = None, + headers: dict[str, str] | None = None, + timeout: float = 20.0, +) -> tuple[int, str, str]: + body = None + request_headers = { + "User-Agent": DEFAULT_USER_AGENT, + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + } + if headers: + request_headers.update(headers) + if data is not None: + body = urllib.parse.urlencode(data).encode("ascii") + request_headers["Content-Type"] = "application/x-www-form-urlencoded" + + req = urllib.request.Request( + url, + data=body, + headers=request_headers, + method=method, + ) + try: + with opener.open(req, timeout=timeout) as response: + raw = response.read() + charset = response.headers.get_content_charset() or "iso-8859-1" + return response.status, response.geturl(), raw.decode(charset, "replace") + except urllib.error.HTTPError as error: + raw = error.read() + charset = error.headers.get_content_charset() or "iso-8859-1" + return error.code, error.geturl(), raw.decode(charset, "replace") + + +def login( + base_url: str, + username: str, + password: str, + *, + insecure: bool, + save_response: Path | None, +) -> int: + base_url = base_url.rstrip("/") + opener = make_opener(insecure) + + try: + initial_status, _, _ = request(opener, base_url + "/") + except urllib.error.URLError as error: + print(f"Could not open initial page: {error}", file=sys.stderr) + if "CERTIFICATE_VERIFY_FAILED" in str(error): + print("Hint: retry with --insecure or install the issuing CA.", file=sys.stderr) + return 2 + + if initial_status >= 400: + print(f"Initial page returned HTTP {initial_status}", file=sys.stderr) + return 2 + + try: + status, final_url, html = request( + opener, + base_url + "/user", + method="POST", + data={"action": "login", "uid": username, "pw0": password}, + headers={ + "Origin": base_url, + "Referer": base_url + "/", + }, + ) + except urllib.error.URLError as error: + print(f"Could not submit login form: {error}", file=sys.stderr) + if "CERTIFICATE_VERIFY_FAILED" in str(error): + print("Hint: retry with --insecure or install the issuing CA.", file=sys.stderr) + return 2 + + if save_response: + save_response.write_text(html, encoding="utf-8") + + lower_html = html.lower() + failed = ( + status in {401, 403} + or "benutzername und/oder kennwort" in lower_html + or "error 403" in lower_html + or "403 - verboten" in lower_html + ) + + if failed: + print(f"Login failed: HTTP {status} ({final_url})", file=sys.stderr) + return 1 + + print(f"Login looks successful: HTTP {status} ({final_url})") + if save_response: + print(f"Saved response HTML to {save_response}") + return 0 + + +def parse_args() -> argparse.Namespace: + load_dotenv() + + parser = argparse.ArgumentParser( + description="Log in to the SUFW eZICOM5 web UI." + ) + parser.add_argument("--base-url", default=DEFAULT_BASE_URL) + parser.add_argument("-u", "--username", default=os.getenv("EZICOM_USERNAME")) + parser.add_argument("-p", "--password", default=os.getenv("EZICOM_PASSWORD")) + parser.add_argument( + "--insecure", + action="store_true", + help="Disable TLS certificate verification if the server certificate cannot be verified.", + ) + parser.add_argument( + "--save-response", + type=Path, + help="Optional path for saving the post-login HTML response for inspection.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + username = args.username or input("eZICOM username: ") + password = args.password or getpass.getpass("eZICOM password: ") + return login( + args.base_url, + username, + password, + insecure=args.insecure, + save_response=args.save_response, + ) + + +if __name__ == "__main__": + raise SystemExit(main())