Rust – Actix Web JWT 認証認可 APIの作成
2024.02.25
どもです。
いやぁ、もう2月も終わるということで、本当に時間の流れを早く感じております。
業務ではRustでソフトウェアを作成しておりますが、趣味ではRustでサーバー開発も行っていまして、今回も前回に引き続きRustのサーバーフレームワークである、Actix Webについてツラツラと書いて行こうかと思います。
今回は、Actix Webを用いた簡易なJWT認証認可のAPIを作成していこうと思います。
JWTに関して
まず、JWTに関して軽くご説明をと。
JWT(JSON Web Token)は、Webアプリケーションやサービス間での安全な情報の伝達を目的とした、コンパクトで自己完結型のトークンフォーマットです。主に認証や情報交換に使用されます。JWTは、JSON形式で構造化されたデータを含んでおり、以下の三部分から構成されています。
- ヘッダー(Header):
- トークンのタイプ(通常はJWT)と、使用されている署名アルゴリズム(例えば、HMAC SHA256やRSA)が含まれます。
- JSON形式で記述され、Base64Url方式でエンコードされます。
- ペイロード(Payload):
- トークンに含める実際のデータが含まれます。
- 通常、ユーザー識別情報や有効期限などのクレーム(主張)が含まれます。
- クレームは三種類に分けられます:登録済みクレーム(予約された名前のセット)、公開クレーム(共有情報用)、プライベートクレーム(送信者と受信者の間で合意した情報)。
- これもJSON形式で記述され、Base64Url方式でエンコードされます。
- 署名(Signature):
- JWTの整合性と検証可能性を確保するための署名が含まれます。
- ヘッダーのエンコードされた値、ペイロードのエンコードされた値、秘密鍵(または公開鍵/秘密鍵ペア)を使用して生成されます。
- 署名アルゴリズムはヘッダーで指定されたものを使用します。
JWTは、そのコンパクトな形式と自己完結型の特性により、URL、POSTパラメータ、HTTPヘッダーなどを介して簡単に送信することができます。また、署名されているため、トークンの内容が途中で改ざんされることはなく、送信者がトークンの内容を保証します。ただし、ペイロードはエンコードされているだけで暗号化されているわけではないため、機密性が必要な情報は含めるべきではありません。
JWTは、特にシングルサインオン(SSO)やOAuthなどの認証フレームワークで広く使われています。という訳で、昨今では様々なシーンで幅広く用いられるJWTを用いてAPIを作成していきましょう。
クレートなど準備
それでは、早速作成していきましょう。
今回の作成したソースはこちらとなります。
ソースだけ確認したい方はこちらをご参照ください。
https://github.com/rust-game-samples/actix_web_sample/tree/main/jwt_auth
今回使用するcrate
Cargo.toml
[package] name = "jwt_auth" version = "0.1.0" edition = "2021" # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] serde = { version = "1.0", features = ["derive"] } actix-web = "4" actix-web-httpauth = "*" jwt-simple = "0.10" rust-crypto = "0.2" derive_more = "0.99.17" chrono = "0.4.31" [dependencies.rusqlite] version = "*" features = ["bundled"] [dependencies.uuid] version = "1.2.2" features = [ "v4", "fast-rng", "macro-diagnostics", ]
API
今回のAPIはシンプルに3つ。
- JWT アクセストークンを生成するAPI
- JWT リフレッシュを生成するAPI
- JWT認可のみアクセス可のAPI
エンドポイント
POST /token/
POST /token/refresh
GET /hello
ディレクトリ構成は以下の通り。
jwt_auth/src/ ├── api │ ├── mod.rs │ └── token.rs ├── constants.rs ├── error.rs ├── main.rs ├── model │ ├── mod.rs │ └── token.rs └── utils ├── mod.rs └── token.rs
main.rsには、APIの処理を行う「create_token」「refresh_token」「hello」メソッドを用意。
src/main.rs
#[actix_web::main] async fn main() -> std::io::Result<()> { HttpServer::new(|| { App::new() .service( web::scope("/token") .service(create_token) .service(refresh_token), ) .service(hello) }) .bind(("127.0.0.1", 8080))? .run() .await }
create_token
まずは、「create_token」メソッドから。
通常は、usernameとpasswordをリクエストで受け、処理を行うのですが、今回は簡易な認証ということで、usernameとpasswordは固定で取り敢えず、リクエストが来るとすべて通過するようにしておきます。
src/api/token.rs
#[post("/")] async fn create_token(data: web::Json<CreateTokenRequest>) -> Result<HttpResponse, ServiceError> { if !(data.username == "daisuke" && data.password == "1234") {
usernameとpasswordの認証が通過できると、アクセストークンとリフレッシュトークンを発行します。create_access_tokenメソッドと、create_refresh_tokenメソッドはsrc/utilsに定義。
let token = create_access_token()?; let refresh = create_refresh_token()?; Ok(HttpResponse::Ok().json(CreateTokenResponse { token, refresh_token: refresh, }))
create_custom_claimsメソッドでclaimを作成します。
今回は、有効期限をアクセストークンは15分間、リフレッシュトークンには24時間に設定しておきます。アクセストークンの有効期限が切れるとリフレッシュトークンを用いてアクセストークンを更新するフローとなります。
utils/token.rs
pub fn create_access_token() -> Result<String, ServiceError> { let claims = create_custom_claims(false, Duration::from_mins(15)); claims_authenticate(claims) } pub fn create_refresh_token() -> Result<String, ServiceError> { let claims = create_custom_claims(true, Duration::from_hours(24)); claims_authenticate(claims) }
create_custom_claims、claims_authenticate
jwt-simpleクレートを用いて独自のClaimsを作成し、JWTを作成していきます。
アクセストークンのis_refreshフラグはfalse、リフレッシュトークンのis_refreshフラグはtrueを受取、それぞれの有効期限を受取り、is_refreshフラグを含めたカスタムなclaimsを作成します。
create_custom_claimsメソッド
fn create_custom_claims(is_refresh: bool, duration: Duration) -> JWTClaims<TokenClaims> { Claims::with_custom_claims( TokenClaims { refresh: is_refresh, }, duration, ) .with_subject(1) .with_jwt_id(Uuid::new_v4().to_string()) }
カスタムclaimsを含めたpayloadを元にJWTの生成を行っていきます。
create_token_key()で、 JWTのSignatureを生成し、claims_authenticateメソッドにて、claimsを引数で渡しJWTを作成していきます。
claims_authenticateメソッド
fn claims_authenticate(claims: JWTClaims<TokenClaims>) -> Result<String, ServiceError> { if let Ok(token) = create_token_key().authenticate(claims) { Ok(token) } else { Err(ServiceError::InternalServerError { error_message: MESSAGE_PROCESS_TOKEN_ERROR.to_string(), }) } }
create_token_keyメソッドで、秘密鍵を元に jwt-simpleクレートのHS256アルゴリズムを用いて、Signatureを生成します**。**「”your_secret_key”」が独自で設定する秘密鍵となり、こちらが漏れてしまったりすると、トークンの不正生成などができてしまうので、外部に漏れないように注意が必要となります。
今回は簡易なので「”your_secret_key”」の固定文字列で生成。
fn create_token_key() -> HS256Key { HS256Key::from_bytes(b"your_secret_key") }
といった流れで、ヘッダー(Header)、ペイロード(Payload)、署名(Signature)、を含んだJWT(JSON Web Token)の生成が行えました。
生成したアクセストークン(token)とリフレッシュトークン(refresh_token)を、クライアントのレスポンスにJSONで返却します。クライアント側はこちらを用いて認可APIにアクセスします。
{ "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDY4NDEwODUsImV4cCI6MTcwNjg0MTE0NSwibmJmIjoxNzA2ODQxMDg1LCJzdWIiOiIxIiwianRpIjoiOGNhZDk2MmItYWVhYy00MmMzLWFjNTgtOGYwNTdkODg0YmQzIiwicmVmcmVzaCI6ZmFsc2V9.mm1hUxevMWoWaNhSCfzKEmry6117Fc355AMxnSZ6E6A", "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE3MDY4NDEwODUsImV4cCI6MTcwNjkyNzQ4NSwibmJmIjoxNzA2ODQxMDg1LCJzdWIiOiIxIiwianRpIjoiZWMxMmRhZTItZWE1Ni00NGZjLWI3ZmQtNTk4NDkyMjc3YmExIiwicmVmcmVzaCI6dHJ1ZX0.OLyYaST_mkIMbZYUU6-QCfT6dYT3URmoUERGQJ5Kwl4" }
hello
helloのAPIは、JWT認可されたAPIとなるので、HttpRequestのheaderセクションに生成されたJWTを付与してリクエストする形となります。
先程生成されたアクセストークンを用いて、headerセクションのAuthorizationに付与しリクエストを行います。
Authorization [JWT token]
「 [JWT token]」は、Bearerの文字列、半角スペースの後に生成されたアクセストークンををふよします。
Bearer eyJhbGciOiJIUzI1NiIs...
helloメソッドはアクセストークンを検証し、問題なければ「”Authorized Access Success! Hello World!”)」の文字列を返却する流れとなります。
#[get("/hello")] async fn hello(req: HttpRequest) -> Result<HttpResponse, ServiceError> { let token = get_token(req)?; let claims = claims_verify_token(&token)?; if claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); } Ok(HttpResponse::Ok().body("Authorized Access Success! Hello World!")) }
まずは、get_tokenでHttpRequestのheaderセクションのAuthorizationに付与されたJWTを取り出します。Authorization のValueより、「Bearer」または「bearer」を判定、そちらの文字列 をtrimし、トークンを取り出します。
utils/token.rs
pub fn get_token(req: HttpRequest) -> Result<String, ServiceError> { if let Some(auth_header) = req.headers().get(AUTHORIZATION) { if let Ok(auth_str) = auth_header.to_str() { if is_auth_header_valid(auth_header) { let token = auth_str[6..auth_str.len()].trim(); return Ok(token.to_string()); } } } Err(ServiceError::InternalServerError { error_message: MESSAGE_PROCESS_TOKEN_ERROR.to_string(), }) }
is_auth_header_validメソッド
pub fn is_auth_header_valid(auth_header: &HeaderValue) -> bool { if let Ok(auth_str) = auth_header.to_str() { return auth_str.starts_with("bearer") || auth_str.starts_with("Bearer"); } false }
claims_verify_tokenメソッドでリクエストされたトークンを検証します。
create_token_keyメソッドは先程のトークン生成でも用いたメソッドとなりますので、ここでSignatここが異なると認可エラーとなる形になります。
claims_verify_token
pub fn claims_verify_token(token: &str) -> Result<JWTClaims<TokenClaims>, ServiceError> { if let Ok(claims) = create_token_key().verify_token::<TokenClaims>(token, None) { Ok(claims) } else { Err(ServiceError::Unauthorized { error_message: MESSAGE_TOKEN_MISSING.to_string(), }) } }
refresh
最後は、リフレッシュトークンを生成する流れとなります。アクセストークンは15分の有効期限しかないため、有効期限が切れた場合、リフレッシュトークンを用いて新たにアクセストークンを生成します。
先程のhelloAPIとほぼ同じですが、異なる点はpayloadのclaimsに設定した「refresh」がTrueかどうかになります。
#[post("/refresh")] async fn refresh_token(req: HttpRequest) -> Result<HttpResponse, ServiceError> { let request_token = get_token(req)?; let claims = claims_verify_token(&request_token)?; if !claims.custom.refresh { return Err(ServiceError::BadRequest { error_message: MESSAGE_REFRESH_TOKEN_ERROR.to_string(), }); } let token = create_access_token()?; Ok(HttpResponse::Ok().json(CreateRefreshTokenResponse { token })) }
refreshトークンの場合は、refreshがtrue。
refreshトークンのpayload
{ "iat": 1706841085, "exp": 1706841145, "nbf": 1706841085, "sub": "1", "jti": "8cad962b-aeac-42c3-ac58-8f057d884bd3", "refresh": true }
refreshトークンではない場合は、エラーを返却。
if !claims.custom.refresh {
refreshトークンを検証し、問題なければ、最初にも実行したcreate_access_tokenで再びアクセストークンを生成します。
pub fn create_access_token() -> Result<String, ServiceError> { let claims = create_custom_claims(false, Duration::from_mins(15)); claims_authenticate(claims) }
成功するとアクセストークンが返却されますので、クライアント側で更新処理を行えば再び認可リクエストが行う形となります。
といった形でざっくり説明で申し訳ありませんでしたが、簡易のJWT認証認可のAPIが作成できました。
次回は、MongoDBを用いて登録されたユーザーでの認証認可API作成を行って行きたいと思います。
ではではぁ。