From a4d4119d5500b0775efba0b6fff4bd8b524d4501 Mon Sep 17 00:00:00 2001 From: patrick Date: Thu, 17 Sep 2026 01:23:21 +0200 Subject: [PATCH] =?UTF-8?q?feat(auth):=20FOUND-016=20API-Token=20f=C3=BCr?= =?UTF-8?q?=20Entwicklungs-/Tooling-Zugriff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rollenbasiertes, langlebiges Token neben dem normalen JWT-Login-Flow - für automatisierte Zugriffe/Skripte. Admin wählt bei Erzeugung eine oder mehrere Rollen, Klartext wird einmalig gezeigt (wie ein Passwort), Token ist jederzeit deaktivierbar/reaktivierbar. Architektur: jedes Token bekommt einen Begleit-"Dienstkonto"-Benutzer (Benutzer.ist_dienstkonto=True, kein echter Login), der die Rollen über die bestehende benutzer_rolle-Tabelle trägt - dadurch funktionieren require_roles()/require_permission() und alle Audit-FKs unverändert, keine bestehende Endpunkt-Dependency musste angepasst werden. get_current_user() erkennt ein Token am Präfix mabea_pat_ und löst es über den Hash (SHA-256, kein bcrypt - Token ist bereits hochentropisch) auf den Begleit-Benutzer auf. Dienstkonten sind aus GET /benutzer ausgeblendet, damit sie keine Auswahl-Dropdowns verunreinigen. Neuer Admin-Tab "API-Tokens" (Gruppe System, nur Administration). Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01VDMvneg6wKfXdXvTYNd3dN --- DEVLOG.md | 75 ++++++++ arbeitskacheln/01_foundation.md | 71 ++++++++ backend/alembic/versions/0061_api_token.py | 40 +++++ backend/app/api/deps.py | 11 +- backend/app/api/v1/api.py | 2 + backend/app/api/v1/endpoints/api_token.py | 76 ++++++++ backend/app/api/v1/endpoints/benutzer.py | 6 +- backend/app/core/security.py | 24 +++ backend/app/models/api_token.py | 29 +++ backend/app/models/auth.py | 7 + backend/app/schemas/api_token.py | 32 ++++ backend/app/services/api_token.py | 95 ++++++++++ backend/tests/test_api_token.py | 134 ++++++++++++++ frontend/DEVLOG.md | 11 ++ frontend/src/api/types.ts | 17 ++ frontend/src/pages/AdminPage.tsx | 6 +- frontend/src/pages/admin/ApiTokenSection.tsx | 178 +++++++++++++++++++ 17 files changed, 811 insertions(+), 3 deletions(-) create mode 100644 backend/alembic/versions/0061_api_token.py create mode 100644 backend/app/api/v1/endpoints/api_token.py create mode 100644 backend/app/models/api_token.py create mode 100644 backend/app/schemas/api_token.py create mode 100644 backend/app/services/api_token.py create mode 100644 backend/tests/test_api_token.py create mode 100644 frontend/src/pages/admin/ApiTokenSection.tsx diff --git a/DEVLOG.md b/DEVLOG.md index 515b20c..48fb2bb 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -16263,3 +16263,78 @@ Keine Commits in dieser Session. - backend/tests/test_zust004_standort_sichtbarkeit.py | 35 ++++++++++++++++++++++ --- +## 2026-09-15 00:29 – 00:33 (3m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- 260e0f6 fix(zustaendigkeit): ZUST-004 Runde 7 - Lagerort-Lücken aus Runde 5 geschlossen + +### Geänderte Dateien +- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++ +- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++---- +- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-15 00:34 – 00:34 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++ +- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++---- +- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-15 00:37 – 00:38 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- DEVLOG.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ +- arbeitskacheln/17_zustaendigkeit.md | 13 +++++++++++ +- backend/app/api/v1/endpoints/lager.py | 39 +++++++++++++++++++++++++++++---- +- backend/tests/test_zust004_standort_sichtbarkeit.py | 64 +++++++++++++++++++++++++++++++++++++++++++++++++++++ + +--- +## 2026-09-15 00:39 – 00:40 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +- dabe0db fix(lager): fehlenden Import standort_fuer_lagerort ergänzt + +### Geänderte Dateien +- backend/app/api/v1/endpoints/lager.py | 1 + + +--- +## 2026-09-15 00:45 – 00:45 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/api/v1/endpoints/lager.py | 1 + + +--- +## 2026-09-15 01:06 – 01:06 (0m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/api/v1/endpoints/lager.py | 1 + + +--- diff --git a/arbeitskacheln/01_foundation.md b/arbeitskacheln/01_foundation.md index 85fcd5e..c0e1bd5 100644 --- a/arbeitskacheln/01_foundation.md +++ b/arbeitskacheln/01_foundation.md @@ -303,6 +303,77 @@ detaillierten) siehe `00_index.md`. Fallback bestehen, DB-Wert hat Vorrang. `send_default_pii=False` fest verdrahtet (DSGVO). Tests: `test_found013_systemeinstellung.py`. +## FOUND-016 — API-Token für Entwicklungs-/Tooling-Zugriff (P2, neu, Nutzer-Anforderung 2026-09-17) + +- **Ziel:** Langlebiger, rollenbasierter API-Zugriff ohne den normalen + Login-Flow (Passwort+JWT) - für Entwicklungs-/Tooling-Zwecke (z.B. + automatisierte Zugriffe/Skripte gegen die laufende API). +- **Bedarf: bestätigt (2026-09-17)** — Nutzer-Anforderung, Klärung per + Rückfrage: Zweck=Tooling-Zugriff, Rechte=frei wählbar bei Erzeugung, + Verwaltung=Admin-UI. +- **Beschreibung:** Admin legt ein Token mit Name + einer oder mehreren der + vier festen Rollen an, bekommt den Klartext EINMALIG angezeigt (wie ein + Passwort - danach nur noch Hash gespeichert), kann es jederzeit + deaktivieren/reaktivieren. +- **Benutzerwert:** Kein Nutzer-Login-Passwort für automatisierte Zugriffe + zweckentfremden/teilen, granular je Zweck deaktivierbar ohne einen + echten Benutzer-Account anzufassen. +- **Abhängigkeiten:** FOUND-003 (Authentication), FOUND-004 (Authorization) +- **Datenmodell:** `api_token` (id, name, token_hash, benutzer_id, + aktiv, erstellt_am, erstellt_von_benutzer_id, letzte_verwendung_am) + + `Benutzer.ist_dienstkonto` (bool). Bewusste Entscheidung: KEIN eigenes + Rollen-/Rechte-Konzept für Tokens - jedes Token bekommt einen + Begleit-„Dienstkonto"-Benutzer (`ist_dienstkonto=True`, kein echter + Login, Zufalls-Passwort-Hash), der die Rollen über die bestehende + `benutzer_rolle`-Tabelle trägt. Dadurch funktionieren + `require_roles()`/`require_permission()` und alle Audit-FKs + (`historie.benutzer_id`, `dokument.hochgeladen_von`, ...) unverändert - + keine einzige bestehende Endpunkt-Dependency musste angepasst werden. +- **Backend:** `GET/POST /api-tokens`, `PATCH /api-tokens/{id}` (aktiv + setzen). `app/api/deps.py:get_current_user()` erkennt ein API-Token am + festen Präfix `mabea_pat_` und löst es über + `app/services/api_token.py:hole_benutzer_fuer_token()` auf den + Begleit-Benutzer auf, statt den JWT-Pfad zu versuchen - kein teurer + "erst JWT probieren, dann Fallback"-Umweg. Hashing: SHA-256 (nicht + bcrypt) - das Token ist bereits hochentropisch (256 Bit Zufall über + `secrets.token_urlsafe`), kein Passwort mit Wörterbuch-/Brute-Force- + Risiko, ein schneller deterministischer Hash erlaubt den Lookup per + UNIQUE-Index statt bcrypt.verify() gegen jede Zeile einzeln. + `GET /benutzer` blendet Dienstkonten aus (`ist_dienstkonto=False`-Filter), + damit sie keine Kontrollverantwortung-/Zuständigkeit-Auswahl-Dropdowns im + Frontend verunreinigen. +- **Frontend:** neuer Admin-Tab „API-Tokens" (`ApiTokenSection.tsx`, + Gruppe „System", nur Administration) - Anlegen-Formular mit + Rollen-Checkboxen, zeigt den Klartext nach Erzeugung einmalig in einem + auffällig markierten Kasten mit Kopier-Button und Warnhinweis, Liste + bestehender Tokens mit Rollen/Erstellt-am/Letzte-Verwendung/Status-Badge + + Aktivieren/Deaktivieren-Button. +- **Mobile:** keins (reiner Admin-/Tooling-Vorgang). +- **QR-Code/Seriennummer/Inventarnummer:** nein. +- **Akte:** nein. +- **Rechte:** Verwaltung (Anlegen/Deaktivieren/Liste) ausschließlich + Administration. +- **Audit:** `erstellt_von_benutzer_id`/`erstellt_am`/`letzte_verwendung_am` + je Token; kein separates Historie-Log-Event (Token-Erzeugung selbst ist + bereits über diese Felder nachvollziehbar) - P3-Detail, kein + Akzeptanzkriterium dieser Kachel. +- **Akzeptanzkriterien:** Token funktioniert als Bearer-Token mit exakt den + zugewiesenen Rollen; Deaktivieren sperrt den Zugriff sofort (401), + Reaktivieren stellt ihn wieder her; Dienstkonten erscheinen nicht in + `GET /benutzer`; ein frei erfundenes Token wird abgelehnt (401). +- **Tests:** `backend/tests/test_api_token.py` - Anlegen+Verwenden, + Ausblendung aus Benutzer-Liste, Deaktivieren sperrt Zugriff, + Reaktivieren stellt wieder her, ungültiges Token abgelehnt, Rollen + werden respektiert (403 bei fehlender Rolle), Verwaltung nur Admin, + Liste zeigt Rollen/Status korrekt. +- **DoD:** ✅ umgesetzt (2026-09-17). Migration `0061_api_token.py`. + `app/models/api_token.py`, `app/services/api_token.py`, + `app/schemas/api_token.py`, `app/api/v1/endpoints/api_token.py`, + `app/core/security.py` (`erzeuge_api_token()`/`hashe_api_token()`), + `app/api/deps.py` (Token-Erkennung per Präfix). Frontend: + `ApiTokenSection.tsx`, `AdminPage.tsx`-Tab, `api/types.ts` + (`ApiToken`/`ApiTokenCreateResponse`). + --- **MABEA-Ist-Stand-Abgleich (aktualisiert 2026-09-10):** FOUND-001…006 sind in diff --git a/backend/alembic/versions/0061_api_token.py b/backend/alembic/versions/0061_api_token.py new file mode 100644 index 0000000..9fb8c9c --- /dev/null +++ b/backend/alembic/versions/0061_api_token.py @@ -0,0 +1,40 @@ +"""FOUND-016: API-Token für Entwicklungs-/Tooling-Zugriff, mit Deaktivierung. +Begleit-Benutzer (ist_dienstkonto=True) trägt die Rollen des Tokens über die +bestehende benutzer_rolle-Tabelle - kein eigenes Rollen-Konzept nötig. + +Revision ID: 0061_api_token +Revises: 0060_lagerort_standort_id +Create Date: 2026-09-17 +""" + +from typing import Sequence, Union + +from alembic import op + +revision: str = "0061_api_token" +down_revision: Union[str, None] = "0060_lagerort_standort_id" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute("ALTER TABLE benutzer ADD COLUMN ist_dienstkonto BOOLEAN NOT NULL DEFAULT false") + op.execute( + """ + CREATE TABLE api_token ( + id UUID PRIMARY KEY, + name VARCHAR NOT NULL, + token_hash VARCHAR NOT NULL UNIQUE, + benutzer_id INTEGER NOT NULL UNIQUE REFERENCES benutzer(id), + aktiv BOOLEAN NOT NULL DEFAULT true, + erstellt_am TIMESTAMPTZ NOT NULL, + erstellt_von_benutzer_id INTEGER NOT NULL REFERENCES benutzer(id), + letzte_verwendung_am TIMESTAMPTZ + ) + """ + ) + + +def downgrade() -> None: + op.execute("DROP TABLE api_token") + op.execute("ALTER TABLE benutzer DROP COLUMN ist_dienstkonto") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 8fb3a59..8b0c245 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -5,10 +5,11 @@ from fastapi.security import OAuth2PasswordBearer from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.core.security import InvalidTokenError, decode_access_token +from app.core.security import API_TOKEN_PRAEFIX, InvalidTokenError, decode_access_token from app.db.session import get_db from app.models.auth import Benutzer, RolleTyp from app.models.permission import Berechtigung, BenutzerRolleZuordnung, Rolle, RolleBerechtigung +from app.services.api_token import hole_benutzer_fuer_token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login") @@ -22,6 +23,14 @@ async def get_current_user( detail="Ungültiger oder abgelaufener Token", headers={"WWW-Authenticate": "Bearer"}, ) + # FOUND-016: API-Token statt JWT - eindeutig am Präfix erkennbar, kein + # teurer/fehlerträchtiger "erst JWT-Decode versuchen"-Umweg nötig. + if token.startswith(API_TOKEN_PRAEFIX): + benutzer = await hole_benutzer_fuer_token(db, token) + if benutzer is None: + raise credentials_error + return benutzer + try: payload = decode_access_token(token) except InvalidTokenError as exc: diff --git a/backend/app/api/v1/api.py b/backend/app/api/v1/api.py index 6ef184c..42799ef 100644 --- a/backend/app/api/v1/api.py +++ b/backend/app/api/v1/api.py @@ -2,6 +2,7 @@ from fastapi import APIRouter from app.api.v1.endpoints import ( akte, + api_token, app_download, ausgabe, auth, @@ -47,6 +48,7 @@ api_router.include_router(app_download.router, tags=["app_download"]) api_router.include_router(auth.router, tags=["auth"]) api_router.include_router(stammdaten.router, tags=["stammdaten"]) api_router.include_router(benutzer.router, tags=["benutzer"]) +api_router.include_router(api_token.router, tags=["api_token"]) api_router.include_router(zustaendigkeit.router, tags=["zustaendigkeit"]) api_router.include_router(vorlagen.router, tags=["vorlagen"]) # import_export VOR objekte/geraet_instanz registriert: /objekte/export und diff --git a/backend/app/api/v1/endpoints/api_token.py b/backend/app/api/v1/endpoints/api_token.py new file mode 100644 index 0000000..0c63b1b --- /dev/null +++ b/backend/app/api/v1/endpoints/api_token.py @@ -0,0 +1,76 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.api.deps import require_roles +from app.db.session import get_db +from app.models.auth import Benutzer, RolleTyp +from app.schemas.api_token import ApiTokenAktivUpdate, ApiTokenCreate, ApiTokenCreateResponse, ApiTokenRead +from app.services.api_token import ( + ApiTokenNichtGefundenError, + erstelle_token, + liste_tokens, + rollen_fuer_token, + setze_aktiv, +) + +router = APIRouter() + +_admin_only = require_roles(RolleTyp.administration) + + +@router.get("/api-tokens", response_model=list[ApiTokenRead]) +async def hole_api_tokens(db: AsyncSession = Depends(get_db), _=Depends(_admin_only)) -> list[ApiTokenRead]: + ergebnisse = [] + for token in await liste_tokens(db): + ergebnisse.append( + ApiTokenRead( + id=token.id, + name=token.name, + aktiv=token.aktiv, + erstellt_am=token.erstellt_am, + letzte_verwendung_am=token.letzte_verwendung_am, + rollen=await rollen_fuer_token(db, token), + ) + ) + return ergebnisse + + +@router.post("/api-tokens", response_model=ApiTokenCreateResponse, status_code=status.HTTP_201_CREATED) +async def erstelle_api_token( + payload: ApiTokenCreate, + db: AsyncSession = Depends(get_db), + current_user: Benutzer = Depends(_admin_only), +) -> ApiTokenCreateResponse: + """FOUND-016: Klartext-Token wird NUR in dieser Antwort zurückgegeben - + danach ist nur noch der Hash gespeichert, kein Wiederherstellen möglich + (wie ein Passwort). Admin-UI muss den Wert sofort anzeigen/kopieren + lassen und darauf hinweisen, dass er nicht wieder abrufbar ist.""" + token, klartext = await erstelle_token( + db, name=payload.name, rollen=payload.rollen, erstellt_von_benutzer_id=current_user.id + ) + return ApiTokenCreateResponse(id=token.id, name=token.name, token=klartext, rollen=payload.rollen) + + +@router.patch("/api-tokens/{token_id}", response_model=ApiTokenRead) +async def aendere_api_token_status( + token_id: uuid.UUID, + payload: ApiTokenAktivUpdate, + db: AsyncSession = Depends(get_db), + _=Depends(_admin_only), +) -> ApiTokenRead: + """Deaktivieren/Reaktivieren - kein Löschen, damit Audit-Historie + (erstellt_von, letzte_verwendung_am) nachvollziehbar bleibt.""" + try: + token = await setze_aktiv(db, token_id, payload.aktiv) + except ApiTokenNichtGefundenError as exc: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="API-Token nicht gefunden") from exc + return ApiTokenRead( + id=token.id, + name=token.name, + aktiv=token.aktiv, + erstellt_am=token.erstellt_am, + letzte_verwendung_am=token.letzte_verwendung_am, + rollen=await rollen_fuer_token(db, token), + ) diff --git a/backend/app/api/v1/endpoints/benutzer.py b/backend/app/api/v1/endpoints/benutzer.py index 2937dff..78b2ca1 100644 --- a/backend/app/api/v1/endpoints/benutzer.py +++ b/backend/app/api/v1/endpoints/benutzer.py @@ -23,7 +23,11 @@ _lesend = require_roles(RolleTyp.administration, RolleTyp.materialverantwortlich @router.get("/benutzer", response_model=list[BenutzerRead]) async def liste_benutzer(db: AsyncSession = Depends(get_db), _=Depends(_lesend)) -> list[BenutzerRead]: - result = await db.execute(select(Benutzer)) + """FOUND-016: Dienstkonten (Begleit-Benutzer eines API-Tokens) tauchen + hier bewusst nicht auf - kein echter Login, würde nur Kontrollverant- + wortung-/Zuständigkeit-Auswahl-Dropdowns im Frontend verunreinigen. + Verwaltet werden sie ausschließlich über /api-tokens.""" + result = await db.execute(select(Benutzer).where(Benutzer.ist_dienstkonto.is_(False))) return [BenutzerRead.from_orm_benutzer(b) for b in result.scalars().all()] diff --git a/backend/app/core/security.py b/backend/app/core/security.py index e2cf445..ac9d20a 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -1,3 +1,5 @@ +import hashlib +import secrets from datetime import datetime, timedelta, timezone import jwt @@ -5,6 +7,11 @@ from passlib.context import CryptContext from app.core.app_settings import settings +# FOUND-016: Präfix macht ein API-Token in Logs/Diffs sofort als solches +# erkennbar (analog GitHub Personal Access Tokens), erleichtert versehentlich +# committete Tokens per Secret-Scanner zu finden. +API_TOKEN_PRAEFIX = "mabea_pat_" + _pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") # Konstanter Dummy-Hash für Timing-Angriff-Schutz (jwt-expert-Review-Fund): @@ -39,6 +46,23 @@ class InvalidTokenError(Exception): pass +def erzeuge_api_token() -> tuple[str, str]: + """FOUND-016: gibt (Klartext, Hash) zurück - Klartext wird NUR beim + Erzeugen einmal zurückgegeben, danach ist nur noch der Hash gespeichert + und der Klartext nirgends mehr rekonstruierbar (wie ein Passwort).""" + klartext = f"{API_TOKEN_PRAEFIX}{secrets.token_urlsafe(32)}" + return klartext, hashe_api_token(klartext) + + +def hashe_api_token(klartext: str) -> str: + # SHA-256 statt bcrypt: das Token selbst ist bereits hochentropisch + # (256 Bit Zufall), kein Passwort mit Wörterbuch-/Brute-Force-Risiko - + # ein schneller, deterministischer Hash erlaubt den Lookup per + # UNIQUE-Index (`SELECT ... WHERE token_hash = ?`) statt bcrypt.verify() + # gegen jede gespeicherte Zeile einzeln durchzuprobieren. + return hashlib.sha256(klartext.encode()).hexdigest() + + def decode_access_token(token: str) -> dict: try: return jwt.decode( diff --git a/backend/app/models/api_token.py b/backend/app/models/api_token.py new file mode 100644 index 0000000..a823646 --- /dev/null +++ b/backend/app/models/api_token.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, ForeignKey, String +from sqlalchemy.dialects.postgresql import TIMESTAMP, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base import Base + + +class ApiToken(Base): + """FOUND-016: langlebiges Token für Entwicklungs-/Tooling-Zugriff auf die + API (kein Nutzer-Login-Flow) - `benutzer_id` zeigt auf einen begleitenden + Dienstkonto-Benutzer (`Benutzer.ist_dienstkonto=True`), der die dem Token + zugewiesenen Rollen trägt. Nur `token_hash` wird gespeichert (SHA-256, + kein bcrypt nötig - hochentropisches Zufallstoken statt Passwort mit + Wörterbuch-/Brute-Force-Risiko, siehe app/core/security.py), der + Klartext wird ausschließlich einmalig bei der Erzeugung zurückgegeben.""" + + __tablename__ = "api_token" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name: Mapped[str] = mapped_column(String, nullable=False) + token_hash: Mapped[str] = mapped_column(String, unique=True, nullable=False) + benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False, unique=True) + aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + erstellt_am: Mapped[datetime] = mapped_column(TIMESTAMP(timezone=True), nullable=False) + erstellt_von_benutzer_id: Mapped[int] = mapped_column(ForeignKey("benutzer.id"), nullable=False) + letzte_verwendung_am: Mapped[datetime | None] = mapped_column(TIMESTAMP(timezone=True)) diff --git a/backend/app/models/auth.py b/backend/app/models/auth.py index 373b669..6daf651 100644 --- a/backend/app/models/auth.py +++ b/backend/app/models/auth.py @@ -52,6 +52,13 @@ class Benutzer(Base): # aber nullable - ein Login ohne fachliche Person(Übergang) wäre sonst nicht # anlegbar, wenn die Migration künftig übersprungen wird. person_id: Mapped[int | None] = mapped_column(ForeignKey("person.id")) + # FOUND-016: Begleit-Benutzer für ein API-Token, kein echter Login (kein + # Mensch tippt hier ein Passwort ein) - trägt nur die dem Token + # zugewiesenen Rollen, damit require_roles()/require_permission() + # unverändert funktionieren und Audit-FKs (historie.benutzer_id etc.) + # auf eine echte Zeile zeigen. Aus normalen Benutzer-Listen/Pickern + # ausgeblendet (siehe liste_benutzer()). + ist_dienstkonto: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) rollen: Mapped[list["BenutzerRolle"]] = relationship(back_populates="benutzer", lazy="selectin") diff --git a/backend/app/schemas/api_token.py b/backend/app/schemas/api_token.py new file mode 100644 index 0000000..02a9f6c --- /dev/null +++ b/backend/app/schemas/api_token.py @@ -0,0 +1,32 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict + +from app.models.auth import RolleTyp + + +class ApiTokenCreate(BaseModel): + name: str + rollen: list[RolleTyp] + + +class ApiTokenCreateResponse(BaseModel): + id: uuid.UUID + name: str + token: str # Klartext - wird NUR in dieser Antwort einmalig übertragen + rollen: list[RolleTyp] + + +class ApiTokenRead(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: uuid.UUID + name: str + aktiv: bool + erstellt_am: datetime + letzte_verwendung_am: datetime | None + rollen: list[RolleTyp] + + +class ApiTokenAktivUpdate(BaseModel): + aktiv: bool diff --git a/backend/app/services/api_token.py b/backend/app/services/api_token.py new file mode 100644 index 0000000..c2e5dcd --- /dev/null +++ b/backend/app/services/api_token.py @@ -0,0 +1,95 @@ +import secrets +import uuid +from datetime import datetime, timezone + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.security import erzeuge_api_token, hash_password, hashe_api_token +from app.models.api_token import ApiToken +from app.models.auth import Benutzer, BenutzerRolle, RolleTyp + + +class ApiTokenNichtGefundenError(Exception): + pass + + +async def erstelle_token( + db: AsyncSession, *, name: str, rollen: list[RolleTyp], erstellt_von_benutzer_id: int +) -> tuple[ApiToken, str]: + """FOUND-016: legt einen Begleit-Benutzer an (kein echter Login - Passwort + ist ein unverwendbarer Zufallswert, siehe Docstring ApiToken), der die + übergebenen Rollen über die bestehende benutzer_rolle-Tabelle trägt. + Gibt das Klartext-Token zusätzlich zurück - der Aufrufer (Endpoint) MUSS + es dem Admin einmalig zeigen, es ist danach nirgends mehr abrufbar.""" + dienstkonto = Benutzer( + name=f"API-Token: {name}", + login=f"api-token:{uuid.uuid4()}", + passwort_hash=hash_password(secrets_urlsafe_unbenutzbar()), + aktiv=True, + ist_dienstkonto=True, + ) + db.add(dienstkonto) + await db.flush() + + for rolle in rollen: + db.add(BenutzerRolle(benutzer_id=dienstkonto.id, rolle=rolle)) + + klartext, token_hash = erzeuge_api_token() + token = ApiToken( + name=name, + token_hash=token_hash, + benutzer_id=dienstkonto.id, + aktiv=True, + erstellt_am=datetime.now(timezone.utc), + erstellt_von_benutzer_id=erstellt_von_benutzer_id, + ) + db.add(token) + await db.flush() + return token, klartext + + +def secrets_urlsafe_unbenutzbar() -> str: + """Eigene, benannte Hilfsfunktion statt `secrets.token_urlsafe()` inline - + macht an der Aufrufstelle klar, dass dieses "Passwort" bewusst nie + irgendwo eingegeben werden soll (Dienstkonto authentifiziert sich nur + über das API-Token, nie über Login/Passwort).""" + return secrets.token_urlsafe(32) + + +async def hole_benutzer_fuer_token(db: AsyncSession, klartext_token: str) -> Benutzer | None: + """Zentrale Auth-Fallback-Funktion (app/api/deps.py): löst ein + API-Token-Klartext auf den zugehörigen Dienstkonto-Benutzer auf, sofern + Token UND Dienstkonto aktiv sind. Aktualisiert `letzte_verwendung_am` + bei jeder erfolgreichen Verwendung (Audit/Aufräum-Hinweis, welche Tokens + noch genutzt werden).""" + token_hash = hashe_api_token(klartext_token) + result = await db.execute(select(ApiToken).where(ApiToken.token_hash == token_hash)) + token = result.scalar_one_or_none() + if token is None or not token.aktiv: + return None + benutzer = await db.get(Benutzer, token.benutzer_id) + if benutzer is None or not benutzer.aktiv: + return None + token.letzte_verwendung_am = datetime.now(timezone.utc) + await db.flush() + return benutzer + + +async def liste_tokens(db: AsyncSession) -> list[ApiToken]: + result = await db.execute(select(ApiToken).order_by(ApiToken.erstellt_am.desc())) + return list(result.scalars().all()) + + +async def rollen_fuer_token(db: AsyncSession, token: ApiToken) -> list[RolleTyp]: + result = await db.execute(select(BenutzerRolle.rolle).where(BenutzerRolle.benutzer_id == token.benutzer_id)) + return [row[0] for row in result.all()] + + +async def setze_aktiv(db: AsyncSession, token_id: uuid.UUID, aktiv: bool) -> ApiToken: + token = await db.get(ApiToken, token_id) + if token is None: + raise ApiTokenNichtGefundenError + token.aktiv = aktiv + await db.flush() + return token diff --git a/backend/tests/test_api_token.py b/backend/tests/test_api_token.py new file mode 100644 index 0000000..be57a82 --- /dev/null +++ b/backend/tests/test_api_token.py @@ -0,0 +1,134 @@ +import pytest + +from tests.conftest import auth_header, login + + +@pytest.mark.asyncio +async def test_api_token_anlegen_und_verwenden(client, admin_user): + """FOUND-016: Token wird einmalig im Klartext zurückgegeben und + funktioniert danach als Bearer-Token mit den zugewiesenen Rollen.""" + admin_token = await login(client, "admin1") + + erstellt = await client.post( + "/api/v1/api-tokens", + json={"name": "CI-Zugriff", "rollen": ["mitarbeiter"]}, + headers=auth_header(admin_token), + ) + assert erstellt.status_code == 201 + body = erstellt.json() + assert body["token"].startswith("mabea_pat_") + assert body["rollen"] == ["mitarbeiter"] + klartext = body["token"] + + # Funktioniert wie ein normaler Bearer-Token gegen einen mitarbeiter+-Endpunkt. + me = await client.get("/api/v1/objekte", headers=auth_header(klartext)) + assert me.status_code == 200 + + +@pytest.mark.asyncio +async def test_api_token_erscheint_nicht_in_benutzer_liste(client, admin_user): + admin_token = await login(client, "admin1") + await client.post( + "/api/v1/api-tokens", json={"name": "Verstecktes Token", "rollen": []}, headers=auth_header(admin_token) + ) + liste = await client.get("/api/v1/benutzer", headers=auth_header(admin_token)) + namen = {b["name"] for b in liste.json()} + assert not any("Verstecktes Token" in n for n in namen) + + +@pytest.mark.asyncio +async def test_api_token_deaktivieren_sperrt_zugriff(client, admin_user): + admin_token = await login(client, "admin1") + erstellt = await client.post( + "/api/v1/api-tokens", + json={"name": "Wird deaktiviert", "rollen": ["administration"]}, + headers=auth_header(admin_token), + ) + token_id = erstellt.json()["id"] + klartext = erstellt.json()["token"] + + vorher = await client.get("/api/v1/objekte", headers=auth_header(klartext)) + assert vorher.status_code == 200 + + deaktiviert = await client.patch( + f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token) + ) + assert deaktiviert.status_code == 200 + assert deaktiviert.json()["aktiv"] is False + + nachher = await client.get("/api/v1/objekte", headers=auth_header(klartext)) + assert nachher.status_code == 401 + + +@pytest.mark.asyncio +async def test_api_token_reaktivieren_erlaubt_zugriff_wieder(client, admin_user): + admin_token = await login(client, "admin1") + erstellt = await client.post( + "/api/v1/api-tokens", + json={"name": "Reaktivierbar", "rollen": ["mitarbeiter"]}, + headers=auth_header(admin_token), + ) + token_id = erstellt.json()["id"] + klartext = erstellt.json()["token"] + + await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": False}, headers=auth_header(admin_token)) + gesperrt = await client.get("/api/v1/objekte", headers=auth_header(klartext)) + assert gesperrt.status_code == 401 + + await client.patch(f"/api/v1/api-tokens/{token_id}", json={"aktiv": True}, headers=auth_header(admin_token)) + wieder_erlaubt = await client.get("/api/v1/objekte", headers=auth_header(klartext)) + assert wieder_erlaubt.status_code == 200 + + +@pytest.mark.asyncio +async def test_ungueltiges_api_token_wird_abgelehnt(client): + response = await client.get("/api/v1/objekte", headers=auth_header("mabea_pat_frei-erfunden")) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_api_token_rollen_werden_respektiert(client, admin_user): + """Token mit mitarbeiter-Rolle darf keine admin-only-Aktion (Objekttyp + anlegen) - dieselbe require_roles()-Prüfung wie bei echten Nutzern.""" + admin_token = await login(client, "admin1") + erstellt = await client.post( + "/api/v1/api-tokens", + json={"name": "Nur Mitarbeiter", "rollen": ["mitarbeiter"]}, + headers=auth_header(admin_token), + ) + klartext = erstellt.json()["token"] + + verboten = await client.post( + "/api/v1/objekttypen", + json={"bereich_id": 1, "name": "Test", "typ": "lager"}, + headers=auth_header(klartext), + ) + assert verboten.status_code == 403 + + +@pytest.mark.asyncio +async def test_api_token_liste_und_verwaltung_nur_admin(client, mitarbeiter_user): + token = await login(client, "mitarbeiter1") + liste = await client.get("/api/v1/api-tokens", headers=auth_header(token)) + assert liste.status_code == 403 + + erstellen = await client.post( + "/api/v1/api-tokens", json={"name": "x", "rollen": []}, headers=auth_header(token) + ) + assert erstellen.status_code == 403 + + +@pytest.mark.asyncio +async def test_api_token_liste_zeigt_rollen_und_status(client, admin_user): + admin_token = await login(client, "admin1") + await client.post( + "/api/v1/api-tokens", + json={"name": "Sichtbar", "rollen": ["materialverantwortlicher"]}, + headers=auth_header(admin_token), + ) + liste = await client.get("/api/v1/api-tokens", headers=auth_header(admin_token)) + assert liste.status_code == 200 + eintrag = next(t for t in liste.json() if t["name"] == "Sichtbar") + assert eintrag["rollen"] == ["materialverantwortlicher"] + assert eintrag["aktiv"] is True + assert eintrag["letzte_verwendung_am"] is None diff --git a/frontend/DEVLOG.md b/frontend/DEVLOG.md index 5c8bd16..9d7aa7f 100644 --- a/frontend/DEVLOG.md +++ b/frontend/DEVLOG.md @@ -1348,3 +1348,14 @@ Keine Commits in dieser Session. - frontend/src/pages/admin/ObjekttypSection.tsx | 19 ++++++++++++++++++ --- +## 2026-09-15 23:03 – 23:04 (1m) +**Beschreibung:** Claude Code Session +**Projekt:** asb-material + +### Commits +Keine Commits in dieser Session. + +### Geänderte Dateien +- backend/app/api/v1/endpoints/lager.py | 1 + + +--- diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 865824c..1d8b97c 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -519,6 +519,23 @@ export interface Me { export type Rolle = "mitarbeiter" | "materialverantwortlicher" | "leitungsverantwortlicher" | "administration"; +// FOUND-016 +export interface ApiToken { + id: string; + name: string; + aktiv: boolean; + erstellt_am: string; + letzte_verwendung_am: string | null; + rollen: Rolle[]; +} + +export interface ApiTokenCreateResponse { + id: string; + name: string; + token: string; + rollen: Rolle[]; +} + export interface Benutzer { id: number; name: string; diff --git a/frontend/src/pages/AdminPage.tsx b/frontend/src/pages/AdminPage.tsx index ce8c6ed..fbd5ddd 100644 --- a/frontend/src/pages/AdminPage.tsx +++ b/frontend/src/pages/AdminPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { apiRequest } from "../api/client"; +import { ApiTokenSection } from "./admin/ApiTokenSection"; import { AusgabeSection } from "./admin/AusgabeSection"; import { useAuth } from "../auth/AuthContext"; import type { Benutzer, Bereich, Beladungsvorlage, Einheit, Einsatzfunktion, Fach, Kategorie, Material, Nummernschema, Objekt, Objekttyp, Person, Qualifikationstyp, Standort } from "../api/types"; @@ -59,7 +60,8 @@ type Tab = | "sollmengenGruppen" | "inventur" | "fehlerTracking" - | "pruefarten"; + | "pruefarten" + | "apiTokens"; type Gruppe = "stammdaten" | "personalUndRechte" | "betrieb" | "wartungUndPruefung" | "einsaetzeUndMeldungen" | "system"; @@ -158,6 +160,7 @@ const TABS: { rollen: ["administration", "materialverantwortlicher", "leitungsverantwortlicher"], }, { key: "fehlerTracking", label: "Fehler-Tracking", gruppe: "system", rollen: ["administration"] }, + { key: "apiTokens", label: "API-Tokens", gruppe: "system", rollen: ["administration"] }, ]; /** @@ -367,6 +370,7 @@ export function AdminPage() { )} {aktiverTab === "inventur" && } {aktiverTab === "fehlerTracking" && } + {aktiverTab === "apiTokens" && } {aktiverTab === "pruefarten" && } {aktiverTab === "verleih" && } {aktiverTab === "eskalation" && } diff --git a/frontend/src/pages/admin/ApiTokenSection.tsx b/frontend/src/pages/admin/ApiTokenSection.tsx new file mode 100644 index 0000000..592886a --- /dev/null +++ b/frontend/src/pages/admin/ApiTokenSection.tsx @@ -0,0 +1,178 @@ +import { useEffect, useState } from "react"; + +import { apiRequest } from "../../api/client"; +import type { ApiToken, ApiTokenCreateResponse, Rolle } from "../../api/types"; + +interface Props { + onFehler: (text: string) => void; +} + +const ALLE_ROLLEN: Rolle[] = [ + "mitarbeiter", + "materialverantwortlicher", + "leitungsverantwortlicher", + "administration", +]; + +const ROLLE_LABEL: Record = { + mitarbeiter: "Mitarbeiter", + materialverantwortlicher: "Materialverantwortlicher", + leitungsverantwortlicher: "Leitungsverantwortlicher", + administration: "Administration", +}; + +// FOUND-016: API-Token für Entwicklungs-/Tooling-Zugriff, kein Nutzer-Login. +// Klartext wird nur einmalig bei der Erzeugung zurückgegeben (wie ein +// Passwort) - danach ist er nirgends mehr abrufbar, nur noch der Hash liegt +// in der DB. +export function ApiTokenSection({ onFehler }: Props) { + const [tokens, setTokens] = useState([]); + const [laedt, setLaedt] = useState(true); + const [name, setName] = useState(""); + const [rollen, setRollen] = useState(["mitarbeiter"]); + const [wirdAngelegt, setWirdAngelegt] = useState(false); + const [neuesToken, setNeuesToken] = useState(null); + const [kopiertHinweis, setKopiertHinweis] = useState(false); + + async function laden() { + setLaedt(true); + try { + setTokens(await apiRequest("/api-tokens")); + } catch { + onFehler("API-Tokens konnten nicht geladen werden."); + } finally { + setLaedt(false); + } + } + + useEffect(() => { + laden(); + }, []); + + function rolleUmschalten(rolle: Rolle) { + setRollen((bisherig) => (bisherig.includes(rolle) ? bisherig.filter((r) => r !== rolle) : [...bisherig, rolle])); + } + + async function anlegen() { + if (!name.trim()) { + onFehler("Name eingeben."); + return; + } + setWirdAngelegt(true); + try { + const antwort = await apiRequest("/api-tokens", { + method: "POST", + body: { name, rollen }, + }); + setNeuesToken(antwort); + setKopiertHinweis(false); + setName(""); + setRollen(["mitarbeiter"]); + await laden(); + } catch { + onFehler("API-Token konnte nicht angelegt werden."); + } finally { + setWirdAngelegt(false); + } + } + + async function statusAendern(token: ApiToken, aktiv: boolean) { + try { + await apiRequest(`/api-tokens/${token.id}`, { method: "PATCH", body: { aktiv } }); + await laden(); + } catch { + onFehler("Status konnte nicht geändert werden."); + } + } + + async function kopieren(text: string) { + try { + await navigator.clipboard.writeText(text); + setKopiertHinweis(true); + } catch { + // Zwischenablage evtl. nicht verfügbar (z.B. kein HTTPS-Kontext) - + // Klartext bleibt trotzdem im Textfeld markierbar, kein Blocker. + } + } + + return ( +
+

