163 lines
5.2 KiB
Markdown
163 lines
5.2 KiB
Markdown
# 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 |