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

140 lines
5.0 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 (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 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 ≈ 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)