API-Tokens

+

+ Für Entwicklungs-/Tooling-Zugriff auf die API ohne Nutzer-Login. Ein Token erbt die hier + ausgewählten Rollen, genau wie ein Benutzer. +

+ + {neuesToken && ( +
+

Token „{neuesToken.name}" erstellt

+

+ Dieser Wert wird nur jetzt angezeigt und ist danach nicht mehr abrufbar. Jetzt kopieren! +

+
+ + +
+ +
+ )} + +
+

Token anlegen

+
+ setName(e.target.value)} + /> + {ALLE_ROLLEN.map((rolle) => ( + + ))} + +
+
+ +
+

Bestehende Tokens

+ {laedt &&

Lade…

} + {!laedt && tokens.length === 0 &&

Keine API-Tokens angelegt.

} + {!laedt && tokens.length > 0 && ( +
    + {tokens.map((token) => ( +
  • + + {token.name} — {token.rollen.map((r) => ROLLE_LABEL[r]).join(", ") || "keine Rollen"} +
    + + Erstellt: {new Date(token.erstellt_am).toLocaleString("de-DE")} + {token.letzte_verwendung_am + ? ` · Zuletzt verwendet: ${new Date(token.letzte_verwendung_am).toLocaleString("de-DE")}` + : " · Noch nicht verwendet"} + +
    +
    + + {token.aktiv ? "Aktiv" : "Deaktiviert"} + + +
    +
  • + ))} +
+ )} +
+
+ ); +}