From faa9d6a25d31ac4dab2042ec84ab99b77182d999 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 22 Jul 2026 16:36:17 +0200 Subject: [PATCH] Langlebige API-Tokens fuer externe Zugriffe (Home Assistant) Die Einkaufsliste war ueber /shopping-list und /shopping-list/groups schon abrufbar, aber nur mit einem Login-JWT, das nach 7 Tagen ablaeuft - fuer HA unpraktisch. - Neue Tabelle api_tokens (Name, SHA-256-Hash, Ersteller, zuletzt genutzt). Der Klartext wird nur einmal beim Anlegen zurueckgegeben. - get_current_user akzeptiert jetzt JWT ODER API-Token im Authorization-Header; bei Nutzung wird last_used_at fortgeschrieben. - Endpunkte /api-tokens (Admin): auflisten, erzeugen, widerrufen. - Einstellungsseite: Tokens verwalten inkl. einmaliger Anzeige und einem aufklappbaren Konfigurationsbeispiel fuer Home Assistant. Co-Authored-By: Claude Opus 4.8 --- backend/app/deps.py | 29 ++++++-- backend/app/main.py | 2 + backend/app/models.py | 19 ++++++ backend/app/routers/api_tokens.py | 55 +++++++++++++++ backend/app/schemas.py | 17 +++++ backend/app/security.py | 12 ++++ web/src/api.js | 5 ++ web/src/pages/Settings.jsx | 108 ++++++++++++++++++++++++++++++ 8 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 backend/app/routers/api_tokens.py diff --git a/backend/app/deps.py b/backend/app/deps.py index 069aec6..83bb121 100644 --- a/backend/app/deps.py +++ b/backend/app/deps.py @@ -2,9 +2,11 @@ from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from sqlalchemy.orm import Session +from datetime import datetime, timezone + from .database import get_db -from .models import Role, User -from .security import decode_access_token +from .models import ApiToken, Role, User +from .security import decode_access_token, hash_api_token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="auth/login") @@ -18,15 +20,28 @@ _credentials_exc = HTTPException( def get_current_user( token: str = Depends(oauth2_scheme), db: Session = Depends(get_db) ) -> User: + """Akzeptiert ein JWT aus dem Login oder ein langlebiges API-Token.""" payload = decode_access_token(token) - if payload is None: + if payload is not None: + username = payload.get("sub") + if not username: + raise _credentials_exc + user = db.query(User).filter(User.username == username).first() + if user is None: + raise _credentials_exc + return user + + # Kein gültiges JWT – als API-Token prüfen (z.B. Home Assistant). + entry = ( + db.query(ApiToken).filter(ApiToken.token_hash == hash_api_token(token)).first() + ) + if entry is None: raise _credentials_exc - username = payload.get("sub") - if not username: - raise _credentials_exc - user = db.query(User).filter(User.username == username).first() + user = db.get(User, entry.user_id) if entry.user_id else None if user is None: raise _credentials_exc + entry.last_used_at = datetime.now(timezone.utc) + db.commit() return user diff --git a/backend/app/main.py b/backend/app/main.py index b35a46b..27d2ee2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -7,6 +7,7 @@ from sqlalchemy import text from .config import get_settings from .database import Base, SessionLocal, engine from .routers import ( + api_tokens, auth, groups, locations, @@ -91,4 +92,5 @@ app.include_router(groups.router) app.include_router(units.router) app.include_router(views.router) app.include_router(transfer.router) +app.include_router(api_tokens.router) app.include_router(settings_router.router) diff --git a/backend/app/models.py b/backend/app/models.py index c8f7793..8705a8a 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -146,6 +146,25 @@ class Product(Base): ) +class ApiToken(Base): + """Langlebiges Token für externe Zugriffe (z.B. Home Assistant). + + Gespeichert wird nur der SHA-256-Hash; der Klartext wird einmalig beim + Anlegen angezeigt. + """ + + __tablename__ = "api_tokens" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + name: Mapped[str] = mapped_column(String(120), nullable=False) + token_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + user_id: Mapped[int | None] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=True + ) + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_now) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + class Barcode(Base): """Zusätzliche EAN-Codes für ein Produkt ODER eine Gruppe. diff --git a/backend/app/routers/api_tokens.py b/backend/app/routers/api_tokens.py new file mode 100644 index 0000000..2ad36d2 --- /dev/null +++ b/backend/app/routers/api_tokens.py @@ -0,0 +1,55 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.orm import Session + +from ..database import get_db +from ..deps import require_admin +from ..models import ApiToken, User +from ..schemas import ApiTokenCreate, ApiTokenCreated, ApiTokenOut +from ..security import generate_api_token, hash_api_token + +router = APIRouter(prefix="/api-tokens", tags=["api-tokens"]) + + +@router.get("", response_model=list[ApiTokenOut]) +def list_tokens( + db: Session = Depends(get_db), _: User = Depends(require_admin) +) -> list[ApiToken]: + return db.query(ApiToken).order_by(ApiToken.id).all() + + +@router.post("", response_model=ApiTokenCreated, status_code=status.HTTP_201_CREATED) +def create_token( + payload: ApiTokenCreate, + db: Session = Depends(get_db), + admin: User = Depends(require_admin), +) -> ApiTokenCreated: + """Erzeugt ein langlebiges Token. Der Klartext wird nur hier zurückgegeben.""" + plain = generate_api_token() + entry = ApiToken( + name=payload.name.strip(), + token_hash=hash_api_token(plain), + user_id=admin.id, + ) + db.add(entry) + db.commit() + db.refresh(entry) + return ApiTokenCreated( + id=entry.id, + name=entry.name, + created_at=entry.created_at, + last_used_at=entry.last_used_at, + token=plain, + ) + + +@router.delete("/{token_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_token( + token_id: int, + db: Session = Depends(get_db), + _: User = Depends(require_admin), +) -> None: + entry = db.get(ApiToken, token_id) + if entry is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Token nicht gefunden") + db.delete(entry) + db.commit() diff --git a/backend/app/schemas.py b/backend/app/schemas.py index b0d7453..509dc7a 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -23,6 +23,23 @@ class UnitCreate(BaseModel): factor: float = Field(gt=0) +# ---- API-Tokens (externe Zugriffe, z.B. Home Assistant) ---- +class ApiTokenOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: int + name: str + created_at: datetime + last_used_at: datetime | None = None + + +class ApiTokenCreate(BaseModel): + name: str = Field(min_length=1, max_length=120) + + +class ApiTokenCreated(ApiTokenOut): + token: str # nur einmalig beim Anlegen + + # ---- Barcodes ---- class BarcodeOut(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/security.py b/backend/app/security.py index 4fb79b1..85de286 100644 --- a/backend/app/security.py +++ b/backend/app/security.py @@ -1,3 +1,5 @@ +import hashlib +import secrets from datetime import datetime, timedelta, timezone import bcrypt @@ -30,6 +32,16 @@ def create_access_token(subject: str, role: str) -> str: return jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) +def generate_api_token() -> str: + """Erzeugt ein langlebiges Token für externe Zugriffe.""" + return secrets.token_urlsafe(32) + + +def hash_api_token(token: str) -> str: + """SHA-256 – schnell genug, um bei jeder Anfrage geprüft zu werden.""" + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + def decode_access_token(token: str) -> dict | None: try: return jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm]) diff --git a/web/src/api.js b/web/src/api.js index 4367268..ca85a5e 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -158,6 +158,11 @@ export const api = { updateUser: (id, body) => request(`/users/${id}`, { method: "PATCH", body }), deleteUser: (id) => request(`/users/${id}`, { method: "DELETE" }), + // API-Tokens (externe Zugriffe) + listApiTokens: () => request("/api-tokens"), + createApiToken: (body) => request("/api-tokens", { method: "POST", body }), + deleteApiToken: (id) => request(`/api-tokens/${id}`, { method: "DELETE" }), + // Einstellungen listSettings: () => request("/settings"), setSetting: (key, value) => diff --git a/web/src/pages/Settings.jsx b/web/src/pages/Settings.jsx index 0ac7bae..a7ccef7 100644 --- a/web/src/pages/Settings.jsx +++ b/web/src/pages/Settings.jsx @@ -82,6 +82,114 @@ export default function Settings() { + + ); } + +/** Langlebige Tokens für externe Zugriffe (Home Assistant, Skripte). */ +function ApiTokensCard() { + const [tokens, setTokens] = useState([]); + const [name, setName] = useState(""); + const [fresh, setFresh] = useState(null); + const [error, setError] = useState(null); + + async function load() { + try { + setTokens(await api.listApiTokens()); + } catch (err) { + setError(err.message); + } + } + + useEffect(() => { load(); }, []); + + async function create(e) { + e.preventDefault(); + setError(null); + try { + const created = await api.createApiToken({ name }); + setFresh(created.token); + setName(""); + load(); + } catch (err) { + setError(err.message); + } + } + + async function remove(token) { + if (!confirm(`Token "${token.name}" widerrufen? Zugriffe damit funktionieren sofort nicht mehr.`)) return; + try { + await api.deleteApiToken(token.id); + load(); + } catch (err) { + setError(err.message); + } + } + + return ( +
+

API-Zugriff (z.B. Home Assistant)

+ {error &&
{error}
} + +

+ Mit einem Token lässt sich die Einkaufsliste dauerhaft abfragen, ohne dass eine + Anmeldung abläuft. Der Token wird nur einmal angezeigt – danach ist nur noch + sein Fingerabdruck gespeichert. +

+ + {fresh && ( +
+ + + Neuer Token – bitte jetzt kopieren: +
{fresh}
+
+
+ )} + +
    + {tokens.map((t) => ( +
  • + + {t.name} + + {" "}· angelegt {new Date(t.created_at).toLocaleDateString("de-DE")} + {t.last_used_at + ? ` · zuletzt genutzt ${new Date(t.last_used_at).toLocaleDateString("de-DE")}` + : " · noch nicht genutzt"} + + + +
  • + ))} + {tokens.length === 0 &&
  • Noch keine Tokens.
  • } +
+ +
+ setName(e.target.value)} required /> + +
+ +
+ Beispiel für Home Assistant +
{`# configuration.yaml
+rest:
+  - resource: http://DEIN-SERVER:8080/api/shopping-list
+    scan_interval: 900
+    headers:
+      Authorization: "Bearer DEIN_TOKEN"
+    sensor:
+      - name: "Einkaufsliste Anzahl"
+        value_template: "{{ value_json | length }}"
+        json_attributes_path: "$"
+      - name: "Einkaufsliste"
+        value_template: "{{ value_json | map(attribute='name') | join(', ') }}"`}
+
+
+ ); +}