diff --git a/docs/superpowers/specs/2026-04-27-auth-part2-ldap-design.md b/docs/superpowers/specs/2026-04-27-auth-part2-ldap-design.md new file mode 100644 index 0000000..cb09ddb --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-auth-part2-ldap-design.md @@ -0,0 +1,139 @@ +# 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`) + +```python +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: + +```python +{ + "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 (a–z + `_`). 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=*` 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 None` → `is_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: + +```python +@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 ≈ 2–3 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)