efihub/docs/superpowers/specs/2026-04-27-auth-part2-ldap-design.md
2026-04-27 14:16:20 +02:00

5.0 KiB
Raw Permalink Blame History

Design Spec: Benutzerverwaltung Part 2 — LDAP-Integration

Datum: 2026-04-27 Status: Genehmigt


Ziel

LDAP-Authentifizierung gegen das Active Directory der TH Nürnberg als Fallback zur lokalen Auth. Bei erfolgreichem Login wird der lokale User-Eintrag angelegt/aktualisiert und ein Hintergrund-Sync aller AD-User angestoßen (login-getriggert, nicht periodisch).


AD-Verbindungsparameter (bereits in config.py)

Setting Wert
LDAP_SERVER gso1.ads1.fh-nuernberg.de
LDAP_DOMAIN ADS1
LDAP_SEARCH_BASE OU=users,OU=EFI,OU=Faculties,DC=ADS1,DC=fh-nuernberg,DC=de
LDAP_ENABLED False (dev), True (prod)

Section 1: LDAP-Authentifizierung

Neue Config-Felder (app/core/config.py)

LDAP_SYNC_MIN_INTERVAL_HOURS: int = 12     # Mindestabstand zwischen Syncs
LDAP_SYNC_LETTER_DELAY_SECONDS: float = 5.0  # Wartezeit zwischen Buchstaben-Batches

Neue Datei app/modules/auth/ldap.py

Reine Funktionen — kein FastAPI, kein DB-Zugriff, nur ldap3.

ldap_authenticate(username, password) -> dict | None Öffnet NTLM-Verbindung gegen LDAP_SERVER, bindet, liest User-Attribute und schließt die Verbindung wieder. Gibt bei Erfolg ein Dict zurück, bei falschem Passwort oder Verbindungsfehler None (Verbindungsfehler wird geloggt). Eine einzige Verbindung für Bind + Attributabfrage.

Rückgabe-Dict:

{
    "username": str,       # sAMAccountName
    "full_name": str,      # f"{givenName} {sn}"
    "department": str,     # department (max 64 Zeichen, "N.N." wenn leer)
    "role": str,           # description (max 64 Zeichen, "N.N." wenn leer)
    "account_expires": datetime | None,  # accountExpires, None = kein Ablauf
}

sync_all_users(username, password, db, settings) -> None Synchronisiert alle AD-User alphabetisch (az + _). Läuft in einem BackgroundTask-Thread:

  1. has_to_update() prüfen — wenn letzter Sync < LDAP_SYNC_MIN_INTERVAL_HOURS, abbrechen
  2. Neue NTLM-Verbindung mit den übergebenen Credentials öffnen
  3. Für jeden Buchstaben:
    • AD-Einträge mit CN=<buchstabe>* laden
    • Jeden Entry upserten (full_name, department, role, account_expires)
    • time.sleep(LDAP_SYNC_LETTER_DELAY_SECONDS)
  4. User in lokaler DB die nicht in AD gefunden wurden UND pw_hash is Noneis_active = False
  5. Verbindung schließen

has_to_update() liest den Zeitstempel des zuletzt geänderten Users aus der DB (entspricht Vorversion).

Geänderte Datei app/modules/auth/service.py

Geänderter Auth-Flow in authenticate_user:

1. user = get_user(db, username)
2. user existiert UND is_active=False → return None  (absolute Sperre)
3. user existiert UND pw_hash gesetzt:
   → verify_password(password, pw_hash)
   → OK: last_login setzen, return user
4. ldap_enabled:
   → attrs = ldap_authenticate(username, password)
   → attrs is None: return None
   → OK: upsert_ldap_user(db, attrs), last_login setzen, return user
5. return None

Neue Funktion upsert_ldap_user(db, username, attrs) -> User Legt User an falls nicht vorhanden, schreibt AD-Felder (full_name, department, role, account_expires). Setzt pw_hash nicht (bleibt None für reine LDAP-User).

Geänderte Datei app/modules/auth/router.py

POST /auth/login erhält einen BackgroundTasks-Parameter. Nach erfolgreichem LDAP-Login wird sync_all_users als BackgroundTask gestartet:

@router.post("/login")
async def login(background_tasks: BackgroundTasks, ...):
    user = authenticate_user(...)
    if user and settings.LDAP_ENABLED:
        background_tasks.add_task(sync_all_users, username, password, db, settings)
    ...

Section 2: AD-Sync-Logik

Sync-Verhalten

Situation Aktion
User in AD, in DB full_name, department, role, account_expires aktualisieren
User in AD, nicht in DB Neuen User anlegen (is_active=True, pw_hash=None)
User nicht in AD, in DB, pw_hash is None is_active = False
User nicht in AD, in DB, pw_hash gesetzt Unverändert (lokaler Account, z. B. Admin)

Timing

  • Sync läuft nur wenn letzter Sync > LDAP_SYNC_MIN_INTERVAL_HOURS her
  • Zwischen jedem Buchstaben-Batch: LDAP_SYNC_LETTER_DELAY_SECONDS Sleep
  • Gesamtdauer ca. 27 × 5s ≈ 23 Minuten (im Hintergrund, blockiert nichts)

Dateistruktur

app/modules/auth/
├── ldap.py           # neu: ldap_authenticate, ldap_get_user_attrs, sync_all_users
├── service.py        # geändert: LDAP-Stub füllen, upsert_ldap_user
└── router.py         # geändert: BackgroundTasks in POST /auth/login
app/core/
└── config.py         # geändert: LDAP_SYNC_MIN_INTERVAL_HOURS, LDAP_SYNC_LETTER_DELAY_SECONDS

Out of Scope

  • Serviceaccount / periodischer Celery-Sync (→ mögliche spätere Erweiterung)
  • account_expires-Prüfung beim Login (Blockierung läuft über AD-Bind-Fehler und is_active)
  • Admin-UI für User-Verwaltung (→ separater Spec)
  • Gruppen aus AD (→ Part 3)