Authentifizierung¶
Kamerplanter unterstützt zwei Authentifizierungsmethoden: Lokale Konten (E-Mail + Passwort) und föderierte Konten (OAuth 2.0 / OIDC über Google, GitHub, Apple oder generische Provider). Für maschinelle Integrationen (Home Assistant, CI/CD) stehen API-Keys zur Verfügung.
Light-Modus
Im Light-Modus (KAMERPLANTER_MODE=light) ist keine Authentifizierung erforderlich. Alle Auth-Endpunkte unter /auth/... sind in diesem Modus deaktiviert. Dieser Abschnitt gilt nur für den Full-Modus.
Token-Modell¶
| Token | Gültigkeitsdauer | Transport | Erneuerung |
|---|---|---|---|
| Access Token (JWT) | 15 Minuten | Authorization: Bearer <token> | Via Refresh-Token |
| Refresh Token | 30 Tage | HttpOnly Cookie kp_refresh | Rotation bei jeder Erneuerung |
Das Access Token ist ein signiertes JWT (HS256). Es enthält die Nutzer-ID und läuft nach 15 Minuten ab. Es wird im Arbeitsspeicher der Client-Anwendung gehalten — niemals im localStorage.
Das Refresh Token wird als HttpOnly-Cookie gesetzt. Es ist für JavaScript nicht lesbar und schützt damit vor XSS-Angriffen. Bei jedem Aufruf von /auth/refresh wird das Token rotiert — das alte Token wird ungültig, ein neues ausgestellt.
Alle Refresh-Tokens, die aus einer Anmeldung durch Rotation entstehen, bilden eine Token-Familie. Wird ein bereits rotiertes Token erneut vorgelegt, gilt das als Wiederverwendung eines gestohlenen Tokens: Der Server beendet die ganze Familie (diese eine Anmeldung, andere Geräte bleiben angemeldet) und antwortet mit 401 Unauthorized. Ausnahme ist das Gnadenfenster von 60 Sekunden für den Cookie-Weg: Erneuern zwei Tabs desselben Browsers gleichzeitig, erhält der zweite ein neues Access Token, aber keinen neuen Cookie — den hat der erste Tab bereits gesetzt. Das Gnadenfenster gilt nur für denselben Client (gleicher User-Agent).
Ein Access Token endet nicht erst nach seinen 15 Minuten: Jeder Widerruf — Abmelden, alle Sitzungen abmelden, eine einzelne Sitzung beenden, Passwortänderung oder -Reset, die Deaktivierung des Kontos, eine erkannte Token-Wiederverwendung — macht alle bis dahin ausgestellten Access Tokens des Kontos sofort ungültig (401 Unauthorized). Geräte, deren Sitzung weiterläuft, holen sich mit ihrem Refresh Token unbemerkt ein neues.
Registrierung¶
POST /api/v1/auth/register
Content-Type: application/json
{
"email": "gartner@example.com",
"password": "sicheres-passwort-2026",
"display_name": "Lena Gärtner"
}
Anforderungen an das Passwort: Mindestens 10, maximal 128 Zeichen.
Antwort (201 Created):
{
"key": "usr_abc123",
"email": "gartner@example.com",
"display_name": "Lena Gärtner",
"email_verified": false,
"is_active": true,
"avatar_url": null,
"locale": "de",
"timezone": "Europe/Berlin",
"last_login_at": null,
"created_at": "2026-03-17T10:00:00Z"
}
Registrierungsmodus (REGISTRATION_MODE). Je nach Einstellung des Betreibers antwortet die Registrierung mit 403 (REGISTRATION_NOT_ALLOWED): bei closed immer, bei invite_only ohne gültiges Einladungs-Token, mit REGISTRATION_ALLOWED_DOMAINS für eine Adresse außerhalb der Liste. Das optionale Feld invitation_token trägt das Token einer offenen E-Mail-Einladung für genau diese Adresse; es ist die Ausnahme von invite_only und von der Domain-Liste (nicht von closed). Die Antwort ist für vergebene und freie Adressen gleich. Welcher Modus gilt, liest ein Client aus GET /api/v1/mode (registration.mode, registration.domain_restricted). Eine erste Anmeldung über OIDC, die der Modus nicht zulässt, leitet auf /auth/callback?error=registration_not_allowed zurück.
Nach der Registrierung wird ein persönlicher Mandant automatisch angelegt. Ist die E-Mail-Verifikation aktiv (REQUIRE_EMAIL_VERIFICATION=true, der Default), muss die E-Mail-Adresse vor dem ersten Login bestätigt werden. Eine Installation ohne ausgehenden Mailversand setzt die Variable ausdrücklich auf false.
E-Mail-Verifizierung¶
Ein Link gilt 24 Stunden und nur einmal. Ein falscher, abgelaufener oder schon benutzter Token ergibt 401 Unauthorized (INVALID_TOKEN).
Bestätigungslink erneut anfordern¶
Für ein Konto, dessen erster Link verloren gegangen oder abgelaufen ist. Die Anmeldung mit dem richtigen Passwort verschickt einen neuen Link selbst (siehe Neuer Link bei der abgelehnten Anmeldung); dieser Endpunkt ist der Weg ohne Passwort — etwa von der Fehlerseite eines abgelaufenen Links — und der Rückfall, wenn über die Anmeldung nichts ankommt.
POST /api/v1/auth/resend-verification
Content-Type: application/json
{
"email": "gartner@example.com"
}
Antwort 202 Accepted, immer mit demselben Body:
{
"message": "If this address belongs to an account that still needs verification, a new verification email is on its way."
}
Die Antwort ist für jede Adresse gleich — unbekannt, unbestätigt, schon bestätigt, Service-Account oder ein Konto ohne lokales Passwort. Auch die Arbeit vor der Antwort ist für alle gleich; Kontosuche, neuer Token und Versand laufen erst danach. So verrät der Endpunkt weder über den Inhalt noch über die Antwortzeit, ob es ein Konto gibt.
Eine E-Mail geht nur raus, wenn REQUIRE_EMAIL_VERIFICATION aktiv ist und das Konto aktiv, unbestätigt, interaktiv (kein Service-Account) und mit lokalem Passwort angelegt ist. Jeder Versand schreibt einen neuen Token mit 24 Stunden Gültigkeit; alle vorher verschickten Links werden damit ungültig.
| Grenze | Wert | Antwort bei Überschreitung |
|---|---|---|
| Je Client-IP | RATE_LIMIT_RESEND_VERIFICATION, Default 10/hour | 429 Too Many Requests |
| Je Adresse | 3 Anfragen; das Budget füllt sich wieder auf, sobald eine Stunde lang keine Anfrage für die Adresse kam | Unverändert 202 — es geht nur keine E-Mail mehr raus |
Die Grenze je Adresse zählt jede eingegebene Adresse gleich, ob es ein Konto gibt oder nicht. Darum antwortet sie stumm: ein 429 würde nichts verraten, aber ein Budget, das nur echte Konten zählt, würde es.
Neuer Link bei der abgelehnten Anmeldung¶
Meldet sich jemand mit dem richtigen Passwort an einem unbestätigten Konto an, antwortet POST /api/v1/auth/login wie bisher 403 Forbidden mit error_code EMAIL_NOT_VERIFIED — und verschickt nach der Antwort selbst einen neuen Bestätigungslink an die gespeicherte Adresse des Kontos (nicht an die Schreibweise, die eingetippt wurde). Für den Link gelten dieselben Regeln wie oben: neuer Token, 24 Stunden gültig, alle früheren Links werden ungültig; nur an aktive, interaktive Konten mit lokalem Passwort und nur mit REQUIRE_EMAIL_VERIFICATION.
Diese Ablehnung erreicht nur, wer das Passwort kennt. Deshalb hat dieser Weg ein eigenes Budget je Konto, das anonyme Anfragen an resend-verification nicht aufbrauchen können — wer nur die Adresse kennt, kann dem Kontoinhaber den neuen Link so nicht mehr vorenthalten.
| Grenze | Wert | Antwort bei Überschreitung |
|---|---|---|
| Je Client-IP | RATE_LIMIT_AUTH (gilt für den ganzen Login) | 429 Too Many Requests |
| Je Konto | 3 Links; das Budget füllt sich wieder auf, sobald eine Stunde lang keine Anmeldung mit EMAIL_NOT_VERIFIED abgelehnt wurde — jede solche Ablehnung zählt, auch über dem Budget, und verlängert das Fenster | Unverändert 403 EMAIL_NOT_VERIFIED — es geht nur keine E-Mail raus |
Die Antwort sagt nicht, ob ein Link verschickt wurde: Status, Body und Header sind dieselben, ob eine E-Mail rausging, das Budget aufgebraucht war oder die Zustellung scheiterte. Ein falsches Passwort antwortet unverändert 401 Unauthorized und verschickt nichts. Clients stellen den neuen Link deshalb nur in Aussicht und bieten resend-verification als Rückfall an.
Budget bei Valkey-Ausfall
Das Budget liegt in Valkey, damit alle Replikas gemeinsam zählen. Ist Valkey nicht erreichbar, zählt jeder Prozess für sich weiter — nie ohne Grenze. Diese In-Process-Stufe hat eine feste Kapazität: Wird sie überschritten, verdrängt sie den ältesten Eintrag und mit ihm dessen verbrauchtes Budget. Hier zählen allerdings Konten, nicht frei gewählte Adressen; sie zu füllen, braucht die Passwörter entsprechend vieler Konten. Die Budgets von resend-verification und Passwort-Reset, die frei gewählte Adressen zählen, verdrängen dagegen nie (siehe Passwort zurücksetzen).
Login¶
POST /api/v1/auth/login
Content-Type: application/json
{
"email": "gartner@example.com",
"password": "sicheres-passwort-2026",
"remember_me": false
}
Antwort (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 900
}
Gleichzeitig setzt der Server den HttpOnly-Cookie kp_refresh. Der Wert von expires_in ist in Sekunden angegeben (900 = 15 Minuten).
remember_me: true verlängert die Lebensdauer des Refresh-Cookies auf 30 Tage. Andernfalls ist der Cookie ein Session-Cookie (läuft beim Schließen des Browsers ab).
Demo-Konto¶
In Entwicklungs- und Testumgebungen steht ein vorkonfiguriertes Demo-Konto bereit:
Produktionsbetrieb
Das Demo-Konto und die Demo-Daten dürfen in Produktionsumgebungen nicht aktiv sein. Entfernen Sie den Seed-Schritt aus der Deployment-Konfiguration.
Access Token verwenden¶
Jede API-Anfrage, die Authentifizierung erfordert, benötigt das Access Token als Bearer-Token im Authorization-Header:
GET /api/v1/t/mein-garten/plant-instances/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Token erneuern¶
Das Access Token läuft nach 15 Minuten ab. Zur Erneuerung wird der Refresh-Cookie automatisch mitgesendet (Browser setzt den Cookie bei Anfragen an /api/v1/auth):
CSRF-Schutz
Token-mutierende Endpunkte (/refresh, /logout, /logout-all) erfordern den Header X-CSRF-Token. Das CSRF-Token wird als reguläres Cookie kp_csrf gesetzt und kann von JavaScript gelesen werden. Es wird bei Login und Refresh erneuert.
Antwort (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 900
}
Das alte Refresh-Token wird ungültig. Der neue Refresh-Cookie wird automatisch gesetzt. Kommt dasselbe alte Token innerhalb von 60 Sekunden aus demselben Browser noch einmal (zweiter Tab), antwortet der Server mit einem Access Token und setzt keinen Cookie; später oder von einem anderen Client gilt es als Wiederverwendung und beendet die Sitzung (siehe Token-Modell).
Logout¶
Aktuellen Browser abmelden¶
Invalidiert die Sitzung (das aktuelle Refresh-Token und alle aus ihr rotierten) und löscht den Cookie. Access Tokens des Kontos werden sofort ungültig; andere angemeldete Geräte erneuern ihres unbemerkt.
Die Web-Oberfläche entfernt beim Abmelden außerdem alles, was sie im Browser über dein Konto hält: geladene Daten, den aktiven Garten und die Browser-Push-Registrierung dieses Geräts. Auf einem gemeinsam genutzten Gerät sieht die nächste Person nichts davon und erhält keine Push-Nachrichten für dein Konto.
Alle Sitzungen abmelden¶
Invalidiert alle Refresh-Tokens des Nutzers auf allen Geräten — und alle Access Tokens: Auch ein noch nicht abgelaufenes Access Token wird ab der nächsten Anfrage mit 401 Unauthorized abgelehnt.
Passwort zurücksetzen¶
Zurücksetzungs-E-Mail anfordern¶
POST /api/v1/auth/password-reset/request
Content-Type: application/json
{
"email": "gartner@example.com"
}
Aus Sicherheitsgründen gibt dieser Endpunkt immer dieselbe Erfolgsantwort zurück, unabhängig davon, ob die E-Mail-Adresse existiert.
| Grenze | Wert | Antwort bei Überschreitung |
|---|---|---|
| Je Client-IP | RATE_LIMIT_AUTH, Default 20/minute | 429 Too Many Requests |
| Je Adresse und Client-IP | 3 Anfragen; das Budget füllt sich wieder auf, sobald von dieser IP eine Stunde lang keine Anfrage für die Adresse kam | Unverändert 200 mit demselben Body — es geht nur kein Link mehr raus |
| Je Adresse insgesamt | 10 Links über alle Quellen; das Budget füllt sich wieder auf, sobald eine Stunde lang kein Link für die Adresse verschickt wurde | Unverändert 200 mit demselben Body — es geht nur kein Link mehr raus |
Die Grenzen je Adresse funktionieren wie beim Bestätigungslink, haben aber ihr eigenes Budget: Sie zählt jede eingegebene Adresse gleich, ob es ein Konto gibt oder nicht, und antwortet deshalb stumm. Groß-/Kleinschreibung und Leerzeichen um die Adresse spielen keine Rolle — Gartner@Example.com und gartner@example.com teilen sich ein Budget. Der Link geht immer an die Adresse, wie sie im Konto gespeichert ist, nicht an die eingegebene Schreibweise. Hast du drei Links angefordert und keiner kam an, warte eine Stunde, ohne erneut anzufragen; dein bisheriges Passwort bleibt bis dahin gültig. Fragt jemand anderes von einer anderen IP aus nach deiner Adresse, verbraucht das dein Budget nicht: Jede Quelle hat ihre eigenen drei Anfragen, und nur verschickte Links zählen gegen die zehn insgesamt.
Grenzen dieses Schutzes
Eine einzelne Quelle kann eine fremde Adresse nicht mehr sperren: Ihre Anfragen verbrauchen nur ihr eigenes Budget für diese Adresse. Wer die Inhaberin trotzdem aussperren will, muss das Gesamtbudget von zehn Links ausschöpfen — dafür braucht es mindestens vier verschiedene IPs je Adresse, und jeder dieser Links landet tatsächlich im Postfach der Inhaberin. Solange das gelingt, bekommt sie keinen neuen Link; kennt sie ihr bisheriges Passwort noch, meldet sie sich damit an. Einen Admin-Weg, der ein Passwort setzt, einen Reset-Link auslöst oder das Budget leert, gibt es nicht. Wird ein Budget überschritten, schreibt das Backend einmal password_reset_budget_exhausted mit der Stufe (address_source oder address) und einem mit Schlüssel gebildeten Hash der Adresse ins Log — nie die Adresse selbst, nie die IP. Ist Valkey nicht erreichbar, zählt jeder Prozess für sich: bis zu 3 Links je Adresse, Stunde und Worker-Prozess über alle Replikas. Diese Zählung verdrängt keinen Eintrag, auch nicht bei einer Flut erfundener Adressen: Ist sie voll (16 384 Einträge je Prozess und Budget; beim Reset belegt eine neue Adresse aus einer neuen Quelle zwei), bekommt eine neue Adresse keinen Link, bis ältere Einträge abgelaufen sind — stumm, wie über dem Budget. Ein flatternder Valkey kann bis zu 3 weitere freigeben, weil die Zähler beider Stufen getrennt sind; das gilt, solange Valkey seine Schlüssel behält — ein Neustart, der sie verliert, gibt je Budget weitere 3 frei.
Neues Passwort setzen¶
POST /api/v1/auth/password-reset/confirm
Content-Type: application/json
{
"token": "<token-aus-der-e-mail>",
"new_password": "neues-passwort-2026"
}
Passwort ändern (angemeldet)¶
POST /api/v1/users/me/password
Authorization: Bearer <access-token>
Content-Type: application/json
{
"current_password": "altes-passwort-2026",
"new_password": "neues-passwort-2026"
}
current_password entfällt nur, wenn das Konto noch kein lokales Passwort hat (ein reines SSO-Konto, das erstmals eines setzt) — in diesem Fall bestätigst du stattdessen mit einer frischen Anmeldung beim verknüpften Anbieter (nächster Abschnitt) oder, nur wenn all deine Anbieter das nicht unterstützen (GitHub, Apple), mit dem Bestätigungscode aus dem übernächsten Abschnitt. Existiert bereits ein lokales Passwort, muss es korrekt mitgeschickt werden — es ist ein Step-up (siehe unten). Bei Erfolg werden alle aktiven Sitzungen des Nutzers beendet.
Eine mit API-Key authentifizierte Anfrage wird mit 403 Forbidden abgelehnt — ein API-Key kann das eigene Passwort nicht ändern.
Im Light-Modus (KAMERPLANTER_MODE=light) antwortet die Route mit 403 Forbidden. Das einzige Konto der Instanz wird von jeder Anfrage ohne Anmeldung benutzt; ein dort gesetztes Passwort sagt nichts darüber, wer es gesetzt hat — und es würde nach dem Wechsel in den Full-Modus zur gültigen Anmeldung.
Erneut anmelden zur Bestätigung (OIDC)¶
Ein Konto ohne lokales Passwort hat kein eigenes Geheimnis, mit dem es die Step-up-Bestätigungen im übernächsten Abschnitt beantworten könnte. Ist mindestens einer seiner verknüpften Anmeldeanbieter ein OpenID-Connect-Anbieter (Google oder ein generischer OIDC-Provider mit dem openid-Scope), bestätigt es stattdessen mit einer frischen Anmeldung genau bei diesem Anbieter — das ist der Regelfall. Nur ein Konto, dessen sämtliche verknüpfte Anbieter das nicht können (ausschließlich GitHub und/oder Apple, siehe Abgrenzung unten), weicht auf den per E-Mail zugeschickten Code aus.
POST /api/v1/users/me/step-up/oidc
Authorization: Bearer <access-token>
Content-Type: application/json
{
"action": "account_erasure"
}
action benennt wie beim Code die zu bestätigende Aktion, target wie dort ihr Ziel (Pflicht für die zielgebundenen Aktionen, siehe unten). Optional provider_key (Schlüssel eines verknüpften Anbieters aus GET /users/me/providers) wählt einen bestimmten Anbieter, wenn mehrere OIDC-fähige verknüpft sind; ohne Angabe nimmt die Route den ersten passenden. Optional client_nonce (32 Hex-Zeichen, vom Client zufällig erzeugt) kommt unverändert neben dem Token bzw. dem Fehler zurück — so erkennt die Seite, die die Anmeldung gestartet hat, ihr eigenes Ergebnis und verwirft ein untergeschobenes.
Antwort (200 OK):
{
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?client_id=kamerplanter&response_type=code&prompt=login&max_age=0&state=…&nonce=…&code_challenge_method=S256"
}
Öffne authorization_url im Browser — dieselbe Login-Anfrage wie beim normalen Anmelden, zusätzlich mit prompt=login und max_age=0: Der Anbieter muss die Person erneut anmelden lassen (keine stille Antwort aus dessen eigener Sitzung) und den Anmeldezeitpunkt zurückmelden. Der Anbieter leitet danach an den bestehenden Callback dieses Servers weiter; der meldet niemanden an, sondern leitet ohne Umweg über eine JSON-Antwort direkt zu {frontend-url}/auth/step-up/callback weiter:
- Erfolg:
#step_up_token=<Token>&action=<Aktion>&client_nonce=<Nonce>im URL-Fragment (nie in der Query — ein Fragment erreicht keinen Server, keinen Proxy und keinenReferer-Header) - Fehler:
?error=step_up_failed(allgemein),?error=step_up_stale(die Anmeldung war älter als fünf Minuten — erneut versuchen), oder?error=step_up_cancelled(am Anbieter abgebrochen)
Das Backend prüft dabei am ID-Token: iss ist der erwartete Aussteller, aud/azp ist diese Instanz, nonce stimmt mit der Anfrage überein, exp ist nicht abgelaufen, sub gehört zu einer verknüpften Anmeldung dieses Kontos, die über genau diese Anbieter-Konfiguration entstanden ist (die Verknüpfung speichert Konfiguration und Aussteller; iss muss dazu passen), und auth_time ist eine endliche Zahl und höchstens fünf Minuten alt (30 Sekunden Uhrtoleranz). Eine ältere Verknüpfung ohne gespeicherte Konfiguration gilt nur dann als re-authentifizierbar, wenn genau eine aktive, fähige Konfiguration dieses Typs existiert — sonst bestätigt das Konto mit dem E-Mail-Code. Es prüft keine JWKS-Signatur: Das ID-Token kommt direkt vom Token-Endpunkt des Anbieters über TLS (die frische Anmeldung wird nur bei einem https-Token-Endpunkt angeboten), in einem Austausch, den dieser Server mit seinem eigenen Client-Secret authentifiziert hat — genau der Fall, für den OIDC Core 3.1.3.7 Nr. 6 die TLS-Absicherung als Ersatz für die Signaturprüfung vorsieht.
Der zurückgegebene step_up_token ist fünf Minuten gültig, gilt für genau die angeforderte Aktion (und bei einer zielgebundenen Aktion genau für das angegebene Ziel), wird durch die erste Aktion verbraucht, die ihn vorlegt, und ist ansonsten wie der E-Mail-Code zu verwenden: als step_up_token im Bestätigungs-Body der jeweiligen Aktion (siehe Tabelle unten).
403 Forbidden für eine mit API-Key authentifizierte Anfrage, einen Service Account oder eine Light-Modus-Installation. 422 Unprocessable Entity: STEP_UP_PASSWORD_REQUIRED, wenn das Konto ein lokales Passwort hat (es bestätigt damit); STEP_UP_REAUTH_UNAVAILABLE, wenn keiner seiner verknüpften Anbieter (bzw. der über provider_key gewählte) eine frische Anmeldung unterstützt — dann bestätigt es mit dem E-Mail-Code —; ein Validierungsfehler, wenn action fehlt/unbekannt oder client_nonce falsch geformt ist. 429 Too Many Requests (STEP_UP_LOCKED), solange der Step-up gesperrt ist.
Betreiber-Voraussetzung
Der verknüpfte Identity-Provider muss prompt=login/max_age sowie den Claim auth_time unterstützen (Google und die meisten generischen OIDC-Provider tun das) und seinen Token-Endpunkt über https anbieten. Die Rückruf-URL der erneuten Anmeldung wird aus APP_BASE_URL gebildet ({APP_BASE_URL}/api/v1/auth/oauth/{slug}/callback), nie aus dem Host-Header der Anfrage — setze APP_BASE_URL auf die öffentliche Adresse und hinterlege genau diese Rückruf-URL beim Provider.
Bestätigungscode per E-Mail anfordern (Ausweichweg für GitHub/Apple)¶
Abgrenzung. GitHub ist reines OAuth2 und stellt kein ID-Token aus — es gibt also keinen Anmeldezeitpunkt, den der vorige Abschnitt prüfen könnte. Apple stellt zwar ein ID-Token aus, aber ohne auth_time. Ein Konto, dessen verknüpfte Anmeldewege ausschließlich aus GitHub und/oder Apple bestehen, kann sich deshalb nicht frisch erneut anmelden lassen und bestätigt stattdessen mit einem Einmalcode, der an die eigene E-Mail-Adresse geschickt wird — derselbe Nachweis, auf den sich ein Passwort-Reset stützt. Ein Konto mit mindestens einem OIDC-fähigen Anbieter (Google, generisches OIDC) nutzt immer den vorigen Abschnitt; die Route unten verweigert ihm den Code (siehe unten).
POST /api/v1/users/me/step-up-code
Authorization: Bearer <access-token>
Content-Type: application/json
{
"action": "account_erasure"
}
action benennt, wofür der Code gelten soll — eine der Aktionen aus der Tabelle unten (account_erasure, admin_account_erasure, tenant_deletion, password_change, email_change, api_key_creation, device_pairing, provider_unlink, admin_account_update, oidc_provider_change, admin_tenant_update, admin_membership_removal, admin_membership_role_change, tenant_member_removal, tenant_member_role_change, admin_membership_add, tenant_erasure_cancel, service_account_change). Der Code bestätigt ausschließlich diese eine Aktion — ein für die Passwortänderung angeforderter Code wird bei einem Löschversuch abgelehnt, ohne dabei verbraucht zu werden. Die zugeschickte E-Mail nennt in Klartext, wofür der Code gilt, aber nie das Ziel.
Ziel der Aktion (target). Wirkt die Aktion auf etwas anderes als dein eigenes Konto, nennt target dieses Ziel — sonst antwortet die Route mit 422; bei einer Aktion auf das eigene Konto führt ein target ebenfalls zu 422:
| Aktion | target |
|---|---|
admin_account_update, admin_account_erasure | Schlüssel des anderen Kontos |
tenant_deletion, tenant_erasure_cancel | Schlüssel des Mandanten |
provider_unlink | Schlüssel der Anbieter-Verknüpfung aus GET /users/me/providers |
oidc_provider_change | Schlüssel der OIDC-Konfiguration, beim Anlegen new:<slug> |
admin_tenant_update | Schlüssel des Mandanten |
admin_membership_removal, admin_membership_role_change, tenant_member_removal, tenant_member_role_change | Schlüssel der Mitgliedschaft (membership_key) |
admin_membership_add | Mandant und Konto, getrennt durch \|: <tenant_key>\|<user_key> (die Mitgliedschaft gibt es noch nicht) |
service_account_change | Beim Anlegen der Schlüssel des Mandanten; beim Rotieren und Entfernen <tenant_key>\|<service_account_key> |
Code und step_up_token gelten dann nur für dieses Ziel: Ein für Konto A angeforderter Code wird bei Konto B abgelehnt und dabei nicht verbraucht. Die Route prüft das Ziel schon beim Ausstellen — es muss existieren, und du musst darauf handeln dürfen (403, wenn nicht; 404, wenn es nicht existiert; 409, wenn der Slug beim Anlegen einer OIDC-Konfiguration schon vergeben ist).
Antwort (202 Accepted):
Der Code besteht aus acht Ziffern, ist zehn Minuten gültig und wird durch die erste Aktion verbraucht, die ihn vorlegt; ein erneuter Aufruf ersetzt einen noch gültigen Code durch einen neuen — mit zwei Ausnahmegrenzen: Ein noch nicht verbrauchter Code jünger als 60 Sekunden wird nicht ersetzt (sonst würde eine erneute Anfrage den Code entwerten, den die Person gerade abtippt), und es werden höchstens 5 Codes pro Stunde und Konto verschickt. Der Code wird ausschließlich an die E-Mail-Adresse des eigenen Kontos verschickt — niemals in dieser Antwort oder anderswo zurückgegeben.
403 Forbidden für eine mit API-Key authentifizierte Anfrage, einen Service Account oder eine Light-Modus-Installation (dort gibt es kein persönliches Konto, das etwas bestätigen könnte). 422 Unprocessable Entity: STEP_UP_PASSWORD_REQUIRED, wenn das Konto bereits ein lokales Passwort hat — es bestätigt damit, nicht mit einem Code —; STEP_UP_REAUTH_REQUIRED, wenn mindestens ein verknüpfter Anbieter des Kontos OIDC-fähig ist (siehe oben — die Route sagt dann, den vorigen Abschnitt zu nutzen); ein Validierungsfehler, wenn action fehlt oder keiner der oben genannten Werte ist. 429 Too Many Requests (STEP_UP_LOCKED, details[0].retry_after_minutes), solange der Step-up des Kontos gesperrt ist, ein noch gültiger Code jünger als 60 Sekunden ist, oder das Stundenbudget an Codes ausgeschöpft ist.
503 Service Unavailable (STEP_UP_CODE_UNDELIVERABLE), wenn der Code nicht per E-Mail zugestellt werden kann — zum Beispiel, weil die Instanz ohne konfigurierten E-Mail-Versand läuft, oder der Mailserver einen Fehler meldet. In diesem Fall wird nichts ausgegeben: Der Code wird verworfen und sein Kontingent zurückgegeben, sodass weder die 60-Sekunden-Wartezeit noch das Stundenbudget den nächsten Versuch blockieren, sobald der Betreiber den Versand behoben hat.
Step-up-Bestätigung für unumkehrbare Kontoaktionen und Anmeldemittel¶
Die folgenden Aktionen verlangen zusätzlich zum gültigen Access Token eine erneute Bestätigung durch die angemeldete Person — seit Version 1.19 auch das Ausstellen bzw. Entfernen von Anmeldemitteln und eine Vertrauensanhebung durch Plattform-Admins:
| Aktion | Route(n) | Zurückgetipptes Ziel (Body-Feld) | Passwort / Erneute Anmeldung / Code |
|---|---|---|---|
| Eigenes Konto löschen (Art. 17 DSGVO) | DELETE /users/me, POST /privacy/erasure | eigene E-Mail (confirm_email) | eigenes Passwort, sofern lokal vorhanden — sonst step_up_token einer frischen Anmeldung, nur bei rein GitHub/Apple der per E-Mail zugeschickte Code (step_up_code) |
Anderes Konto löschen (Plattform-Admin; 202, die Löschung läuft in einem Worker, #1949) | DELETE /admin/platform/users/{key} | E-Mail des Zielkontos (confirm_email) | das des Admins, sonst dessen step_up_token bzw. Code |
| Mandant löschen | DELETE /tenants/{slug}, DELETE /admin/platform/tenants/{key} | Slug (confirm_slug) | eigenes Passwort, sonst step_up_token bzw. Code |
| E-Mail-Adresse ändern | POST /privacy/email-change | — | eigenes Passwort, sonst step_up_token bzw. Code |
| Passwort ändern | POST /users/me/password | — | aktuelles (current_password) — die erste Passwortvergabe eines Kontos ohne eines läuft stattdessen über step_up_token bzw. Code |
| API-Key ausstellen | POST /auth/api-keys | — | eigenes Passwort, sonst step_up_token bzw. Code. Light-Modus: kein Step-up — die Instanz hat nur das eine Systemkonto |
| Gerät per QR-Code koppeln | POST /auth/device-pairing | — | eigenes Passwort, sonst step_up_token bzw. Code |
| Anmeldeweg (Provider-Verknüpfung) entfernen | DELETE /users/me/providers/{provider_key} | — | eigenes Passwort, sonst step_up_token bzw. Code |
| Vertrauen eines anderen Kontos ändern (Plattform-Admin) | PATCH /admin/platform/users/{key}, sobald sich email_verified oder is_active ändert (anheben oder senken, #1992) | — | das des Admins, sonst dessen step_up_token bzw. Code |
| OIDC-Provider anlegen, ändern, löschen (Plattform-Admin) | POST /admin/oidc-providers, PUT/DELETE /admin/oidc-providers/{key} — beim PUT nicht, wenn sich nur display_name oder icon_url ändert (Ein- und Abschalten verlangen ihn) | — | das des Admins, sonst dessen step_up_token bzw. Code |
| Service Account anlegen, Key rotieren, Service Account entfernen (Leitung mit Zusatzberechtigung Technik) | POST /t/{slug}/service-accounts, POST /t/{slug}/service-accounts/{sa_key}/rotate-key, DELETE /t/{slug}/service-accounts/{sa_key} | — | eigenes Passwort, sonst step_up_token bzw. Code (service_account_change) |
Kein automatischer Widerruf von API-Keys
Passwortänderung, Passwort-Reset und POST /auth/logout-all widerrufen die Refresh-Token des Kontos — nicht dessen API-Keys. Ein Key steht für eine bewusst eingerichtete Maschinen-Integration (Home Assistant, MCP-Client); ihn bei jeder Passwortänderung stillschweigend zu entwerten, würde diese Integrationen ohne Vorwarnung brechen. Ein Key kann seit dieser Version nur noch hinter diesem Step-up entstehen — also nicht mehr aus einer bloß gestohlenen Sitzung oder aus einem anderen Key. Keys sind unter GET /auth/api-keys mit Erstellungs- und letztem Nutzungszeitpunkt gelistet und einzeln über DELETE /auth/api-keys/{key_id} widerrufbar.
Eine mit einem API-Key authentifizierte Anfrage oder eine Anfrage eines Service Accounts kann keine dieser Aktionen auslösen — auch nicht das Ausstellen eines weiteren API-Keys oder eines Kopplungscodes (siehe Prüfreihenfolge unten).
Beispiel-Body für die Kontolöschung (lokales Konto):
Beispiel-Body nach einer frischen Anmeldung (OIDC-fähiges Konto):
Beispiel-Body für ein Konto, das ausschließlich über GitHub/Apple angemeldet ist:
Prüfreihenfolge:
- Eine Anfrage mit API-Key oder von einem Service Account wird mit
403 Forbiddenabgelehnt — bevor irgendetwas geprüft oder gezählt wird. - Ist die Bestätigung gesperrt (siehe unten), antwortet die Route sofort
429 Too Many Requests, ohne das Geheimnis zu prüfen. - Stimmt das zurückgetippte Ziel nicht (E-Mail ohne Groß-/Kleinschreibung, Slug exakt), antwortet die Route
422 Unprocessable Entity. Ein falsches Echo zählt nicht als Fehlversuch. Die E-Mail-Änderung und die Passwortänderung haben kein Ziel zum Zurücktippen. - Hat das betroffene Konto ein lokales Passwort, muss das Passwortfeld korrekt gesetzt sein, sonst
401 Unauthorized. Sonst — trägt der Body einstep_up_token, prüft die Route dieses (bestätigt es nichts oder ist es abgelaufen:401 Unauthorized). Fehltstep_up_token: Hat das Konto einen OIDC-fähigen verknüpften Anbieter, antwortet die Route401 Unauthorizedmit dem FehlercodeSTEP_UP_REAUTH_REQUIRED— ein Hinweis, sich zuerst erneut anzumelden. Andernfalls (nur GitHub/Apple verknüpft) prüft die Route stattdessenstep_up_code; fehlt der, antwortet sie401 UnauthorizedmitSTEP_UP_CODE_REQUIRED.
Drosselung: Nach 5 falschen Bestätigungen (Passwort, Code oder eine ungültige/abgelaufene erneute Anmeldung) derselben Kombination aus Konto und Client-Adresse sperrt das System weitere Bestätigungen für 15 Minuten; bei wiederholten Fehlversuchen verdoppelt sich die Wartezeit bis auf 4 Stunden. Zusätzlich gilt eine kontoweite Obergrenze von 15 Fehlversuchen über beliebig viele Client-Adressen hinweg. Alle fünf Aktionen — und das Anfordern eines Codes oder einer erneuten Anmeldung — teilen sich dasselbe Fehlversuchs-Budget je Konto; ein erfolgreicher Step-up leert es wieder.
Eine gesperrte Bestätigung antwortet mit dem Fehlercode STEP_UP_LOCKED:
{
"error_code": "STEP_UP_LOCKED",
"message": "Too many failed confirmations. Try again in 15 minutes.",
"details": [
{
"field": "password",
"reason": "Too many failed confirmations.",
"code": "STEP_UP_LOCKED",
"retry_after_minutes": 15
}
]
}
Die Login-Sperre bleibt unberührt
Diese Sperre betrifft ausschließlich die fünf oben genannten Bestätigungen und wirkt sich nicht auf POST /auth/login aus. Wer eine dieser Bestätigungen sperrt — etwa jemand mit einer gestohlenen Sitzung —, kann sich trotzdem weiterhin anmelden, Sitzungen im Tab Sitzungen beenden und das Passwort per E-Mail zurücksetzen.
Für Betreiber: zwei verschiedene Voraussetzungen
Ein Konto mit einem OIDC-fähigen Anbieter (Google, generisches OIDC) bestätigt über die erneute Anmeldung — dafür muss dieser Anbieter prompt=login/max_age und auth_time unterstützen (siehe oben), nicht SMTP. Nur ein Konto, dessen verknüpfte Anbieter ausschließlich GitHub und/oder Apple sind, braucht den per E-Mail zugeschickten Code und damit funktionierenden Mail-Versand: Läuft die Instanz mit dem Konsolen-E-Mail-Adapter und ohne Debug-Modus — die produktive Voreinstellung ohne konfigurierten E-Mail-Versand (SMTP oder Resend) —, wird der Code nirgends zugestellt und ein solches Konto kann sich dann nicht löschen, keinen Mandanten löschen, kein erstes lokales Passwort setzen und die E-Mail-Adresse nicht ändern. Details zur Konfiguration unter Umgebungsvariablen.
OAuth 2.0 / OIDC (Federated Login)¶
Stub-Implementierung
Die OAuth/OIDC-Integration ist als Stub implementiert. Die Endpunkte existieren, liefern jedoch noch keinen vollständigen Datenaustausch. Eine vollständige Implementierung ist für einen Folge-Sprint geplant.
Verfügbare Provider abfragen¶
Antwort:
OAuth-Flow initiieren¶
Der Server antwortet mit einem 302-Redirect zur Autorisierungs-URL des Providers. Nach erfolgreichem Login beim Provider wird der Nutzer zum Callback-Endpunkt zurückgeleitet. Die Rückruf-URL ist {APP_BASE_URL}/api/v1/auth/oauth/{slug}/callback — gebildet aus APP_BASE_URL, nie aus dem Host-Header — und genau diese URL hinterlegst du beim Provider. Der Server tauscht den Code mit derselben URL, die im Autorisierungs-Request stand (RFC 6749 §4.1.3).
Der Server setzt das HttpOnly-Refresh-Cookie und leitet zum Frontend weiter — ohne Token in der URL; das Frontend holt das Access Token über POST /api/v1/auth/refresh:
Bevor der Server jemanden anmeldet, prüft er das ID-Token des Anbieters (OIDC Core 3.1.3.7): die Signatur gegen die Schlüssel (JWKS), die der Anbieter veröffentlicht (jwks_url der Konfiguration, sonst jwks_uri des Discovery-Dokuments), iss, aud/azp (diese Instanz), die nonce der Anfrage sowie exp, iat (höchstens zehn Minuten alt) und, wenn vorhanden, nbf (30 Sekunden Toleranz). Die Schlüssel stehen je Konfiguration zehn Minuten im Zwischenspeicher des Workers; ein unbekanntes kid holt sie höchstens alle zehn Sekunden einmal neu, Antworten sind auf 256 KiB begrenzt. Liefert der Anbieter die Identität über den Userinfo-Endpunkt, muss dessen sub mit dem sub des ID-Tokens übereinstimmen (OIDC Core 5.3.2); ein leeres oder fehlendes sub verweigert die Anmeldung. Jede Verweigerung sieht für den Browser gleich aus (?error=provider_error, keine Sitzung, kein neues Konto); der Grund steht nur als Log-Ereignis oauth_login_refused mit dem Feld reason (signature, iss, aud, azp, nonce, exp, iat, nbf, sub_missing, sub_mismatch, id_token_missing, jwks_unavailable, configuration_changed) — nie mit Werten aus dem Token. Anbieter ohne ID-Token (GitHub, reines OAuth2) liefern ein Konto-id statt eines sub; auch hier verweigert ein fehlender Wert die Anmeldung.
API-Keys (M2M-Integration)¶
API-Keys ermöglichen maschinellen Zugriff ohne interaktiven Login — zum Beispiel für Home Assistant, Grafana oder CI/CD-Pipelines.
API-Key erstellen¶
POST /api/v1/auth/api-keys
Authorization: Bearer <access-token>
Content-Type: application/json
{
"label": "Home Assistant Integration",
"tenant_scope": "mein-garten",
"current_password": "aktuelles-passwort-2026"
}
tenant_scope ist optional und nimmt beim Anlegen den Slug oder den Key des Tenants entgegen. In beiden Fällen musst du im genannten Tenant aktives Mitglied sein — sonst antwortet die Route mit 403 Forbidden ("tenant_scope must name a tenant you are an active member of."), und zwar mit derselben Meldung für einen unbekannten wie für einen fremden Tenant.
Ausstellen ist ein Step-up
Statt current_password kannst du auch step_up_token (nach einer frischen Anmeldung, siehe Erneut anmelden zur Bestätigung) oder step_up_code (nach Bestätigungscode anfordern) mitschicken — dieselben Regeln wie in der Step-up-Tabelle oben. Eine mit einem API-Key authentifizierte Anfrage antwortet 403 Forbidden, bevor überhaupt ein Passwort geprüft wird — ein Key kann sich also nicht selbst vermehren. Im Light-Modus stellt diese Route den einen MCP-Key der Instanz ohne Step-up aus.
Antwort (201 Created):
{
"key": "apk_xyz789",
"label": "Home Assistant Integration",
"raw_key": "kp_sk_abc...xyz",
"key_prefix": "kp_sk_abc",
"tenant_scope": "t-a1b2c3d4",
"created_at": "2026-03-17T10:00:00Z"
}
Gespeichert und zurückgegeben wird immer der Key des Tenants, nicht der beim Anlegen eingegebene Slug. Dadurch ändert ein späteres Umbenennen des Tenants (neuer Slug) nichts am Scope, und ein Löschen des Tenants löscht den Key gleich mit.
Raw Key nur einmal sichtbar
Das Feld raw_key wird nur bei der Erstellung angezeigt und danach nicht mehr ausgegeben. Speichern Sie den Key sofort an einem sicheren Ort.
API-Key verwenden¶
Der API-Key wird im selben Authorization-Header wie ein JWT verwendet.
Ein Key mit tenant_scope handelt nur in diesem Tenant. Auf jeder Route unter /api/v1/t/{slug}/ und auf jeder Route, die den Header X-Active-Tenant liest, antwortet ein anderer Tenant mit 403 Forbidden — auch wenn der Besitzer des Keys dort Mitglied ist — und zwar mit derselben Antwort wie ein Tenant, in dem der Besitzer nicht Mitglied ist. Ohne den Header fällt ein begrenzter Key nur dann auf deinen persönlichen Tenant zurück, wenn dieser sein Scope ist; sonst sieht er nur den gemeinsamen Katalog. Der Abgleich erfolgt dabei ausschließlich über den gespeicherten Tenant-Key (siehe oben).
IP-Allowlist und Rate Limit je Key¶
Ein API-Key kann zusätzlich eine CIDR-Allowlist und ein eigenes Anfragen-Limit pro Minute tragen. Beide Einschränkungen gelten identisch für die REST-API und für den MCP-Server — es gibt ein gemeinsames Budget je Key, keine getrennte Zählung je Schnittstelle.
- Liegt die Client-Adresse außerhalb der Allowlist oder lässt sie sich nicht auflösen, antwortet die API mit
401 Unauthorized("Client IP is not permitted for this API key.") — derselben Meldung, die auch der MCP-Server dafür gibt. - Ist das Minutenbudget aufgebraucht, antwortet die API mit
429 Too Many Requests. Dieselbe Antwort gibt es, wenn der Zähler-Speicher selbst nicht erreichbar ist — ein Ausfall des Zähler-Speichers wird nie als "kein Limit" gewertet.
Kontoweite Routen und ein begrenzter Key¶
Ein Key mit tenant_scope ist auf genau einen Tenant beschränkt. Auf Routen, die keinen Tenant auflösen — etwa die eigenen Kontodaten (PATCH/DELETE /users/me, Passwort, Sitzungen, verknüpfte Provider), die Datenschutz-Endpunkte, die Verwaltung der API-Keys selbst, die Gerätekopplung, POST /auth/logout-all oder das Anlegen bzw. Beitreten eines neuen Tenants — antwortet er deshalb mit 403 Forbidden ("This API key is restricted to one tenant and cannot act on the account."). Dieselbe Antwort gibt es auf den wenigen Routen unter /t/{slug}/, die trotz Tenant im Pfad deine kontoweiten Einstellungen ändern: Benachrichtigungs-Einstellungen und Web-Push-Abos, Benutzer-Einstellungen, der Onboarding-Status und das Entfernen eines Favoriten. Sie gelten für alle deine Tenants, nicht nur für den des Scopes. Zugelassen bleiben: GET /users/me (die eigene Identitätsabfrage), GET /tenants (zeigt dann nur den einen Tenant des Scopes) sowie alle übrigen Routen, die einen Tenant auflösen (/t/{slug}/, X-Active-Tenant) — dort bindet weiterhin der Scope. Ein Key ohne tenant_scope ist von dieser Einschränkung nicht betroffen.
API-Keys auflisten¶
Die Antwort enthält alle Keys des Nutzers ohne den raw_key-Wert.
API-Key widerrufen¶
Gerätekopplung (QR-Code)¶
Für native Mobil-Apps (z. B. die künftige Flutter-App) bietet Kamerplanter eine QR-Code-Kopplung: Ein bereits angemeldeter Nutzer lässt sich im Web-Frontend einen QR-Code anzeigen, scannt ihn mit der App und erhält so ein eigenständiges Token-Paar — ohne Passworteingabe auf dem Mobilgerät.
Der Ablauf hat drei Schritte: Ein angemeldeter Client fordert einen Kopplungscode an (1), die App liest den QR-Code und tauscht den Code gegen ein Token-Paar ein (2), und weil native Clients keinen Cookie-Speicher haben, erneuert die App ihr Zugriffstoken über einen eigenen, cookie-losen Transport (3).
Kopplungscode anfordern¶
POST /api/v1/auth/device-pairing
Authorization: Bearer <access-token>
Content-Type: application/json
{
"current_password": "aktuelles-passwort-2026"
}
Anfordern ist ein Step-up
Wie beim Ausstellen eines API-Keys kannst du statt current_password auch step_up_token oder step_up_code mitschicken (siehe Step-up-Tabelle oben). Eine mit einem API-Key authentifizierte Anfrage antwortet 403 Forbidden — ein Key kann sich damit keine vollständige Sitzung selbst ausstellen.
Antwort (201 Created):
{
"payload_version": 1,
"server_url": "https://garten.example.org",
"code": "Qm5kR2xoY0dWeUlHTnZaR1VnWm05eUlHRWdjR0ZwY21sdVp3",
"expires_at": "2026-08-11T14:32:41Z",
"expires_in": 90
}
server_url stammt aus der auf dem Server konfigurierten Basis-URL der Instanz — nicht aus der URL der eingehenden Anfrage, die hinter einem Reverse Proxy von außen gar nicht erreichbar wäre. expires_in ist bereits die verbleibende Gültigkeitsdauer in Sekunden und steht im Einklang mit expires_at.
QR-Payload¶
Der QR-Code, den die App scannt, kodiert genau diese drei Felder als JSON:
{
"v": 1,
"url": "https://garten.example.org",
"code": "Qm5kR2xoY0dWeUlHTnZaR1VnWm05eUlHRWdjR0ZwY21sdVp3"
}
Das Feld v (entspricht payload_version) existiert für Vorwärtskompatibilität: Eine künftige App-Version kann eine ihr unbekannte Payload-Version ablehnen, statt sie fehlzuinterpretieren.
Light-Modus: Instanz-Erkennung als App-Link-URL
Im Light-Modus (KAMERPLANTER_MODE=light) gibt es keine Konten, und die Kopplungs-Endpunkte antworten mit 404. Das Web-Frontend zeigt dort trotzdem einen QR-Code an — allerdings keine JSON-Nutzlast, sondern eine reine App-Link-URL ohne Kopplungscode, die auf einen festen Deep-Link-Pfad der aufgerufenen Instanz zeigt:
Der Wert ist ein einfacher URL-String, kein JSON-Blob. Das ist der entscheidende Unterschied zu #1118 P12: Die System-Kamera eines Smartphones erkennt eine JSON-Zeichenkette nicht als etwas Öffenbares, eine https-URL dagegen als Link. Die URL entsteht rein im Frontend aus window.location.origin (der Adresse, über die der Nutzer die Instanz tatsächlich erreicht), enthält kein Credential, ruft keinen Endpunkt auf und meldet niemanden an — sie dient ausschließlich der Instanz-Erkennung. Das Query-Feld v=1 teilt sich denselben Versionsraum wie das v der Kopplungs-Payload, sodass die App eine ihr unbekannte Version ablehnen kann.
App-Erkennungs-Kontrakt für den Discovery-Link¶
Der Discovery-Link ist bewusst als unverifizierter Deep Link ausgelegt. Die künftige Kamerplanter-Android-App deklariert dazu einen intent-filter mit Wildcard-Host (android:host="*") auf dem festen Pfad /connect, sodass sie https://<beliebige-instanz>/connect abfängt:
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" android:host="*" android:pathPrefix="/connect" />
</intent-filter>
- Warum keine verifizierten App Links? Automatisch verifizierte Android App Links binden über
assetlinks.jsonan feste, im Manifest deklarierte Domains. Kamerplanter wird jedoch selbst gehostet — jede Instanz hat ihre eigene, im Voraus unbekannte Domain. Über beliebige selbstgehostete Domains hinweg ist eine Auto-Verifizierung daher nicht möglich. Der Kontrakt ist deshalb ein unverifizierter Deep Link mit Wildcard-Host: Android zeigt beim Öffnen einen App-Auswahldialog (bzw. öffnet direkt, sobald der Nutzer die App als Standard gesetzt hat). - Browser-Fallback: Ist die App nicht installiert, öffnet die System-Kamera die URL im Browser. Der Pfad
/connectrendert dort eine schlanke Landing-Seite („In der Kamerplanter-App öffnen" bzw. „Im Browser fortfahren"), damit der Link nie ins Leere (404) läuft. Die Seite liegt außerhalb des Auth-Guards und der Modus-Weiche, funktioniert also in Light- und Full-Modus. - Der Kopplungs-QR bleibt bewusst undurchsichtig: Der Login-/Kopplungs-QR (
{"v","url","code"}, siehe oben) bleibt opakes JSON und ausschließlich in-App scanbar. Er wird bewusst nicht zu einem Deep Link, weil seincodeein Einmal-Credential ist: Als System-Kamera-öffenbarer Link könnte er abgefangen und an eine fremde App geroutet werden. Nur der credential-freie Discovery-Link darf eine öffentlich erkennbare URL sein.
Kopplungscode einlösen¶
POST /api/v1/auth/device-pairing/redeem
Content-Type: application/json
{
"code": "Qm5kR2xoY0dWeUlHTnZaR1VnWm05eUlHRWdjR0ZwY21sdVp3",
"device_name": "Pixel 8 (Gewächshaus)"
}
Dieser Endpunkt ist öffentlich — die App hat zu diesem Zeitpunkt noch kein eigenes Credential, der gescannte Code ist der Nachweis.
Antwort (200 OK):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"expires_in": 900,
"refresh_token": "hZ3JvdzogcmVmcmVzaCB0b2tlbiBmb3IgYSBwYWlyZWQgZGV2aWNl"
}
Refresh Token im JSON-Body
Anders als beim Browser-Login (siehe Token-Modell) liefert die Kopplung das Refresh Token im JSON-Antwortkörper aus und setzt keinen Cookie. Das ist bewusst so gebaut: Native Clients besitzen keinen Cookie-Speicher und müssen das Token selbst entgegennehmen, um es sicher (z. B. im Android Keystore) abzulegen.
device_name ist optional, maximal 64 Zeichen lang und wird — falls angegeben — als Bezeichnung in der Sitzungsliste angezeigt.
Zugriffstoken erneuern (native Clients)¶
Weil ein gekoppeltes Gerät keinen Cookie-Speicher hat, akzeptiert POST /api/v1/auth/refresh zusätzlich zum Cookie-Ablauf einen optionalen JSON-Body:
POST /api/v1/auth/refresh
Content-Type: application/json
{
"refresh_token": "hZ3JvdzogcmVmcmVzaCB0b2tlbiBmb3IgYSBwYWlyZWQgZGV2aWNl"
}
Antwort (200 OK): identisch zur Antwort von /device-pairing/redeem — {access_token, token_type, expires_in, refresh_token} mit dem rotierten Refresh Token im Body.
Ist der Body vorhanden und trägt ein Refresh Token, gilt:
- Der
X-CSRF-Token-Header wird nicht benötigt (kein Cookie wird verbraucht, also gibt es nichts, wovor der CSRF-Schutz schützen müsste). - Es wird kein Cookie gesetzt.
- Das rotierte Refresh Token kommt im JSON-Body zurück.
Fehlt das Feld refresh_token im Body, ist es null, oder ist der gesamte Body leer, greift stattdessen der klassische Cookie-Pfad inklusive CSRF-Prüfung. Sind Body-Token und Cookie gleichzeitig vorhanden, gewinnt der Body — der Cookie wird in diesem Fall ignoriert, nicht als Fallback verbraucht.
Content-Type: application/json ist Pflicht
Ein nicht leerer Body, der kein gültiges JSON ist, wird mit 422 Unprocessable Entity abgelehnt. Native Clients müssen den Header Content-Type: application/json setzen.
Die Rotation ist transportübergreifend: Ein per Body oder per Cookie erneuertes Refresh Token macht das jeweils vorherige Token auf beiden Transportwegen ungültig — es gibt nur eine Rotation, keine getrennte Buchführung je Transportweg.
Kein Gnadenfenster auf dem Body-Weg
Ein bereits rotiertes Token, das erneut vorgelegt wird, beendet die Sitzung des Geräts (Token-Familie, siehe Token-Modell) — auch dann, wenn nur die Antwort auf die erste Erneuerung verloren ging. Ein nativer Client speichert das neue Token deshalb, bevor er es benutzt, und wiederholt eine Erneuerung nicht blind mit dem alten Token. Ist die Sitzung beendet, koppelt er sich neu.
Sitzung eines gekoppelten Geräts beenden¶
Native Clients können /auth/logout nicht verwenden
POST /api/v1/auth/logout prüft den CSRF-Cookie und antwortet ohne ihn mit 403 Forbidden. Ein gekoppeltes Gerät hat diesen Cookie nie besessen und kann sich darüber folglich nicht abmelden.
Ein gekoppeltes Gerät beendet seine Sitzung stattdessen über die reguläre Sitzungsverwaltung:
Alternativ genügt es, das gespeicherte Refresh Token auf dem Gerät zu verwerfen — die Sitzung läuft dann regulär nach 30 Tagen ab, ohne dass sie aktiv widerrufen wurde.
Fehlerantworten¶
| Status | Bedeutung |
|---|---|
401 Unauthorized | „Invalid or expired pairing code." — gilt gleichermaßen für einen unbekannten, bereits eingelösten und einen abgelaufenen Code. Es gibt bewusst keine unterscheidbare Antwort, damit eine Anfrage nicht als Orakel für den Zustand eines Codes missbraucht werden kann. |
423 Locked | Die Quelladresse ist wegen zu vieler fehlgeschlagener Einlöseversuche gesperrt; die Antwort nennt die verbleibende Sperrdauer in Minuten. Der zuletzt verwendete Code wird dabei nicht verbraucht — derselbe QR-Code kann nach Ablauf der Sperre erneut eingelöst werden, solange seine eigene (kurze) Gültigkeitsdauer noch nicht abgelaufen ist. |
429 Too Many Requests | Das Rate Limit für den Einlöse-Endpunkt ist überschritten. |
Sicherheitshinweise¶
Kopplungscode niemals im Klartext neben dem QR-Code anzeigen
Zeige den Kopplungscode ausschließlich als QR-Code an, niemals zusätzlich als lesbaren Text auf demselben Bildschirm — sonst genügt ein Blick über die Schulter, um sich als das gekoppelte Gerät auszugeben. Scanne außerdem nur einen QR-Code, den du selbst gerade erst erzeugt hast — ein fremder oder älterer QR-Code kann bereits verbraucht, abgelaufen oder manipuliert sein.
Der Kopplungscode ist kurzlebig (60–120 Sekunden, konfigurierbar) und nur einmal einlösbar. Er ist kein Passwort und kein langlebiges Token — er dient ausschließlich dazu, einmalig ein reguläres Token-Paar auszustellen.
Rollen und Berechtigungen¶
Nutzer können Mitglied mehrerer Mandanten sein und in jedem Mandanten eine eigene Rolle haben.
| Rolle | Beschreibung |
|---|---|
viewer | Lesezugriff auf alle Mandantenressourcen |
grower | Lese- und Schreibzugriff auf Pflanzen, Durchläufe, Aufgaben |
admin | Vollzugriff inklusive Mitgliederverwaltung und Einstellungen |
Die Rolle wird beim Zugriff auf mandantengebundene Endpunkte automatisch geprüft. Endpunkte mit erhöhten Anforderungen dokumentieren ihre Mindestrolle in der Swagger UI.
Plattform-Admin¶
Der Plattform-Admin hat Zugriff auf die plattformweite Verwaltung unter /api/v1/admin/. Diese Rolle wird über die Mitgliedschaft im platform-Mandanten mit der Rolle admin gesteuert.
Login-Schutz¶
Nach mehreren fehlgeschlagenen Login-Versuchen wird das Konto temporär gesperrt. Die API antwortet dann mit 423 Locked und gibt die verbleibende Sperrdauer an:
{
"error_code": "ACCOUNT_LOCKED",
"message": "Account temporarily locked. Try again in 15 minutes.",
"details": [
{
"field": "account",
"reason": "Too many failed login attempts. Locked for 15 minutes.",
"code": "ACCOUNT_LOCKED"
}
]
}
IP-Rate-Limits und Proxy-Tiefe¶
Die IP-Limits der Anmelderouten (Login, Registrierung, Passwort-Reset, Gerätekopplung) zählen in einem Speicher, den alle Replikas und Worker-Prozesse gemeinsam nutzen. Eine Installation mit mehreren Replikas hat damit dieselbe Grenze wie eine mit einem Prozess.
Nur über API / Betreiber-Konfiguration
Als Betreiber legst du den Speicher mit RATE_LIMIT_STORAGE_URL fest. Bleibt der Wert leer, nutzt das Backend den Valkey aus REDIS_URL — in der Regel musst du nichts setzen. Erlaubt sind redis://, rediss://, redis+unix:// und memory://; jeder andere Wert verhindert den Start. Bleibt RATE_LIMIT_STORAGE_URL leer, gilt das für REDIS_URL: Ein REDIS_URL mit unix://, sentinel:// oder redis+socket://, ein leerer Wert und eine Liste mehrerer Hosts (redis://h1:6379,h2:6379/0) verhindern den Start, seit die IP-Limits über alle Prozesse zählen. Nutzt deine Installation eine solche Form, setze beim Upgrade RATE_LIMIT_STORAGE_URL auf eine erlaubte Adresse (Mehrfach-Hosts scheitern schon an der Prüfung von REDIS_URL selbst). Ebenso eine URL — hier oder in REDIS_URL —, deren Passwort ein unkodiertes /, # oder ? enthält, oder in deren Pfad, Query oder Fragment ein @ steht; kodiere diese Zeichen im Passwort (%2F, %23, %3F). Ein @ im Passwort selbst ist zulässig (redis://:p@ss@host:6379/0 lädt), kodiert (%40) ist es eindeutiger. Die Meldung nennt in beiden Fällen nur die Variable und die erlaubte Form, nie den Wert. Setze memory:// nur, wenn ein einziger Prozess die ganze Installation ist — ohne DEBUG loggt das Backend dann beim Start einmal rate_limit_storage_counts_per_process.
- Speicher fällt aus: Jeder Prozess zählt dann für sich weiter. Scheitert der Speicher mit einem Fehler des Redis-Clients (Verbindung, Timeout, Antwortfehler), werden die Anfragen weder offen durchgelassen noch mit einem Fehler abgelehnt — auch die nicht, die beim Ausfall gerade auf den Speicher warten. Ein anderer Fehler ist ein Defekt und wird nicht verschluckt. Die Grenze ist während des Ausfalls weicher: Je Prozess kann ein frisches Zeitfenster gelten — eines je Fenster, nicht eines je Ausfall; ein Speicher, der mehrmals kurz ausfällt, gibt keinen neuen Spielraum.
- Wartezeit: Hängt der Speicher, statt die Verbindung abzulehnen, wartet die Anfrage, die den Ausfall bemerkt, einen Socket-Timeout von 0,5 s je Verbindungsversuch und Adresse, ohne Wiederholung. Nicht darin enthalten sind die Namensauflösung (DNS), mehrere Adressen eines Namens, Verbindungsaufbau und Handshake zusammen sowie das erneute Laden des Zähl-Skripts nach
NOSCRIPT. Anfragen, die in diesem Moment schon warten, warten gleichzeitig, nicht nacheinander. Alle weiteren Anfragen zählt der Limiter ohne Wartezeit im Prozess — er fällt je Aufruf zurück und hat einen Prüfplan (nächster Punkt). Die Prüfung läuft dabei nie auf der Ereignisschleife des Prozesses: Auch auf asynchronen Routen (öffentliche KI-Frage, Export-Download, Testbenachrichtigung) wartet nur die begrenzte Anfrage selbst, nicht jede andere Anfrage des Prozesses.GET /api/healthzählt nur im Prozessspeicher und fragt den gemeinsamen Speicher nie. Die übrigen Valkey-Clients der Anfragepfade — Gerätekopplung, API-Key-Limits, OAuth-Anmeldestatus und MCP-Sitzungen — warten ebenso höchstens 0,5 s; vorher galt für sie die redis-py-Vorgabe von 5 s je Socket-Operation. - Mail-Budgets: Die Budgets für Reset-Link, Bestätigungslink und den Link der Login-Ablehnung sowie der Zähler für unbekannte Adressen beim Login nutzen dieselbe Client-Einstellung und denselben Prüfplan wie der Limiter: Der erste Speicheraufruf, der einen hängenden Valkey bemerkt, wartet höchstens einen Socket-Timeout von 0,5 s (mit denselben Ausnahmen wie oben); danach zählen alle Aufrufe des Prozesses ohne Wartezeit im Prozess, bis eine Prüfanfrage — in denselben Abständen wie oben — wieder eine Antwort bekommt. Im Log stehen
throttle_store_unavailableundthrottle_store_recovered. - Rückkehr: Jeder Prozess prüft den Speicher für sich erneut, in Abständen von 1, 2, 4, 8, 16 und 32 s und danach alle 32 s, jeweils mit einer einzigen Anfrage, für die dieselbe Wartezeit gilt. Antwortet er, zählt dieser Prozess ab seiner Prüfanfrage wieder im gemeinsamen Speicher. Jeder Prozess kehrt also mit seiner eigenen nächsten Prüfanfrage zurück: bis zu rund 32 s, nachdem der Speicher wieder da ist, und nur, wenn bei ihm Anfragen eintreffen. Im Log stehen
rate_limit_storage_unavailableundrate_limit_storage_recovered. Hat der Speicher seit dem Start des Prozesses noch nie geantwortet — etwa wegen eines falschen Passworts oder Hosts inREDIS_URL—, steht dort statt der Warnungrate_limit_storage_never_reachedauf Fehler-Level. Bleibt er weg, erinnertrate_limit_storage_still_unavailablebei jeder zehnten fehlgeschlagenen Prüfung daran, im Dauerzustand etwa alle fünf Minuten. Die Readiness des Pods bleibt davon unberührt: Ein Valkey-Ausfall soll nicht das ganze Backend aus dem Verkehr ziehen. - Proxy-Tiefe: Das Limit gilt je Client-Adresse und Route — ein anderer Wert im Pfad (ein anderer Glossar-Begriff, Export-Schlüssel oder Tenant) zählt in denselben Zähler. Wie viele Proxys davor stehen, sagt
TRUSTED_PROXY_HOPS. Das Helm-Chart setzt genau1: Ingress-Controller vor nginx (in der Referenz-Installation Contour/Envoy). Leitet deine Installation/apiohne nginx direkt zum Backend, setze0. Ist der gewählte Eintrag keine IP-Adresse, gilt die Client-Adresse als nicht ermittelbar: Das IP-Limit zählt die Anfrage unter der Adresse des direkten Gegenübers, eine IP-Allowlist eines API-Schlüssels lehnt sie ab. Verschiedene Schreibweisen derselben IPv6-Adresse zählen zusammen.
Findest du im Backend-Log einmal je Prozess die Zeile forwarded_chain_deeper_than_trusted_proxy_hops, steht TRUSTED_PROXY_HOPS auf 0, obwohl die X-Forwarded-For-Kette mehr als einen Eintrag hat. Dafür gibt es zwei Ursachen. Entweder steht ein Proxy davor, den die Einstellung nicht zählt: Dann liest das Backend die Adresse eines Proxys statt die des Aufrufers, und alle Aufrufer teilen sich einen Zähler. Oder ein Aufrufer hat selbst einen X-Forwarded-For-Header geschickt, auf einer Installation ohne Ingress, für die 0 richtig ist. Erhöhe TRUSTED_PROXY_HOPS nur, wenn wirklich mehr als ein Proxy davor steht, und dann auf die Zahl der Proxys nach dem ersten (hinter Ingress und nginx: 1). Ein zu hoher Wert macht jede IP-basierte Kontrolle fälschbar: Jeder Aufrufer kann sich dann seine Adresse aussuchen. Die Zeile enthält weder den Header-Wert noch eine IP-Adresse. Ein Lauf ohne Ingress, bei dem die Kette nur einen Eintrag hat, bleibt still.
Umgebungsvariablen (Authentifizierung)¶
| Variable | Standard | Beschreibung |
|---|---|---|
JWT_SECRET_KEY | change-me-... | Signierschlüssel für JWTs — in Produktion mit openssl rand -hex 32 generieren |
JWT_ALGORITHM | HS256 | Signierungsalgorithmus |
ACCESS_TOKEN_EXPIRE_MINUTES | 15 | Gültigkeitsdauer des Access Tokens in Minuten |
REFRESH_TOKEN_EXPIRE_DAYS | 30 | Gültigkeitsdauer des Refresh Tokens in Tagen |
REQUIRE_EMAIL_VERIFICATION | true | E-Mail-Verifikation vor erstem Login erzwingen. Ohne ausgehenden Mailversand ausdrücklich auf false setzen |
KAMERPLANTER_MODE | full | light deaktiviert die gesamte Authentifizierung |
FERNET_KEY | — | Verschlüsselungsschlüssel für OIDC-Provider-Secrets |
RATE_LIMIT_STORAGE_URL | leer | Speicher der IP-Rate-Limits; leer = Valkey aus REDIS_URL — siehe IP-Rate-Limits und Proxy-Tiefe |
Siehe auch¶
- API-Überblick — URL-Struktur und Deployment-Modi
- Fehlerbehandlung — Auth-spezifische Fehlercodes