Contents
Axum の基本構造とミドルウェア概念
Axum アプリは「Router → Service → Layer」という三層構造で動作します。この流れを理解すると、リクエストの前後に処理を差し込むミドルウェアがシンプルに実装でき、認証・ロギング・トレーシングといった横断的な機能を全ルートへ一括適用できます。
Router と Service の役割
- Router: エンドポイントとハンドラ(Service)を紐付ける。
- Service: 実際にリクエストを処理する関数や構造体。
- Layer: Service をラップし、リクエスト/レスポンスに対して前処理・後処理を行う。
ミドルウェア実装例(ロギング)
以下は Layer として実装したシンプルなロガーです。middleware::from_fn で関数ベースのミドルウェアを作り、.layer() に渡すだけで全ルートに適用できます。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
use axum::{ async_trait, extract::FromRequest, http::{self, Request, StatusCode}, middleware::{self, Next}, response::IntoResponse, routing::get, Router, }; async fn hello() -> &'static str { "Hello, Axum!" } // ロギングミドルウェア async fn logger<B>(req: Request<B>, next: Next<B>) -> impl IntoResponse where B: Send + 'static, { println!("{} {}", req.method(), req.uri().path()); // 次の Service(ハンドラ)へ処理を委譲 let response = next.run(req).await; response } let app = Router::new() .route("/", get(hello)) .layer(middleware::from_fn(logger)); |
ポイント:Layer が Service を包むだけなので、認証・トレーシング等の横断処理も同様に実装できます。
JWT の概要と jsonwebtoken クレートの導入
JSON Web Token(JWT)は ヘッダー・ペイロード・署名 の三部構成で、認可情報を安全かつ自己完結的に運搬できる標準規格です。本節では Rust で広く利用されている jsonwebtoken クレートの基本的な使い方と、実装時に注意すべき設定項目を紹介します。
Cargo.toml の正しい記述
テスト以外で署名検証を無効化するオプションは絶対に使用しないようにしてください。代わりにデフォルトの機能だけを有効にします。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# Cargo.toml [dependencies] axum = "0.7" jsonwebtoken = { version = "9", default-features = false, features = ["dangerous_insecure_disable_signature_validation"] } # ← **テスト時のみ**、本番ビルドでは削除 serde = { version = "1.0", features = ["derive"] } dotenvy = "0.15" chrono = { version = "0.4", features = ["serde"] } [features] # デフォルトのビルドは安全設定のみ default = [] test-insecure = ["jsonwebtoken/dangerous_insecure_disable_signature_validation"] |
- 本番環境では
--no-default-featuresまたは--features ""でビルドし、危険オプションを除外します。 - テストコードだけ
#[cfg(feature = "test-insecure")]として有効化すれば、安全性を損なうことなくテストが可能です。
トークン生成・検証の最小実装例
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey}; use serde::{Deserialize, Serialize}; #[derive(Debug, Serialize, Deserialize)] struct Claims { sub: String, exp: usize, } // ---------- エンコード(HS256) ---------- pub fn create_hs256(secret: &[u8], user_id: &str, ttl_seconds: i64) -> String { let expiration = chrono::Utc::now() .checked_add_signed(chrono::Duration::seconds(ttl_seconds)) .expect("日時計算に失敗") .timestamp() as usize; let claims = Claims { sub: user_id.to_owned(), exp: expiration, }; encode( &Header::default(), // HS256 がデフォルト &claims, &EncodingKey::from_secret(secret), ) .expect("トークン生成に失敗") } // ---------- デコード(HS256) ---------- pub fn verify_hs256(secret: &[u8], token: &str) -> Result<Claims, jsonwebtoken::errors::Error> { let validation = Validation::default(); // HS256 用デフォルト設定 decode::<Claims>(token, &DecodingKey::from_secret(secret), &validation).map(|c| c.claims) } |
重要:secret は環境変数やシークレット管理サービスから取得し、コードにハードコーディングしないこと。
鍵管理・アルゴリズム選択・ローテーション手順
対称鍵(HS256)と非対称鍵(RS256)は 運用コスト・安全性・パフォーマンス がそれぞれ異なります。この節では実際のプロダクトでどちらを採用すべきか判断する指標と、キーの安全な保管・ローテーション手順を具体的に示します。
HS256 と RS256 の比較(正しいパフォーマンス情報)
| 項目 | HS256 (対称) | RS256 (非対称) |
|---|---|---|
| 鍵の構成 | 1 本のシークレット | 秘密鍵 + 公開鍵のペア |
| 計算コスト | 高速(HMAC は CPU に優しい) | 低速(RSA の署名・検証は比較的重い) |
| 鍵配布方式 | 全サービスが同一シークレットを保持 | 公開鍵だけを共有すれば OK、秘密鍵は安全に保管 |
| ローテーション | キー変更時に全サービスで更新が必要 | 秘密鍵のみローテーションし、公開鍵は再配布不要 |
| 主な利用シーン | 内部マイクロサービス間・短期トークン | 外部クライアントやサードパーティ連携 |
アルゴリズム選択の指針
- 内部限定か外部公開か
- 同一組織内だけで完結するマイクロサービス間通信 → HS256(シンプル&高速)
-
外部クライアントやサードパーティへトークンを配布 → RS256(鍵管理が容易)
-
トークンの有効期間
- 短時間(15〜60 分)のアクセストークンは HS256 でも十分安全。
-
長期的に保持するリフレッシュトークンや重要権限付与は RS256 が推奨。
-
インフラ要件
- ハードウェアリソースが限られる環境(IoT デバイス等) → HS256
- 高いセキュリティ基準が求められる金融系・医療系 → RS256 もしくは ES256
鍵の安全な保管方法
| 環境 | 推奨手段 |
|---|---|
| ローカル開発 | .env ファイル+ dotenvy(.gitignore に必ず追加) |
| Docker コンテナ | Docker Secret (docker run -e JWT_SECRET=$(cat /run/secrets/jwt_secret)) |
| Kubernetes | Secret リソースを envFrom または volume マウントで注入 |
| CI/CD (GitHub Actions 等) | ワークフローの Secrets 機能から環境変数として提供 |
| サーバーレス (AWS Lambda) | AWS Secrets Manager / Parameter Store から取得 |
鍵ローテーションの具体的手順(例:RS256)
- 新鍵ペア生成
bash
openssl genpkey -algorithm RSA -out private_new.pem -pkeyopt rsa_keygen_bits:2048
openssl rsa -pubout -in private_new.pem -out public_new.pem - シークレット管理サービスに登録(例:AWS Secrets Manager)
private_current→private_oldに名前変更-
private_newを新しいシークレットとして保存 -
アプリケーション側の切り替えロジック
rust
// 環境変数でキー名を取得
let key_name = env::var("JWT_PRIVATE_KEY_NAME").unwrap_or_else(|_| "private_current".into());
let private_key_pem = secret_manager.get(&key_name).await?; - 段階的デプロイ
- 新しい公開鍵 (
public_new.pem) をすべてのサービスに配布。 -
旧鍵で署名されたトークンは 一定期間(例:7 日) 有効に保ち、徐々に新鍵へ移行。
-
古鍵の廃止
- 移行完了後、
private_oldとpublic_oldを安全に削除。 - ログで「旧鍵使用トークンが残っていない」ことを確認。
認証ミドルウェアと保護ルートの実装
Axum では Extractor(FromRequest 実装)を用いることで、リクエストごとに JWT 検証ロジックを自動的に走らせられます。この節では AuthUser 抽出子の作り方と、保護されたハンドラへの適用例を示します。
Extractor (FromRequest) の実装手順
- 認証情報構造体 を定義
- エラー型 として
IntoResponseを実装し、401 を返す async_traitでFromRequestを実装し、ヘッダー取得・トークン検証・クレーム抽出を行う
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
use axum::{ async_trait, extract::{FromRequest, RequestParts}, http::StatusCode, response::{IntoResponse, Response}, }; use jsonwebtoken::{decode, Validation, DecodingKey}; #[derive(Debug)] pub struct AuthUser { pub sub: String, } // エラーレスポンス(認証失敗時に 401 を返す) struct AuthError; impl IntoResponse for AuthError { fn into_response(self) -> Response { (StatusCode::UNAUTHORIZED, "認証に失敗しました").into_response() } } #[async_trait] impl<B> FromRequest<B> for AuthUser where B: Send, { type Rejection = AuthError; async fn from_request(req: &mut RequestParts<B>) -> Result<Self, Self::Rejection> { // Authorization ヘッダー取得 let auth_header = req .headers() .get(axum::http::header::AUTHORIZATION) .and_then(|v| v.to_str().ok()) .ok_or(AuthError)?; // "Bearer <token>" 形式の検証 let token = auth_header.strip_prefix("Bearer ").ok_or(AuthError)?; // 秘密鍵は環境変数またはシークレットストアから取得(例: HS256) let secret = std::env::var("JWT_SECRET").map_err(|_| AuthError)?; let decoded = decode::<crate::Claims>( token, &DecodingKey::from_secret(secret.as_bytes()), &Validation::default(), ) .map_err(|_| AuthError)?; Ok(AuthUser { sub: decoded.claims.sub, }) } } |
保護されたハンドラの例
|
1 2 3 4 5 6 7 8 |
async fn dashboard_handler(user: AuthUser) -> impl IntoResponse { format!("ようこそ、{} さん!", user.sub) } // ルーティング設定 let app = Router::new() .route("/dashboard", axum::routing::get(dashboard_handler)); |
AuthUser が取得できなかった場合は自動的に AuthError が返り、ステータスコード 401 と共に「認証に失敗しました」というメッセージがクライアントへ送信されます。
エラーハンドリングとリフレッシュトークンの概念
| ケース | 処理内容 |
|---|---|
| 署名不正 | decode がエラー → AuthError (401) |
| 有効期限切れ | Validation::leeway を設定しつつ、期限超過は同様に 401 |
| リフレッシュトークン使用 | /refresh エンドポイントで長期 RSA 署名トークンを受け取り、検証後に新しい短期 HS256 アクセストークンを発行 |
実装のヒント
- リフレッシュトークンは DB にハッシュ保存し、盗難時にローテーションできるようにする。
- トークン失効情報(ブラックリスト)は Redis 等の高速キャッシュで管理するとスケールしやすい。
テスト・デプロイ・ベストプラクティス
実装した認証ロジックは ユニットテスト と 統合テスト の両方で検証し、CI に組み込むことで品質を保証します。また、本番環境へ安全にデプロイするためのシークレット管理手順も合わせて示します。
ユニットテストと統合テストの書き方
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 |
#[cfg(test)] mod tests { use super::*; use axum::{ body::Body, http::{Request, StatusCode}, Router, }; use tower::ServiceExt; // `oneshot` 用 #[tokio::test] async fn login_success_returns_token() { let app = Router::new().route("/login", axum::routing::post(login_handler)); let payload = serde_json::json!({ "username": "alice", "password": "secret" }); let response = app .oneshot( Request::builder() .method("POST") .uri("/login") .header("content-type", "application/json") .body(Body::from(payload.to_string())) .unwrap(), ) .await .unwrap(); assert_eq!(response.status(), StatusCode::OK); // 返却 JSON に access_token が含まれることを検証(省略可) } #[tokio::test] async fn protected_route_rejects_without_token() { let app = Router::new() .route("/dashboard", axum::routing::get(dashboard_handler)); let response = app .oneshot(Request::builder().uri("/dashboard").body(Body::empty()).unwrap()) .await .unwrap(); assert_eq!(response.status(), StatusCode::UNAUTHORIZED); } } |
- ユニットテストは
create_hs256・verify_hs256のロジックだけを対象にし、期限切れや不正署名のシナリオも網羅する。 - 統合テストはミドルウェア全体が期待通りに動くか(例:ログイン後のトークンで保護ルートへアクセスできる)を確認。
CI/CD におけるシークレット注入表
| 環境 | 注入方法 |
|---|---|
| GitHub Actions | secrets.JWT_SECRET を env: で渡す |
| GitLab CI | $CI_JOB_TOKEN 経由で Vault から取得し、環境変数に展開 |
| Azure Pipelines | キーボルトのシークレットを variables にマッピング |
| CircleCI | context に登録したシークレットを自動注入 |
ベストプラクティスまとめ
- コードベースに鍵情報は絶対に残さない(
.gitignoreに.env*を必ず追加)。 - ローテーション手順をドキュメント化し、定期的に実行。古い鍵は一定期間だけ有効にしてバックワード互換性を保つ。
- アルゴリズムは RS256 か ES256(楕円曲線)を本番で推奨。HS256 は内部限定の短期トークンに留める。
- エラーメッセージは情報漏洩防止のため簡潔にし、スタックトレース等はログにのみ出力。
まとめ
- Axum の
LayerとServiceを組み合わせれば、認証・ロギング・トレーシングを統一的に実装できる。 jsonwebtokenは安全なデフォルト設定で利用し、テスト以外での署名無効化オプションは削除すること。- アルゴリズム選択は パフォーマンスと運用コスト を基準に判断し、鍵ローテーション手順を明文化しておく。
- Extractor による認証ミドルウェアで保護ルートを簡潔に実装し、エラーハンドリングとリフレッシュトークンの流れも併せて設計する。
- ユニット/統合テスト・CI への組み込み、シークレット管理まで網羅すれば、本番環境でも安心してデプロイできる JWT 認証基盤が完成します。
ぜひこの手順をローカルで試した後、ステージング環境へ展開し、実運用に向けた最終調整を行ってください。安全かつ高速な認証システムが、あなたの Rust アプリケーションの価値を大きく高めてくれるはずです。