Rustのactix-webでJWTを試してみた

今更ですがRustのactix-webでJWT(JSON Web Token)を試してみました。JWTとはJsonをBase64URLエンコードし、署名を付けることで改ざんを検知できるようなものになっていますす。JWTをクッキーにして渡すことでサーバサイドでセッションを保有しないということもできるのですが、2017年くらいにリリースされたPlayFramework2.6でこれがデフォルトになったのがインパクトがあったので印象に残っています。JWTのクッキーを使うことでサーバ間でのセッション情報の同期は必要がなくなりスケールアウトしやすくなるという特徴があるようです。

JWTとは

JWTについてですが、以下の記事を読んでみるとだいたい分かるかと思います。

techblog.yahoo.co.jp

以下のページのデバッガーの部分でJWTの確認ができます。 jwt.io jwt.ioを開いた直後はEncodedに以下のように表示されていたのですが、これは"."でヘッダーとペイロードと署名の3つに分かれています。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
 ↓
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 ← ヘッダー
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ ←ペイロード
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c ←署名

ヘッダーとペイロードはBase64URLエンコードされているだけなので、Base64URLデコードするだけで簡単に中身が確認できます。

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 → {"alg":"HS256","typ":"JWT"}
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ → {"sub":"1234567890","name":"John Doe","iat":1516239022}

ヘッダーのHS256は署名に使うアルゴリズムになります。それからペイロードですが、rfc7519ではタイトル(sub)と発効日(iat)に加えて有効期限(exp)なども必須化されているようです。

datatracker.ietf.org

次に署名部分ですがヘッダーで指定したHS256を使ったHMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), シークレット文字列)の計算結果になります。jwt.ioのページのデバッカーにあるシークレットの文字列を変えることで署名部分が変わることが確認できるかと思います。暗号化のアルゴリズムではサーバ証明書を用いたrs256を使うこともできます。

Rustで実装してみる

では次にRustで動作確認してみようと思います。Webフレームワークとしてactix-webを使用し、JWTのライブラリとしてjsonwebtokenを使用しました。

github.com

github.com

JWT発行

ログイン時にJWTを発行するようにしたのですが、関連の処理を抜き出すと以下のようになりました。

use actix_web::{get, middleware, post, web, App, Error, HttpResponse, HttpServer};
use chrono::Duration;
use jsonwebtoken::{encode, EncodingKey};
use serde::{Deserialize, Serialize};

const JWT_SECRET: &str = "secret";
const JWT_COOKIE_KEY: &str = "jwt";

#[derive(Debug, Serialize, Deserialize)]
struct Claims {
    exp: i64,
    uuid: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserForm {
    pub name: String,
    pub password: String,
}

fn enc_jwt(secret: &String, user: &models::User) -> String {
    let mut header = jsonwebtoken::Header::default();
    header.typ = Some(String::from("JWT"));
    header.alg = jsonwebtoken::Algorithm::HS256;
    let claim = Claims {
        exp: (chrono::Utc::now() + Duration::hours(8)).timestamp(),
        uuid: user.id.to_string(),
    };
    encode(&header, &claim, &EncodingKey::from_secret(secret.as_ref())).unwrap()
}

/// Finds user by username and password.
#[post("/login")]
async fn login(
    pool: web::Data<DbPool>,
    form: web::Json<models::UserForm>,
) -> Result<HttpResponse, Error> {
    let conn = pool.get().expect("couldn't get db connection from pool");
    web::block(move || actions::find_user_by_name(&form.name, &form.password, &conn))
        .await
        .map(|user_opt| match user_opt {
            Some(user) => {
                let cookie = actix_web::cookie::Cookie::build(
                    JWT_COOKIE_KEY,
                    enc_jwt(&JWT_SECRET.to_string(), &user),
                )
                .http_only(true)
                .finish();

                let ret = Ok(HttpResponse::Ok()
                    .header("Set-Cookie", cookie.to_string())
                    .content_type("text/plain; charset=utf-8")
                    .body("login success."));
                ret
            }
            _ => Ok(HttpResponse::NonAuthoritativeInformation()
                .content_type("text/plain; charset=utf-8")
                .body("login faied.")),
        })
        .map_err(|e| {
            eprintln!("{}", e);
            HttpResponse::InternalServerError().finish()
        })?
}

actions::find_user_by_nameでDBからユーザーを検索しているのですが、ユーザーが見つかったらenc_jwt(&JWT_SECRET.to_string(), &user)で以下の関数を呼び出しJWTを生成しています。ここでは暗号化のアルゴリズムHS256を指定し、ペイロード部分にはexpと任意の項目としてuser_idを保持するようにしています。動かしてみて気づいたのですが今回使用したクレートではexpが未指定だと無効なJWTとして扱われるようです。

fn enc_jwt(secret: &String, user: &models::User) -> String {
    let mut header = jsonwebtoken::Header::default();
    header.typ = Some(String::from("JWT"));
    header.alg = jsonwebtoken::Algorithm::HS256;
    let claim = Claims {
        exp: (chrono::Utc::now() + Duration::hours(8)).timestamp(),
        uuid: user.id.to_string(),
    };
    encode(&header, &claim, &EncodingKey::from_secret(secret.as_ref())).unwrap()
}

実際にログインのリクエストを投げてみると、以下のようにJWTが生成されcookieに保存されるのが確認できました。

curl -i --cookie-jar cookie -XPOST http://localhost:8080/login -H "Content-Type: application/json"  -d "{\"name\": \"jake\", \"password\": \"password\"}"
HTTP/1.1 200 OK
content-length: 14
content-type: text/plain; charset=utf-8
set-cookie: jwt=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE2MzIxNjM3MDUsInV1aWQiOiJkODgwNTEwOC01ZmIxLTQ1YzktYjNiMy03NWMyYjUzYjUwNmEifQ.Jv7dVXVaHKIst-w4aDdKLI6eR4SAReGy8vzhSnPp92Y; HttpOnly
date: Mon, 20 Sep 2021 10:48:25 GMT

login success.

JWT検証

次にJWTの検証ですが、ユーザー取得のリクエストで行うようにしていまして関連する処理を抜き出すと以下のようになりました。

fn dec_jwt(secret: &String, jwt: &String) -> Option<String> {
    let validation = jsonwebtoken::Validation::default();
    match jsonwebtoken::decode::<Claims>(
        &jwt,
        &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()),
        &validation,
    ) {
        Ok(c) => Option::Some(c.claims.uuid),
        _ => Option::None,
    }
}

