Compare commits

...
2 Commits
4 changed files with 133 additions and 5 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-license" name = "tesm-license"
version = "2.8.0" version = "2.8.2"
description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung" description = "TESM-Lizenzserver -- Kunden, Tickets, Lizenzen, Aktivierung"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "tesm-core" name = "tesm-core"
version = "2.8.0" version = "2.8.2"
description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver" description = "Gemeinsames Anwendungsgeruest (Auth, RBAC, Navigation, Design-System, Migrationen) fuer TESM und TESM-Lizenzserver"
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = [ dependencies = [
@@ -26,6 +26,7 @@ import re
import socket import socket
import sqlite3 import sqlite3
import ssl import ssl
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any, Literal from typing import Any, Literal
@@ -162,19 +163,82 @@ def _tls_object(config: dict[str, Any]):
def _server(config: dict[str, Any]): def _server(config: dict[str, Any]):
"""Das Server-Objekt -- so sparsam wie moeglich.
``get_info`` entscheidet, was ldap3 beim Verbinden **zusaetzlich** holt.
Mit ``ALL`` ist das die Beschreibung des Verzeichnisses *und sein ganzes
Schema*: bei einem Active Directory sind das Tausende Attribut- und
Klassendefinitionen, mehrere Megabyte, bei jeder Verbindung neu. Fuer eine
Anmeldung braucht davon nichts -- gelesen wird hoechstens der
``defaultNamingContext``, wenn kein Basis-DN eingetragen ist, und dafuer
genuegt ``DSA`` (nur die Serverinformation, ein Datensatz).
Liegt der Domain-Controller in einem anderen Netz, ist genau das der
Unterschied zwischen "geht sofort" und "dauert manchmal".
"""
from ldap3 import Server from ldap3 import Server
security = str(config["ldap_security"]) security = str(config["ldap_security"])
braucht_basis = not str(config.get("ldap_base_dn") or "").strip()
return Server( return Server(
str(config["ldap_host"]), str(config["ldap_host"]),
port=int(config["ldap_port"]), port=int(config["ldap_port"]),
use_ssl=security == "ldaps", use_ssl=security == "ldaps",
tls=_tls_object(config) if security in {"ldaps", "starttls"} else None, tls=_tls_object(config) if security in {"ldaps", "starttls"} else None,
get_info="ALL", get_info="DSA" if braucht_basis else "NO_INFO",
connect_timeout=int(config["ldap_timeout"]), connect_timeout=int(config["ldap_timeout"]),
) )
#: Fehler, die nur "gerade jetzt nicht" bedeuten -- nicht "falsches Passwort".
#:
#: Die Liste stammt aus der Fassung 1.2.8, wo dieses Problem schon einmal
#: geloest war. Der Kommentar dort beschreibt es genauer, als ich es koennte:
#: ein Domain-Controller in einem anderen, langsameren Netzsegment -- die
#: allererste Verbindung dorthin ist oft spuerbar langsamer als jede folgende,
#: und der erste Login schlug mit "Benutzer/Passwort ungueltig" fehl, waehrend
#: ein sofortiger zweiter Versuch klappte.
TRANSIENTE_FEHLER: tuple[type[Exception], ...] = ()
def _transiente_fehler() -> tuple[type[Exception], ...]:
"""Die Ausnahmeklassen von ldap3, die einen Wiederholversuch verdienen."""
global TRANSIENTE_FEHLER
if TRANSIENTE_FEHLER:
return TRANSIENTE_FEHLER
from ldap3.core.exceptions import (
LDAPResponseTimeoutError,
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
)
TRANSIENTE_FEHLER = (
LDAPSocketOpenError,
LDAPSocketReceiveError,
LDAPSocketSendError,
LDAPResponseTimeoutError,
)
return TRANSIENTE_FEHLER
def mit_wiederholung(aufbau: Any) -> Any:
"""Genau ein zweiter Versuch -- und nur bei einem Netzfehler.
Nicht bei einer abgelehnten Anmeldung: ein Tippfehler im Passwort wuerde
sonst den Sperrzaehler des Kontos zweimal statt einmal hochzaehlen. Das
ist die Regel aus 1.2.8, und sie ist richtig.
"""
try:
return aufbau()
except _transiente_fehler() as fehler:
logger.info(
"Verzeichnisdienst antwortete nicht (%s) -- ein zweiter Versuch",
type(fehler).__name__,
)
return aufbau()
def _connect(config: dict[str, Any], user: str, password: str): def _connect(config: dict[str, Any], user: str, password: str):
"""Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen.""" """Baut eine Verbindung auf und bindet. Wirft bei Netz-/TLS-Problemen."""
from ldap3 import Connection from ldap3 import Connection
@@ -192,6 +256,12 @@ def _connect(config: dict[str, Any], user: str, password: str):
auto_bind=False, auto_bind=False,
raise_exceptions=False, raise_exceptions=False,
receive_timeout=int(config["ldap_timeout"]), receive_timeout=int(config["ldap_timeout"]),
# Ein Active Directory antwortet auf eine Suche ab der Domaenenwurzel
# mit Verweisen auf andere Controller. ldap3 folgt ihnen von sich aus
# -- und baut dafuer neue Verbindungen auf, womoeglich in ein anderes
# Netz. Fuer eine Anmeldung ist das nicht nur unnoetig, sondern die
# Quelle genau der Wartezeiten, die niemand erklaeren kann.
auto_referrals=False,
) )
if security == "starttls": if security == "starttls":
if not connection.open(): if not connection.open():
@@ -253,8 +323,19 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
if not config["ldap_verify_cert"]: if not config["ldap_verify_cert"]:
warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft") warnings.append("Serverzertifikat des Verzeichnisdienstes wird nicht geprüft")
begonnen = time.monotonic()
zeiten: list[str] = []
def gemessen(name: str, seit: float) -> float:
"""Haelt eine Stufe mit ihrer Dauer fest und gibt die neue Marke."""
jetzt = time.monotonic()
zeiten.append(f"{name} {int((jetzt - seit) * 1000)}ms")
return jetzt
try: try:
service_conn, bound = _connect(config, bind_user, bind_password) service_conn, bound = mit_wiederholung(
lambda: _connect(config, bind_user, bind_password)
)
except (PermissionError, ConnectionError) as exc: except (PermissionError, ConnectionError) as exc:
logger.error("Verzeichnisdienst: %s", exc) logger.error("Verzeichnisdienst: %s", exc)
return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings)) return DirectoryResult("unavailable", message=str(exc), warnings=tuple(warnings))
@@ -271,8 +352,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
warnings=tuple(warnings), warnings=tuple(warnings),
) )
marke = gemessen("Dienstbindung", begonnen)
try: try:
base = _base_dn(service_conn, config) base = _base_dn(service_conn, config)
marke = gemessen("Basis-DN", marke)
if not base: if not base:
return DirectoryResult( return DirectoryResult(
"unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings) "unavailable", message="Basis-DN konnte nicht ermittelt werden.", warnings=tuple(warnings)
@@ -288,8 +371,10 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
attributes=list(dict.fromkeys(wanted)), attributes=list(dict.fromkeys(wanted)),
size_limit=2, size_limit=2,
) )
marke = gemessen("Benutzersuche", marke)
entries = list(service_conn.entries) entries = list(service_conn.entries)
if len(entries) != 1: if len(entries) != 1:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
entry = entries[0] entry = entries[0]
@@ -300,9 +385,18 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
# Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche # Zweite, unabhaengige Verbindung mit den Benutzerdaten -- die eigentliche
# Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden. # Passwortpruefung. Niemals ueber die Dienstkonto-Verbindung rebinden.
user_conn, user_bound = _connect(config, dn, password) #
# Mit Wiederholversuch, und genau hier zeigte sich das Symptom: bei
# einem Domain-Controller in einem anderen Netz schlug der erste
# Versuch mit "Benutzer/Passwort ungueltig" fehl, der zweite gleich
# darauf klappte. Wiederholt wird nur bei einem Netzfehler -- eine
# abgelehnte Anmeldung bleibt abgelehnt, sonst zaehlt ein Tippfehler
# den Sperrzaehler des Kontos zweimal hoch.
user_conn, user_bound = mit_wiederholung(lambda: _connect(config, dn, password))
marke = gemessen("Passwortpruefung", marke)
try: try:
if not user_bound: if not user_bound:
logger.info("Verzeichnisanmeldung %s: %s", username, ", ".join(zeiten))
return DirectoryResult("invalid", warnings=tuple(warnings)) return DirectoryResult("invalid", warnings=tuple(warnings))
finally: finally:
try: try:
@@ -313,6 +407,7 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
group_dns: tuple[str, ...] = () group_dns: tuple[str, ...] = ()
if config["ldap_group_sync"]: if config["ldap_group_sync"]:
group_dns = _resolve_groups(service_conn, base, dn) group_dns = _resolve_groups(service_conn, base, dn)
marke = gemessen("Gruppen", marke)
profile = DirectoryProfile( profile = DirectoryProfile(
username=_attr(entry, attribute) or username, username=_attr(entry, attribute) or username,
@@ -322,6 +417,15 @@ def authenticate(conn: sqlite3.Connection, username: str, password: str) -> Dire
email=_attr(entry, email_attribute), email=_attr(entry, email_attribute),
group_dns=group_dns, group_dns=group_dns,
) )
# Eine Zeile je Anmeldung mit den Dauern der Stufen. Aus "dauert
# manchmal laenger" wird damit eine Zahl -- und man sieht, ob es die
# Verbindung ist, die Suche oder die Gruppen.
logger.info(
"Verzeichnisanmeldung %s: %s, gesamt %dms",
username,
", ".join(zeiten),
int((time.monotonic() - begonnen) * 1000),
)
return DirectoryResult("ok", profile=profile, warnings=tuple(warnings)) return DirectoryResult("ok", profile=profile, warnings=tuple(warnings))
finally: finally:
try: try:
@@ -58,6 +58,30 @@ class SettingSpec:
return str(raw) return str(raw)
def serialize(self, value: Any) -> str: def serialize(self, value: Any) -> str:
"""Ein Wert als Text fuer die Datenbank.
Der Wert kann als Python-Wert kommen (aus einem Formular, aus dem
Code) -- oder als die Zeichenkette, die schon in der Datenbank stand.
Genau so liest eine **Sicherung** ihn wieder ein, und genau dort lag
ein stiller Fehler: ``serialize("0")`` ergab ``"1"``, weil jede nicht
leere Zeichenkette wahr ist. Aus jedem abgeschalteten Schalter wurde
beim Einspielen ein eingeschalteter -- auch aus
``ldap_allow_insecure``. Ein JSON-Feld wurde ein zweites Mal kodiert,
und aus der Navigationsreihenfolge damit eine Zeichenkette.
Deshalb: eine Zeichenkette erst lesen. Was hier herauskommt, muss
durch ``serialize`` unveraendert hindurchgehen -- sonst ueberlebt kein
Wert eine Sicherung.
"""
if isinstance(value, str):
if self.kind == "bool":
value = self.parse(value)
elif self.kind == "json":
try:
value = json.loads(value)
except ValueError:
# Kein JSON-Text, also ein Wert: dann wird er kodiert.
pass
if self.kind == "bool": if self.kind == "bool":
return "1" if value else "0" return "1" if value else "0"
if self.kind == "json": if self.kind == "json":