JWT認証

JWT(JSON Web Token)は、関係者間で情報を安全に転送するためのオープンスタンダード(RFC 7519)です。JSONオブジェクト形式でクレームを表現するコンパクトでURLセーフな方式であり、通常は認証や情報交換に使用されます。

JWTは以下の3つの部分で構成されます:

  • ヘッダー(Header) - トークンのタイプと使用する署名アルゴリズムを指定
  • ペイロード(Payload) - ユーザーID、ロール、有効期限などのクレームを含む
  • 署名(Signature) - メッセージが転送中に改ざんされていないことを検証するために使用

認証フローにおけるJWTの典型的な使用方法:

  1. ユーザーログイン後、サーバーがJWTトークンを生成
  2. トークンがクライアントに返され保存される(通常はlocalStorageやcookie内)
  3. 以降のリクエストでは、クライアントがAuthorizationヘッダーでこのトークンを送信
  4. サーバーがトークンの有効性を検証し、アクセスを許可

JWT認証機能を提供するミドルウェアを実装しています。

暗号プロバイダーの選択

Salvo 0.95 には、2 つの選択可能な JWT feature があります。

  • jwt-auth は AWS-LC provider を使用します。
  • jwt-auth-ring は RustCrypto provider を使用します。

通常のビルドでは、どちらか一方だけを有効にしてください。独立した ring feature は Salvo の rustls 統合用に ring provider を選択するもので、JWT 認証を有効にはしません。以下の例では jwt-auth を使用しています。 RustCrypto を使用する場合は、次のように設定します。

salvo = { version = "0.95", default-features = false, features = ["server", "http1", "ring", "jwt-auth-ring"] }

Cargo の feature 統合によって両方の JWT provider が有効になった場合は、 main の先頭で、jsonwebtoken の直接操作や JWT middleware の初期化より 前に salvo::jwt_auth::install_crypto_provider() を呼び出してください。 以下の例でも、provider の選択を確定的にするためにこの初期化を行っています。

サンプルコード

main.rs
Cargo.toml
use jsonwebtoken::{self, EncodingKey};
use salvo::http::{Method, StatusError};
use salvo::jwt_auth::{ConstDecoder, QueryFinder};
use salvo::prelude::*;
use serde::{Deserialize, Serialize};
use time::{Duration, OffsetDateTime};

const SECRET_KEY: &str = "YOUR SECRET_KEY";

#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct JwtClaims {
    username: String,
    exp: i64,
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().init();
    salvo::jwt_auth::install_crypto_provider()
        .expect("install the JWT crypto provider before first use");

    let auth_handler: JwtAuth<JwtClaims, _> =
        JwtAuth::new(ConstDecoder::from_secret(SECRET_KEY.as_bytes()))
            .finders(vec![
                // Box::new(HeaderFinder::new()),
                Box::new(QueryFinder::new("jwt_token")),
                // Box::new(CookieFinder::new("jwt_token")),
            ])
            .force_passed(true);

    let acceptor = TcpListener::new("0.0.0.0:8698").bind().await;
    Server::new(acceptor)
        .serve(Router::with_hoop(auth_handler).goal(index))
        .await;
}
#[handler]
async fn index(req: &mut Request, depot: &mut Depot, res: &mut Response) -> anyhow::Result<()> {
    if req.method() == Method::POST {
        let (username, password) = (
            req.form::<String>("username").await.unwrap_or_default(),
            req.form::<String>("password").await.unwrap_or_default(),
        );
        if !validate(&username, &password) {
            res.render(Text::Html(LOGIN_HTML));
            return Ok(());
        }
        let exp = OffsetDateTime::now_utc() + Duration::days(14);
        let claim = JwtClaims {
            username,
            exp: exp.unix_timestamp(),
        };
        let token = jsonwebtoken::encode(
            &jsonwebtoken::Header::default(),
            &claim,
            &EncodingKey::from_secret(SECRET_KEY.as_bytes()),
        )?;
        res.render(Redirect::other(format!("/?jwt_token={token}")));
    } else {
        match depot.jwt_auth_state() {
            JwtAuthState::Authorized => {
                let data = depot.jwt_auth_data::<JwtClaims>().unwrap();
                res.render(Text::Plain(format!(
                    "Hi {}, have logged in successfully!",
                    data.claims.username
                )));
            }
            JwtAuthState::Unauthorized => {
                res.render(Text::Html(LOGIN_HTML));
            }
            JwtAuthState::Forbidden => {
                res.render(StatusError::forbidden());
            }
        }
    }
    Ok(())
}

fn validate(username: &str, password: &str) -> bool {
    username == "root" && password == "pwd"
}

static LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html>
    <head>
        <title>JWT Auth Demo</title>
    </head>
    <body>
        <h1>JWT Auth</h1>
        <form action="/" method="post">
        <label for="username"><b>Username</b></label>
        <input type="text" placeholder="Enter Username" name="username" required>

        <label for="password"><b>Password</b></label>
        <input type="password" placeholder="Enter Password" name="password" required>

        <button type="submit">Login</button>
    </form>
    </body>
</html>
"#;