diff --git a/docs/superpowers/specs/2026-04-27-auth-part1-design.md b/docs/superpowers/specs/2026-04-27-auth-part1-design.md new file mode 100644 index 0000000..acd48a9 --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-auth-part1-design.md @@ -0,0 +1,163 @@ +# 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 \ No newline at end of file