# Design Spec: Benutzerverwaltung Part 1 — Core Auth **Datum:** 2026-04-27 **Status:** Genehmigt --- ## Ziel Lokale Benutzerverwaltung mit JWT-basierter Session (httpOnly-Cookie) und optionalem LDAP-Fallback. Part 1 implementiert das User-Datenmodell, lokale Passwort-Auth und die FastAPI-Dependencies. Part 2 (separater Spec) ergänzt LDAP-Auth und Celery-Hintergrund-Sync. --- ## Datenmodell Einzige Tabelle `users`: | Feld | SQLAlchemy-Typ | Details | |---|---|---| | `id` | `Integer`, PK | Auto-increment | | `username` | `String(64)`, unique, not null | sAMAccountName / lokaler Login | | `full_name` | `String(128)`, not null | Vor- + Nachname | | `email` | `String(128)`, nullable | optional | | `department` | `String(64)`, nullable | aus AD (Part 2) | | `role` | `String(64)`, nullable | aus AD `description`-Feld (Part 2) | | `pw_hash` | `String(256)`, nullable | `None` = kein lokaler Login möglich | | `is_active` | `Boolean`, default `True` | gesperrte Accounts | | `is_admin` | `Boolean`, default `False` | Admin-Rechte | | `account_expires` | `DateTime`, nullable | aus AD; `None` = kein Ablauf | | `last_login` | `DateTime`, nullable | bei jedem Login aktualisiert | | `created_at` | `DateTime`, server_default | automatisch | | `updated_at` | `DateTime`, onupdate | automatisch | `pw_hash is not None` → lokale Auth verfügbar. Kein separates `auth_source`-Flag. --- ## Auth-Flow ``` POST /auth/login (username + password als Form-Daten) │ ├─► Nutzer in DB vorhanden und is_active? │ └─► Nein → 401 │ ├─► pw_hash gesetzt? │ ├─► Ja → passlib bcrypt.verify(password, pw_hash) │ │ ├─► OK → Login erfolgreich ✓ │ │ └─► Falsch → weiter zu LDAP (falls aktiviert) │ └─► Nein → weiter zu LDAP (falls aktiviert) │ ├─► LDAP aktiviert? (settings.LDAP_ENABLED == True, gesetzt wenn APP_ENV != "development") │ ├─► Ja → ldap3 NTLM-Bind gegen gso1.ads1.fh-nuernberg.de │ │ ├─► OK → AD-Felder in User-Eintrag schreiben, │ │ │ last_login setzen → Login erfolgreich ✓ │ │ └─► Fehler → 401 │ └─► Nein → 401 │ └─► Bei Erfolg: JWT erstellen → httpOnly-Cookie "access_token" setzen → Redirect / ``` **Timing-Attack-Schutz:** Wenn `pw_hash` gesetzt aber falsch, trotzdem LDAP probieren — kein frühes Abbrechen das Timing-Unterschiede verrät. --- ## JWT - Library: `python-jose[cryptography]` - Algorithmus: `HS256` - Claims: `sub` (username), `exp` (jetzt + 8h), `is_admin` (bool) - Secret: `settings.SECRET_KEY` (Zufallsstring, in `.env` gesetzt) - Cookie: `access_token`, `httpOnly=True`, `samesite="lax"`, `secure=True` wenn `APP_ENV == "production"` --- ## Konfiguration (Ergänzungen in `app/core/config.py`) ```python SECRET_KEY: str = "changeme" LDAP_ENABLED: bool = False # True wenn APP_ENV != "development" LDAP_SERVER: str = "gso1.ads1.fh-nuernberg.de" LDAP_DOMAIN: str = "ADS1" LDAP_SEARCH_BASE: str = "OU=users,OU=EFI,OU=Faculties,DC=ADS1,DC=fh-nuernberg,DC=de" ``` `LDAP_ENABLED` kann in `.env` explizit überschrieben werden. --- ## Dateistruktur ``` app/ ├── core/ │ ├── config.py # Ergänzung: SECRET_KEY, LDAP_* │ ├── database.py # neu: Engine, SessionLocal, Base, get_db() │ └── auth.py # neu: JWT erstellen/lesen, Cookie setzen/löschen ├── modules/ │ └── auth/ │ ├── __init__.py │ ├── models.py # SQLAlchemy User-Model │ ├── schemas.py # Pydantic: LoginForm, UserOut │ ├── service.py # verify_password, authenticate_user │ ├── router.py # GET/POST /auth/login, GET /auth/logout │ └── dependencies.py # get_current_user, require_admin ├── templates/ │ └── auth/ │ └── login.html # Login-Formular (extends base.html) alembic/ ├── alembic.ini ├── env.py └── versions/ └── 0001_create_users.py ``` --- ## Endpunkte | Methode | Pfad | Beschreibung | |---|---|---| | `GET` | `/auth/login` | Login-Formular | | `POST` | `/auth/login` | Auth-Logik, Cookie setzen, Redirect `/` | | `GET` | `/auth/logout` | Cookie löschen, Redirect `/auth/login` | | `GET` | `/auth/me` | Aktueller User als JSON (für API-Clients) | --- ## FastAPI-Dependencies - `get_current_user(request: Request, db: Session = Depends(get_db)) -> User` Liest JWT aus Cookie `access_token`, dekodiert, lädt User aus DB. Wirft `RedirectResponse("/auth/login")` wenn kein/ungültiges Token. - `require_admin(user: User = Depends(get_current_user)) -> User` Prüft `user.is_admin`, wirft `HTTPException(403)` wenn False. --- ## Login-Template Minimales Formular, extends `base.html`. Felder: `username`, `password`. Fehlermeldung wenn Auth fehlschlägt (kein Detail welche Methode fehlschlug — Sicherheit). --- ## Neue Dependencies ``` alembic python-jose[cryptography] passlib[bcrypt] python-multipart ldap3 ``` --- ## Out of Scope (Part 1) - LDAP-Hintergrund-Sync (→ Part 2) - Passwort-Reset / Registrierungs-UI - Admin-CRUD-UI für User-Verwaltung (→ sqladmin, separater Spec) - Refresh-Tokens