Axum でカスタム認証レイヤーを構築する
Ethan Miller
Product Engineer · Leapcell

はじめに
現代のウェブサービスの世界では、API のセキュリティ保護が最優先事項です。マイクロサービスを構築している場合でも、モノリシックなアプリケーションを構築している場合でも、承認されたユーザーまたはサービスのみが特定の endpoint にアクセスできるようにすることは、基本的な要件です。JSON Web Token (JWT) と API キーは、これを達成するための 2 つの一般的な方法であり、軽量でステートレスな ID 検証方法を提供します。Axum は Rust でますます人気のある Web フレームワークであり、優れた構成要素を提供しますが、カスタム認証の実装には、そのミドルウェアアーキテクチャのより深い理解が必要になることがよくあります。この記事では、JWT と API キーの両方の検証を処理できる、再利用可能な認証「レイヤー」をゼロから作成するプロセスを説明します。設計原則を探り、実装の詳細を説明し、Axum アプリケーションにシームレスに統合する方法をデモンストレーションします。
コアコンセプトの理解
コードに飛び込む前に、議論の中心となるいくつかの重要な用語を簡単に定義しましょう。
- Axum リクエスト処理: Axum は、サービスのチェーンを通じて受信 HTTP リクエストを処理します。各サービスはリクエストを処理、変更、またはチェーン内の次のサービスに渡すことができます。
- Tower Service トレイト: Axum のミドルウェアシステムの中心には
tower::Service
トレイトがあります。これは、リクエストを受け取り、レスポンスを返す汎用非同期操作を定義します。 - Tower Layer トレイト:
tower::Layer
はtower::Service
インスタンスのファクトリです。内部サービスをラップし、内部サービスが実行される前または後にロジックを追加できるようにします。これは、認証ロジックを注入するために使用するものとまったく同じです。 - JSON Web Token (JWT): 2 つの当事者間で転送されるクレームを表す、自己完結型でコンパクトな URL セーフな手段です。JWT は、サーバーがログイン成功後にクライアントにトークンを発行し、クライアントが後続のリクエストでこのトークンを送信して ID を証明するために、認証によく使用されます。
- API キー: API プロバイダーが API へのアクセスをコンシューマーに提供する一意の識別子です。API キーは通常、リクエストヘッダーまたはクエリパラメータで渡されます。よりシンプルですが、きめ細かな制御が少なく、追加のメカニズムなしでユーザー認証の場合は JWT よりも一般的に安全性が低くなります。
- 認証と認可: 認証は「あなたが誰であるか」(ID の検証)であり、認可は「何ができるか」(アクセス権の決定)です。私たちのレイヤーは主に認証に焦点を当てます。
認証レイヤーの構築
私たちの認証レイヤーは、Authorization
ヘッダーの JWT またはカスタムヘッダー(例: X-API-Key
)の API キーのいずれかの受信リクエストを検査します。有効な資格情報が見つかった場合、リクエストは続行されます。それ以外の場合は、未承認のレスポンスが返されます。
認証ステータス
まず、可能性のある認証結果を表すシンプルな enum を定義しましょう。
#[derive(Debug, Clone, PartialEq)] pub enum AuthStatus { Authenticated(String), // ユーザー ID またはその他の識別子用 Unauthenticated, }
この AuthStatus
は、認証試行の結果を示すために使用されます。認証された場合、ユーザー ID やその他の関連情報を格納する可能性があります。
認証サービス
次に、tower::Service
トレイトを実装するカスタム AuthService
を作成します。このサービスは内部サービスをラップし、偽の認証ロジックを実行します。
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, } } // JWT を検証するヘルパー関数 fn validate_jwt(&self, token: &str) -> Option<String> { // 実際のアプリケーションでは、JWT を解析して検証します。 // デモンストレーションのために、ダミー検証を仮定しましょう。 if token.starts_with("Bearer my_valid_jwt_") { // トークンからユーザー ID を抽出します (例: クレームをデコードして)。 Some("user123".to_string()) } else { None } } // API キーを検証するヘルパー関数 fn validate_api_key(&self, api_key: &str) -> Option<String> { if self.valid_api_keys.contains(&api_key.to_string()) { // API キーの場合、キー自体または関連するユーザー/サービス ID を返す可能性があります。 Some(format!("api_user_{}", api_key)) } else { None } } } // AuthService の tower::Service トレイトを実装します 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(); // Async ブロックのためにクローン // ここは難しい部分です: `Service::call` は `& mut self` を取るため、`self.inner` をクローンする必要があります。 // `S` が `Clone` を実装していない場合、このアプローチでは `ServiceBuilder::service` が必要で、これは `&& mut S` を取ります。 // 一般的なパターンは、`S` がクローン可能でない場合、共有状態を `Arc<Mutex<S>>` でラップすることです。 // ここでは単純化のために S がクローン可能であるか、ミュータブル参照を慎重に処理すると仮定します。 // Axum の `tower::util::ServiceFn` または `middleware::from_extractor_with_state` では、クローンが処理されます。 // 生の Tower Service の場合、より明示的に行う必要があります。 let inner = self.inner.ready(); // 内部サービスが準備完了であることを確認 Box::pin(async move { let mut auth_status = AuthStatus::Unauthenticated; // 1. JWT をチェックします 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( // この `new` は `validate_jwt` ヘルパー関数を呼び出すためだけで、 // 実際に呼び出されるサービスを作成するためではありません。 // `validate_jwt` をフリー関数にしたか、コンテキストを渡す方が良いでしょう。 S::default(), // ダミーの内部サービス、検証ロジックには使用されません jwt_secret.clone(), String::new(), // JWT 検証には使用されません vec![], // JWT 検証には使用されません ).validate_jwt(token) { auth_status = AuthStatus::Authenticated(user_id); } } } } // 2. まだ認証されていない場合は API キーをチェックします 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(), // ダミーの内部サービス String::new(), // API キー検証には使用されません 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) => { // 認証されたユーザー ID をリクエスト拡張機能に保存します request.extensions_mut().insert(user_id.clone()); inner.await?.call(request).await // 内部サービスに続行 } AuthStatus::Unauthenticated => { // 401 Unauthorized を返します Ok(StatusCode::UNAUTHORIZED.into_response()) } } }) } }
Service::call
と &mut self
に関する重要事項: tower::Service::call
メソッドは &mut self
を取ります。これは、AuthService
が内部状態(jwt_secret
や valid_api_keys
など)に依存する非同期操作を実行し、その後(これも &mut S
を取る)内部サービスを呼び出す必要がある場合、注意が必要であることを意味します。上記のコードではデモンストレーションのために clone
を使用しています。本番環境では、共有状態を Arc
と Mutex
または RwLock
でラップして、非同期タスク間で共有する必要がある場合はミュータブルにしたり、構成のコピーを渡したりすることがよくあります。Axum の tower::Layer::layer
ヘルパーはこれを簡略化することがよくあります。または Service
を作成後に不変にすることで可能です。Layer
の場合、作成される Service
は通常、接続またはアプリケーションのライフタイムにわたって存続するため、状態は共有可能である必要があります。
inner.await?.call(request).await
を使用して &mut self
で Service::call
を処理する、より慣用的な方法は、tower::util::ServiceFn
を使用するか、axum::middleware::from_fn
を使用して合成できる middleware
関数に資格ロジックを分離することによって達成されることがよくあります。ただし、生の tower::Layer
と Service
を明示的にデモンストレーションするために、このパターンに従っています。
認証レイヤー
次に、AuthService
インスタンスを作成する AuthLayer
を定義します。
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, } } } // AuthLayer の tower::Layer トレイトを実装します 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(), ) } }
認証されたユーザー ID の抽出
認証されたユーザー ID をハンドラーで利用できるように、Axum 抽出機能を作成できます。
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 { // レイヤーが正しく適用されていれば、これは発生しないはずですが、セーフガードとして機能します。 Err(StatusCode::UNAUTHORIZED.into_response()) } } }
Axum との統合
最後に、このレイヤーを Axum ルーターに適用する方法を見てみましょう。
use axum::{routing::get, Router}; use tower::ServiceBuilder; // 認証が必要なハンドラー async fn protected_handler(AuthenticatedUser(user_id): AuthenticatedUser) -> String { format!("Hello, authenticated user: {}!", user_id) } // シンプルな公開ハンドラー async fn public_handler() -> &'static str { "This is a public endpoint." } #[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(), // 実際には config/env からロードします "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!("Listening on http://127.0.0.1:3000"); axum::serve(listener, app).await.unwrap(); }
仕組み
- リクエスト
AuthLayer
: HTTP リクエストが来ると、まずAuthLayer
にヒットします。 AuthLayer::layer
: レイヤーはAuthService
インスタンスを作成し、内部サービス(別のレイヤーまたは最終ハンドラーである可能性があります)をラップします。AuthService::call
:AuthService
のcall
メソッドが呼び出されます。- "Bearer" トークンを
Authorization
ヘッダーでチェックし、JWT 検証を試みます。 - JWT 検証が失敗したか、存在しない場合、
X-Api-Key
ヘッダーで定義済みの API キーをチェックします。 - どちらかが有効な場合、導出された
user_id
(または同様の識別子) をリクエストの拡張機能にrequest.extensions_mut().insert()
を使用して挿入します。 - 次に、
inner
サービスを呼び出し、リクエストがハンドラーに進むことを許可します。 - JWT または API キーのどちらも有効でない場合、リクエストチェーンをショートサーキットして、すぐに
401 Unauthorized
レスポンスを返します。
- "Bearer" トークンを
AuthenticatedUser
抽出機能:protected_handler
では、AuthenticatedUser
抽出機能を使用します。この抽出機能は、リクエスト拡張機能からString
(私たちのuser_id
) を取得します。存在する場合、ハンドラーはそれを受け取ります。それ以外の場合は、抽出機能は独自の拒否フローから401 Unauthorized
を返します。これは二重チェックとして機能します。
結論
Axum の tower::Layer
および tower::Service
トレイトを活用することで、JWT と API キーの両方の検証を処理できるカスタム認証レイヤーを正常に実装しました。このアプローチは認証ロジックを集中化し、ハンドラーをクリーンに保ち、コードの再利用性を促進します。この堅牢なミドルウェアパターンは、Axum で安全で保守可能な Web アプリケーションを構築するための基本です。明確に定義された境界とモジュラーコンポーネントに依存する構造化されたアプリケーションを忘れないでください。カスタムレイヤーはこれを達成するための強力なツールです。