efihub/docs/superpowers/specs/2026-04-27-auth-part1-design.md
2026-04-27 09:16:55 +02:00

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