Aufbau einer benutzerdefinierten Authentifizierungsschicht in Axum
Ethan Miller
Product Engineer · Leapcell

Einleitung
In der Welt moderner Webdienste ist die Sicherung Ihrer APIs von größter Bedeutung. Egal, ob Sie einen Microservice oder eine monolithische Anwendung erstellen, die Sicherstellung, dass nur autorisierte Benutzer oder Dienste auf bestimmte Endpunkte zugreifen können, ist eine grundlegende Anforderung. JSON Web Tokens (JWTs) und API-Schlüssel sind zwei verbreitete Methoden, um dies zu erreichen, und bieten leichtgewichtige und zustandslose Möglichkeiten zur Überprüfung der Identität. Während Axum, das zunehmend beliebte Web-Framework von Rust, hervorragende Bausteine bietet, erfordert die Implementierung einer benutzerdefinierten Authentifizierung oft ein tieferes Verständnis seiner Middleware-Architektur. Dieser Artikel führt Sie durch den Prozess der Erstellung einer wiederverwendbaren Authentifizierungs-"Schicht" in Axum von Grund auf, die sowohl JWT- als auch API-Schlüssel-Validierung verarbeiten kann. Wir werden die Designprinzipien untersuchen, die Implementierungsdetails durchgehen und demonstrieren, wie sie nahtlos in Ihre Axum-Anwendungen integriert werden kann.
Kernkonzepte verstehen
Bevor wir uns mit dem Code befassen, definieren wir kurz einige Schlüsselbegriffe, die für unsere Diskussion zentral sind:
- Axum Request Handling: Axum verarbeitet eingehende HTTP-Anfragen über eine Kette von Services. Jeder Service kann die Anfrage verarbeiten, modifizieren oder an den nächsten Service in der Kette weiterleiten.
- Tower Service Trait: Das Herzstück des Middleware-Systems von Axum ist der
tower::Service
-Trait. Er definiert eine generische asynchrone Operation, die eine Anfrage entgegennimmt und eine Antwort zurückgibt. - Tower Layer Trait: Eine
tower::Layer
ist eine Fabrik fürtower::Service
-Instanzen. Sie umschließt einen inneren Service und ermöglicht es Ihnen, Logik hinzuzufügen, bevor oder nachdem der innere Service ausgeführt wird. Genau das werden wir verwenden, um unsere Authentifizierungslogik einzufügen. - JSON Web Token (JWT): Ein eigenständiges, kompaktes und URL-sicheres Mittel zur Darstellung von Ansprüchen, die zwischen zwei Parteien übertragen werden sollen. JWTs werden oft zur Authentifizierung verwendet, wobei der Server nach erfolgreicher Anmeldung einen Token an den Client ausstellt und der Client diesen Token dann bei nachfolgenden Anfragen sendet, um seine Identität nachzuweisen.
- API-Schlüssel: Eine eindeutige Kennung, die von einem API-Anbieter einem Verbraucher zur Verfügung gestellt wird, um auf seine API zugreifen zu können. API-Schlüssel werden typischerweise in Anforderungsheadern oder als Abfrageparameter übergeben. Obwohl einfacher, bieten sie weniger granulare Kontrolle und sind im Allgemeinen weniger sicher als JWTs für die Benutzerauthentifizierung ohne zusätzliche Mechanismen.
- Authentifizierung vs. Autorisierung: Bei der Authentifizierung geht es darum, wer Sie sind (Überprüfung der Identität), während es bei der Autorisierung darum geht, was Sie tun können (Bestimmung der Zugriffsrechte). Unsere Schicht konzentriert sich hauptsächlich auf die Authentifizierung.
Aufbau der Authentifizierungsschicht
Unsere Authentifizierungsschicht wird eingehende Anfragen entweder auf einen JWT im Authorization
-Header oder auf einen API-Schlüssel in einem benutzerdefinierten Header (z. B. X-API-Key
) prüfen. Wenn gültige Anmeldeinformationen gefunden werden, wird die Anfrage fortgesetzt. Andernfalls wird eine nicht autorisierte Antwort zurückgegeben.
Der Authentifizierungszustand
Definieren wir zunächst eine einfache Enum, die mögliche Authentifizierungsergebnisse darstellt:
#[derive(Debug, Clone, PartialEq)] pub enum AuthStatus { Authenticated(String), // Für Benutzer-ID oder andere Kennung Unauthenticated, }
Dieser AuthStatus
wird verwendet, um das Ergebnis unseres Authentifizierungsversuchs zu signalisieren. Bei erfolgreicher Authentifizierung können wir eine Benutzer-ID oder andere relevante Informationen speichern.
Der Authentifizierungsdienst
Als Nächstes erstellen wir unseren benutzerdefinierten AuthService
, der den tower::Service
-Trait implementiert. Dieser Dienst umschließt einen inneren Dienst und führt die Authentifizierungslogik aus.
use async_trait::async_trait; use axum::{ body::{Body, BoxBody}, extract::Request, http::{ header::{AUTHORIZATION, CONTENT_TYPE}, response::Response, StatusCode, }, response::IntoResponse, middleware::Next, }; use std::{ future::Future, pin::Pin, task::{Context, Poll}, }; use tower::{Layer, Service}; pub struct AuthService<S> { inner: S, jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, } impl<S> AuthService<S> { pub fn new( inner: S, jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, ) -> Self { Self { inner, jwt_secret, api_key_header_name, valid_api_keys, } } // Hilfsfunktion zur Validierung von JWT fn validate_jwt(&self, token: &str) -> Option<String> { // In einer realen Anwendung würden Sie das JWT parsen und validieren. // Zur Demonstration gehen wir von einer Dummy-Validierung aus. if token.starts_with("Bearer my_valid_jwt_") { // Benutzer-ID aus dem Token extrahieren (z. B. durch Dekodierung von Claims) Some("user123".to_string()) } else { None } } // Hilfsfunktion zur Validierung des API-Schlüssels fn validate_api_key(&self, api_key: &str) -> Option<String> { if self.valid_api_keys.contains(&api_key.to_string()) { // Für API-Schlüssel können wir den Schlüssel selbst oder eine zugehörige Benutzer-/Dienst-ID zurückgeben Some(format!("api_user_{}", api_key)) } else { None } } } // Implementieren Sie den tower::Service-Trait für AuthService impl<S> Service<Request> for AuthService<S> where S: Service<Request, Response = Response> + Send + 'static, S::Future: Send + 'static, { type Response = S::Response; type Error = S::Error; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut request: Request) -> Self::Future { let jwt_secret = self.jwt_secret.clone(); let api_key_header_name = self.api_key_header_name.clone(); let valid_api_keys = self.valid_api_keys.clone(); // Klonen für asynchronen Block // Dies ist ein kniffliger Teil: Wir müssen `self.inner` klonen, da `Service::call` `&mut self` entgegennimmt. // Wenn `S` `Clone` nicht implementiert, erfordert dieser Ansatz `ServiceBuilder::service`, der `&&mut S` entgegennimmt. // Ein gängiges Muster ist, den inneren Dienst in einen `Arc<Mutex<S>>` zu verpacken, wenn `S` nicht klonbar ist. // Der Einfachheit halber gehen wir hier davon aus, dass S klonbar ist oder wir den veränderlichen Verweis sorgfältig behandeln. // In Axums `tower::util::ServiceFn` oder `middleware::from_extractor_with_state` wird das Klonen gehandhabt. // Für einen reinen Tower Service müssen wir oft expliziter sein. let inner = self.inner.ready(); // Sicherstellen, dass der innere Dienst bereit ist Box::pin(async move { let mut auth_status = AuthStatus::Unauthenticated; // 1. Nach JWT suchen if let Some(auth_header) = request.headers().get(AUTHORIZATION) { if let Ok(header_value) = auth_header.to_str() { if header_value.starts_with("Bearer ") { let token = &header_value[7..]; if let Some(user_id) = AuthService::<S>::new( // Dieses `new` dient nur zum Aufrufen der `validate_jwt`-Hilfsfunktion, // nicht zum Erstellen des tatsächlichen Dienstes, der aufgerufen wird. // Besser ist es, `validate_jwt` als kostenlose Funktion zu machen oder Kontext zu übergeben. S::default(), // Dummy-Inner-Dienst, nicht für Validierungslogik verwendet jwt_secret.clone(), String::new(), // Nicht für JWT-Validierung verwendet vec![], // Nicht für JWT-Validierung verwendet ).validate_jwt(token) { auth_status = AuthStatus::Authenticated(user_id); } } } } // 2. Nach API-Schlüssel suchen, falls noch nicht authentifiziert if auth_status == AuthStatus::Unauthenticated { if let Some(api_key_header) = request.headers().get(&api_key_header_name) { if let Ok(key_value) = api_key_header.to_str() { if let Some(api_user) = AuthService::<S>::new( S::default(), // Dummy-Inner-Dienst String::new(), // Nicht für API-Schlüssel-Validierung verwendet api_key_header_name.clone(), valid_api_keys.clone(), ).validate_api_key(key_value) { auth_status = AuthStatus::Authenticated(api_user); } } } } match auth_status { AuthStatus::Authenticated(user_id) => { // Die authentifizierte Benutzer-ID in den Request-Erweiterungen speichern request.extensions_mut().insert(user_id.clone()); inner.await?.call(request).await // Zum inneren Dienst fortfahren } AuthStatus::Unauthenticated => { // 401 Unauthorized zurückgeben Ok(StatusCode::UNAUTHORIZED.into_response()) } } }) } }
Wichtiger Hinweis zu Service::call
und &mut self
: Die Methode tower::Service::call
nimmt &mut self
entgegen. Das bedeutet, wenn unser AuthService
asynchrone Operationen durchführen muss, die von internem Zustand abhängen (wie jwt_secret
oder valid_api_keys
), und dann auch den inner
-Dienst aufrufen muss (der ebenfalls &mut S
entgegennimmt), müssen wir vorsichtig sein. Der obige Code verwendet clone
zur Demonstration. In einer Produktionsumgebung würden Sie oft gemeinsam genutzten Zustand in Arc
und Mutex
oder RwLock
verpacken, wenn er veränderlich und über asynchrone Aufgaben hinweg gemeinsam genutzt werden muss, oder Kopien der Konfiguration übergeben. Axums tower::Layer::layer
-Helfer vereinfacht dies oft, oder indem Service
nach der Erstellung unveränderlich gemacht wird. Für einen Layer
ist der erstellte Service
typischerweise für die Lebensdauer einer Verbindung oder Anwendung gedacht, was bedeutet, dass der Zustand gemeinsam nutzbar sein muss.
Ein idiomatischerer Weg, Service::call
mit &mut self
zu handhaben, wenn mit inner.await?.call(request).await
gearbeitet wird, wird oft durch tower::util::ServiceFn
erreicht oder indem die Authentifizierungslogik in eine eigene middleware
-Funktion aufgeteilt wird, die mit axum::middleware::from_fn
komponiert werden kann. Um jedoch explizit einen rohen tower::Layer
und Service
zu demonstrieren, folgen wir diesem Muster.
Die Authentifizierungsschicht
Nun definieren wir unsere AuthLayer
, die Instanzen von AuthService
erstellt:
pub struct AuthLayer { jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>, } impl AuthLayer { pub fn new(jwt_secret: String, api_key_header_name: String, valid_api_keys: Vec<String>) -> Self { Self { jwt_secret, api_key_header_name, valid_api_keys, } } } // Implementieren Sie den tower::Layer-Trait für AuthLayer impl<S> Layer<S> for AuthLayer { type Service = AuthService<S>; fn layer(&self, inner: S) -> Self::Service { AuthService::new( inner, self.jwt_secret.clone(), self.api_key_header_name.clone(), self.valid_api_keys.clone(), ) } }
Extrahieren der ID des authentifizierten Benutzers
Um die ID des authentifizierten Benutzers für unsere Handler verfügbar zu machen, können wir einen Axum-Extraktor erstellen:
use axum::{ async_trait, extract::{FromRequestParts, State}, http::request::Parts, response::{IntoResponse, Response}, Json, }; pub struct AuthenticatedUser(pub String); #[async_trait] impl<S> FromRequestParts<S> for AuthenticatedUser where S: Send + Sync, { type Rejection = Response; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> { if let Some(user_id) = parts.extensions.get::<String>() { Ok(AuthenticatedUser(user_id.clone())) } else { // Dies sollte idealerweise nicht passieren, wenn die Schicht korrekt angewendet wird, // aber es dient als Sicherung. Err(StatusCode::UNAUTHORIZED.into_response()) } } }
Integration mit Axum
Schließlich sehen wir, wie wir diese Schicht auf einen Axum-Router anwenden:
use axum::{routing::get, Router}; use tower::ServiceBuilder; // Handler, der Authentifizierung erfordert async fn protected_handler(AuthenticatedUser(user_id): AuthenticatedUser) -> String { format!("Hallo, authentifizierter Benutzer: {}!", user_id) } // Einfacher öffentlicher Handler async fn public_handler() -> &'static str { "Dies ist ein öffentlicher Endpunkt." } #[tokio::main] async fn main() { let app = Router::new() .route("/public", get(public_handler)) .route("/protected", get(protected_handler)) .layer( ServiceBuilder::new() .layer(AuthLayer::new( "super_secret_jwt_key".to_string(), // In Wirklichkeit aus Konfiguration/Umgebung laden "X-Api-Key".to_string(), vec!["my_secret_api_key".to_string(), "another_key".to_string()], )) ); let listener = tokio::net::TcpListener::bind("127.0.0.1:3000") .await .unwrap(); println!("Mithören auf http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); }
Funktionsweise
- Anfrage an
AuthLayer
: Wenn eine HTTP-Anfrage eingeht, trifft sie zuerst auf unsereAuthLayer
. AuthLayer::layer
: Die Schicht erstellt eineAuthService
-Instanz, die die innere Schicht (die eine andere Schicht oder der endgültige Handler sein könnte) umschließt.AuthService::call
: Diecall
-Methode vonAuthService
wird aufgerufen.- Sie prüft den
Authorization
-Header auf ein "Bearer"-Token und versucht die JWT-Validierung. - Wenn die JWT-Validierung fehlschlägt oder nicht vorhanden ist, prüft sie den
X-Api-Key
-Header auf einen vordefinierten API-Schlüssel. - Wenn einer von beiden gültig ist, fügt sie die abgeleitete
user_id
(oder eine ähnliche Kennung) mitrequest.extensions_mut().insert()
in die Erweiterten Elemente der Anfrage ein. - Sie ruft dann die
inner
-Dienst auf, wodurch die Anfrage zum Handler weitergeleitet wird. - Wenn weder JWT noch API-Schlüssel gültig sind, gibt sie sofort eine Antwort vom Typ
401 Unauthorized
zurück und unterbricht die Anfragekette.
- Sie prüft den
- Extraktor
AuthenticatedUser
: In unseremprotected_handler
verwenden wir denAuthenticatedUser
-Extraktor. Dieser Extraktor ruft denString
(unsereuser_id
) aus den Anforderungserweiterungen ab. Wenn er vorhanden ist, erhält der Handler ihn; andernfalls gibt der Extraktor aus seinem eigenen Ablehnungsfluss401 Unauthorized
zurück, was als doppelte Überprüfung dient.
Fazit
Durch die Nutzung der tower::Layer
- und tower::Service
-Traits von Axum haben wir erfolgreich eine benutzerdefinierte Authentifizierungsschicht implementiert, die sowohl JWT- als auch API-Schlüssel-Validierung verarbeiten kann. Dieser Ansatz zentralisiert die Authentifizierungslogik, hält Handler sauber und fördert die Wiederverwendbarkeit von Code. Dieses robuste Middleware-Muster ist grundlegend für den Aufbau sicherer und wartbarer Webanwendungen mit Axum. Denken Sie daran, dass eine gut strukturierte Anwendung klare Grenzen und modulare Komponenten erfordert, und benutzerdefinierte Schichten sind ein mächtiges Werkzeug, um dies zu erreichen.