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.
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"
}
Nach der Registrierung wird ein persönlicher Mandant automatisch angelegt. Wenn E-Mail-Verifikation aktiv ist (REQUIRE_EMAIL_VERIFICATION=true), muss die E-Mail-Adresse vor dem ersten Login bestätigt werden.
E-Mail-Verifizierung¶
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.
Logout¶
Aktuellen Browser abmelden¶
Invalidiert das aktuelle Refresh-Token und löscht den Cookie.
Alle Sitzungen abmelden¶
Invalidiert alle Refresh-Tokens des Nutzers auf allen Geräten.
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.
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"
}
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.
Der Server setzt die Cookies und leitet zum Frontend weiter:
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"
}
Antwort (201 Created):
{
"key": "apk_xyz789",
"label": "Home Assistant Integration",
"raw_key": "kp_sk_abc...xyz",
"key_prefix": "kp_sk_abc",
"tenant_scope": "mein-garten",
"created_at": "2026-03-17T10:00:00Z"
}
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.
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¶
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.
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"
}
]
}
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 | false | E-Mail-Verifikation vor erstem Login erzwingen |
KAMERPLANTER_MODE | full | light deaktiviert die gesamte Authentifizierung |
FERNET_KEY | — | Verschlüsselungsschlüssel für OIDC-Provider-Secrets |
Siehe auch¶
- API-Überblick — URL-Struktur und Deployment-Modi
- Fehlerbehandlung — Auth-spezifische Fehlercodes