/// Finds user by UID.
#[get("/user/{user_id}")]
async fn get_user(
    pool: web::Data<DbPool>,
    req: actix_web::HttpRequest,
    user_uid: web::Path<Uuid>,
) -> Result<HttpResponse, Error> {
    let user_uid = user_uid.into_inner();
    let conn = pool.get().expect("couldn't get db connection from pool");

    let jwt = get_cookie_map(req)
        .get(JWT_COOKIE_KEY)
        .cloned()
        .unwrap_or_default();

    match dec_jwt(&JWT_SECRET.to_string(), &jwt) {
        Some(_) => {
            let user_opt = web::block(move || actions::find_user_by_uid(user_uid, &conn))
                .await
                .map_err(|e| HttpResponse::InternalServerError().finish())?;

            match user_opt {
                Some(user) => Ok(HttpResponse::Ok().json(user)),
                _ => Ok(HttpResponse::NonAuthoritativeInformation()
                    .content_type("text/plain; charset=utf-8")
                    .body("user not found.")),
            }
        }
        _ => Ok(HttpResponse::NonAuthoritativeInformation()
            .content_type("text/plain; charset=utf-8")
            .body("invalid token.")),
    }
}


fn get_cookie_map(req: actix_web::HttpRequest) -> HashMap<String, String> {
    match get_cookie_string(req) {
        Some(cookie_str) => {
            let cookies: Vec<&str> = cookie_str.split(";").collect();
            cookies
                .iter()
                .fold(HashMap::<String, String>::new(), |mut acc, cur| {
                    let entry: Vec<&str> = cur.split("=").collect();
                    acc.insert(entry[0].to_string(), entry[1].to_string());
                    acc
                })
        }
        None => HashMap::new(),
    }
}

fn get_cookie_string(req: actix_web::HttpRequest) -> Option<String> {
    let cookie_header = req.headers().get("cookie");
    if let Some(v) = cookie_header {
        let cookie_string = v.to_str().unwrap();
        return Some(String::from(cookie_string));
    }
    return None;
}

ここではヘッダーに付与されたcookieからJWTを取得し、dec_jwt(&JWT_SECRET.to_string(), &jwt)で有効なJWTであればセットされたログインユーザーのIDを取得しており、以下の関数を呼び出しています。それからJWTが有効であればリクエストで受け取ったユーザーIDの情報を返すというものになっています。

fn dec_jwt(secret: &String, jwt: &String) -> Option<String> {
    let validation = jsonwebtoken::Validation::default();
    match jsonwebtoken::decode::<Claims>(
        &jwt,
        &jsonwebtoken::DecodingKey::from_secret(secret.as_ref()),
        &validation,
    ) {
        Ok(c) => Option::Some(c.claims.uuid),
        _ => Option::None,
    }
}

実際にcurlでリクエストを投げると、ユーザーが取得できることが確認できました。

curl -i --cookie cookie -XGET http://localhost:8080/user/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97
HTTP/1.1 200 OK
content-length: 81
content-type: application/json
date: Mon, 20 Sep 2021 10:53:25 GMT

{"id":"9e46baba-a001-4bb3-b4cf-4b3e5bab5e97","name":"bill","password":"password"}

また、このときcurlでログインリクエストを投げたときcookieの情報はcookie-jarでファイルに保持しており、ユーザー取得の時はcookieとしてそのファイルを指定するということをやっていたのですが、cookieを保存したファイルの内容を変更したら以下のようにJWTの不正が検知できていることが確認できました。

curl -i --cookie cookie -XGET http://localhost:8080/user/9e46baba-a001-4bb3-b4cf-4b3e5bab5e97
HTTP/1.1 203 Non Authoritative Information
content-length: 14
content-type: text/plain; charset=utf-8
date: Mon, 20 Sep 2021 10:59:42 GMT

invalid token.

JWTの仕組み自体はjsonをBase64URLエンコードして署名を付けるだけなので分かりやすいと思いますが、実際運用で使うとしたらどの情報をJWTで扱うかなどの判断は必要になりそうな気がしましたが、どうなのでしょうか。