JWT-Auth-API
Das Modul eigenständiges JWT signiert und verifiziert einen
einzelnen Token. Eine echte API braucht den ganzen Lebenszyklus: einen
kurzlebigen Access-Token, einen langlebigen Refresh-Token, Rotation beim
Refresh und Widerruf für das Abmelden. Rustango liefert das als
JwtLifecycle — und einen schlüsselfertigen Router, der POST /api/auth/login,
/refresh, /logout und GET /me für Sie einhängt.
Quelle:
rustango::tenancy::jwt_lifecycle(JwtLifecycle,JwtTokenPair,JwtClaims) undrustango::tenancy::auth_routes(jwt_router,Config) +rustango::jti_store(JtiStore,InMemoryJtiStore) — hinterjwt+tenancy.Lauffähige Version: Die Token-Engine wird durch den getesteten
auth_demoabgedeckt —cargo test -p auth_demo --test auth_jwt_api. Die HTTP-Endpunkte sind tenant-bezogen und werden durchgängig durch das eigenecrates/rustango/tests/tenant_auth_live.rsdes Frameworks geprüft.
Ein Begriff hier neu für Sie? Access-/Refresh-Token, Rotation, Widerruf — siehe das Glossar.
Vertiefende Ergänzung zum Abschnitt „JWTs ausstellen und erneuern“ des Sicherheitsleitfadens. Für einen einzelnen, manuell verwalteten Token siehe stattdessen JWT (eigenständig).
Inhaltsverzeichnis
- Der eingebaute Router · Die Verdrahtung
- Die Token-Engine · Refresh & Rotation
- Widerruf & der JTI-Speicher · Benutzerdefinierte Claims
- Hinweise und Grenzen
Der eingebaute Router
jwt_router hängt die vier Standard-Endpunkte gegen die tenant-spezifische
Tabelle rustango_users ein — die ~50 Zeilen Login-Boilerplate, die jedes
Projekt sonst neu schreibt:
| Methode | Pfad | Body / Auth | Liefert |
|---|---|---|---|
| POST | /api/auth/login | {username, password} | {access, refresh, user} |
| POST | /api/auth/refresh | {refresh} | {access, refresh} |
| POST | /api/auth/logout | Authorization: Bearer <access> + optional {refresh} | 204 (widerruft beide JTIs) |
| GET | /api/auth/me | Authorization: Bearer <access> | {user_id, username, is_superuser} |
Login verifiziert das Passwort mit argon2id und stellt dann
ein Paar aus. Pfade, TTLs und der Signierschlüssel sind über Config
konfigurierbar.
Die Verdrahtung
use rustango::tenancy::auth_routes::{jwt_router, Config};
rustango::manage::Cli::new()
.tenancy()
.api(my_app::urls::api()
.merge(jwt_router(Config::default()))) // hängt /api/auth/* ein
.run()
.await
Config::default() signiert mit RUSTANGO_SESSION_SECRET (demselben Schlüssel
wie das Admin-Session-Cookie) und verwendet TTLs von 15 Min. Access / 7 Tage
Refresh. Überschreiben Sie prefix, access_ttl_secs, refresh_ttl_secs oder
session_secret nach Bedarf. Die Endpunkte laufen im Tenant-Kontext, hängen Sie
sie also in einer Tenancy-App ein.
# Login → access + refresh
curl -sX POST localhost:8080/api/auth/login \
-H 'content-type: application/json' \
-d '{"username":"alice","password":"hunter2hunter"}'
# Einen geschützten Endpunkt aufrufen
curl localhost:8080/api/auth/me -H "Authorization: Bearer $ACCESS"
Die Token-Engine (JwtLifecycle)
Unter dem Router sitzt JwtLifecycle — direkt nutzbar, wenn Sie den
Lebenszyklus ohne die eingebaute HTTP-Form wollen:
use rustango::tenancy::jwt_lifecycle::JwtLifecycle;
let jwt = JwtLifecycle::new(secret_32_bytes);
// Login: das Paar ausstellen.
let pair = jwt.issue_pair(user_id);
// → pair.access (kurze TTL, im Authorization-Header senden)
// → pair.refresh (lange TTL, in einem HttpOnly-Cookie / sicheren Speicher ablegen)
// Authentifizierte Anfrage: den Access-Token verifizieren.
match jwt.verify_access(&access).await {
Some(claims) => { /* claims.sub ist die Benutzer-ID */ }
None => { /* 401: ungültig, abgelaufen, widerrufen oder falscher Typ */ }
}
Access- und Refresh-Tokens sind nicht austauschbar — verify_access lehnt
einen Refresh-Token ab und umgekehrt, sodass ein gestohlener kurzlebiger
Access-Token nicht zum Erzeugen neuer Tokens verwendet werden kann:
let pair = jwt.issue_pair(42);
assert!(jwt.verify_refresh(&pair.access).await.is_none());
assert!(jwt.verify_access(&pair.refresh).await.is_none());
Refresh und Rotation
refresh tauscht einen gültigen Refresh-Token gegen ein neues Paar und setzt
die JTI des alten Refresh-Tokens auf die Sperrliste — gleitender Ablauf mit
Refresh-Tokens für den einmaligen Gebrauch (das Wiedereinspielen des alten wird
abgelehnt):
let pair = jwt.issue_pair(7);
let rotated = jwt.refresh(&pair.refresh).await.expect("refresh ok");
assert_ne!(pair.access, rotated.access);
assert!(jwt.refresh(&pair.refresh).await.is_none()); // der alte Refresh ist jetzt tot
Standardmäßig bewahrt refresh die benutzerdefinierten Claims des Tokens.
Wenn sich Berechtigungen geändert haben könnten (Rolle widerrufen, Scope
herabgestuft), verwenden Sie refresh_with(token, new_claims), um ein frisches
Payload einzusetzen, während die alte Refresh-JTI dennoch auf die Sperrliste
gesetzt wird.
Widerruf und der JTI-Speicher
Jeder Token trägt eine eindeutige jti. revoke fügt sie einer Sperrliste
hinzu, sodass nachfolgende verify_*-Aufrufe fehlschlagen, bis der Token ohnehin
abgelaufen wäre — genau das ruft POST /api/auth/logout auf:
let pair = jwt.issue_pair(1);
assert!(jwt.revoke(&pair.access).await);
assert!(jwt.verify_access(&pair.access).await.is_none());
Den Refresh-Token an /logout senden
Nur den Bearer zu widerrufen beendet einen Token, der ohnehin in Minuten abgelaufen wäre. Der Refresh-Token ist derjenige mit tagelanger Lebensdauer und kann während seiner gesamten TTL neue Access-Token ausstellen — ein Logout, das ihn am Leben lässt, beendet die Sitzung also nicht, es verschiebt sie nur:
POST /api/auth/logout
Authorization: Bearer <access>
{ "refresh": "<refresh>" } // widerruft auch die langlebige Hälfte
Der Body ist optional, sodass Clients, die gegen den älteren Endpunkt geschrieben wurden, unverändert weiterlaufen — sie widerrufen einfach weniger. Senden Sie ihn. Beide Hälften sind an den aufrufenden Mandanten gebunden, sodass eine Subdomain den Token einer anderen nicht widerrufen kann.
Die Sperrliste liegt in einem austauschbaren JtiStore. Der Standard
InMemoryJtiStore ist einprozessig und verliert Widerrufe beim Neustart —
in Ordnung für eine einzelne Instanz. Jede Multi-Replica-Bereitstellung MUSS
einen gemeinsamen, dauerhaften Speicher (Redis / DB) installieren, damit ein
Logout auf einer Replica von allen respektiert wird:
use rustango::jti_store::{InMemoryJtiStore, JtiStore};
use std::sync::Arc;
let shared: Arc<dyn JtiStore> = Arc::new(InMemoryJtiStore::new()); // in Prod durch Redis ersetzen
let a = JwtLifecycle::new(secret.clone()).with_jti_store(Arc::clone(&shared));
let b = JwtLifecycle::new(secret).with_jti_store(Arc::clone(&shared));
let pair = a.issue_pair(5);
a.revoke(&pair.access).await;
assert!(b.verify_access(&pair.access).await.is_none()); // B sieht As Widerruf
Ohne gemeinsamen Speicher ist
/logoutbestenfalls „best-effort“: Ein widerrufener Token kann auf einer anderen Replica bis zu seinem natürlichen Ablauf noch akzeptiert werden. Dies ist die einzelne wichtigste Produktionseinstellung für JWT-Auth.
Einen dauerhaften Speicher schreiben
JtiStore ist asynchron (seit v0.52, #1191), eine dauerhafte Implementierung ist
also eine einzige Abfrage — kein Hintergrund-Flusher, kein Konvergenzfenster, in
dem ein widerrufener jti woanders noch akzeptiert wird. Beide Methoden geben
JtiFuture<'_, T> zurück (ein geboxtes Future — der Trait wird als
Arc<dyn JtiStore> verwendet, und natives async fn in Traits ist nicht
dyn-kompatibel):
use rustango::jti_store::{JtiFuture, JtiStore};
impl JtiStore for PgJtiStore {
fn is_used<'a>(&'a self, jti: &'a str) -> JtiFuture<'a, bool> {
Box::pin(async move { self.lookup(jti).await })
}
fn mark_used<'a>(&'a self, jti: &'a str, exp_unix: i64) -> JtiFuture<'a, bool> {
Box::pin(async move { self.insert_if_absent(jti, exp_unix).await })
}
}
mark_used MUSS atomar sein: Bei gleichzeitigen Aufrufen für denselben jti
darf genau einer true sehen — ein einziger bedingter Schreibvorgang
(INSERT … ON CONFLICT DO NOTHING, Redis SET NX), niemals Lesen-dann-Schreiben.
Sonst ist die Einmalverwendungs-Garantie für Refresh-Tokens verloren.
Da die Verifizierung den Speicher konsultiert, sind verify_access,
verify_refresh, refresh, revoke und die MCP-/Tenant-Token-Helfer
(mcp::verify_agent_token, tenancy::auth_routes::verify_for_tenant) alle
async. Der Ablauf wird vor dem Speicherzugriff geprüft, ein abgelaufener
Token kostet also keinen Roundtrip.
Benutzerdefinierte Claims
Betten Sie roles / tenant / scope direkt in den Token ein, sodass die
Verifizierung keine DB-Abfrage benötigt. Reservierte Namen (sub, exp, jti,
typ) werden abgelehnt:
let custom = serde_json::json!({ "roles": ["admin"], "tenant": "acme" })
.as_object().unwrap().clone();
let pair = jwt.issue_pair_with(99, custom)?;
let claims = jwt.verify_access(&pair.access).await.unwrap();
let roles: Vec<String> = claims.get_custom("roles").unwrap(); // ["admin"]
Benutzerdefinierte Claims überleben refresh (werden auf das neue Paar
übertragen), es sei denn, Sie verwenden refresh_with.
Hinweise und Grenzen
-
Sessions vs. JWT vs. dies: Ein einfaches JWT kann nicht widerrufen werden; eine Session ist widerrufbar, braucht aber eine Speicherabfrage pro Anfrage;
JwtLifecycleist der Mittelweg — zustandslose Verifizierung, plus eine JTI-Sperrliste für die Widerrufe, die Sie tatsächlich brauchen (Logout, Rotation). -
HTTP-Endpunkte sind tenant-bezogen.
jwt_routerlöst Benutzer über den Tenant-Kontext +rustango_usersauf; hängen Sie ihn in einer.tenancy()-App ein. Die Token-Engine (JwtLifecycle) selbst hat diese Anforderung nicht. -
Kombinieren Sie dies mit dem
JwtBackendder Auth-Backend-Kette, um beliebige Routen aus demAuthorization: Bearer-Header zu authentifizieren. -
HS256-Signierung, 32-Byte-Schlüssel-Untergrenze — derselbe Algorithmus und dieselben Einschränkungen wie beim eigenständigen JWT.
-
Die Tokens sind gewöhnliche JWTs, also kann alles, was ein JWT prüft, auch diese prüfen:
jwt.io, die Standardbibliothek deiner Plattform, ein API-Gateway, ein anderer Dienst, dem du ein Token gibst. Drei Segmente, ein JOSE-Header{"alg":"HS256","typ":"JWT"}, signiert überheader.payload.Bis #1397 waren es zwei Segmente ohne Header, signiert nur über die Payload — lesbar von nichts außer rustango und sogar von
rustango::jwt::decodeabgelehnt. Wer dafür einen eigenen Verifier geschrieben hat, kann ihn wegwerfen.Vor dem Fix ausgestellte Tokens werden beim Verifizieren weiterhin akzeptiert, damit ein Upgrade niemanden ausloggt. Dieser Kompatibilitätspfad entfällt in 0.58, wenn jedes Token der alten Form längst abgelaufen ist.
