Rustのactix-webでJWTを試してみた
今更ですがRustのactix-webでJWT(JSON Web Token)を試してみました。JWTとはJsonをBase64URLエンコードし、署名を付けることで改ざんを検知できるようなものになっていますす。JWTをクッキーにして渡すことでサーバサイドでセッションを保有しないということもできるのですが、2017年くらいにリリースされたPlayFramework2.6でこれがデフォルトになったのがインパクトがあったので印象に残っています。JWTのクッキーを使うことでサーバ間でのセッション情報の同期は必要がなくなりスケールアウトしやすくなるという特徴があるようです。
JWTとは
JWTについてですが、以下の記事を読んでみるとだいたい分かるかと思います。
以下のページのデバッガーの部分で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)なども必須化されているようです。
次に署名部分ですがヘッダーで指定したHS256を使ったHMACSHA256(base64UrlEncode(header) + "." +base64UrlEncode(payload), シークレット文字列)
の計算結果になります。jwt.ioのページのデバッカーにあるシークレットの文字列を変えることで署名部分が変わることが確認できるかと思います。暗号化のアルゴリズムではサーバ証明書を用いたrs256を使うこともできます。
Rustで実装してみる
では次にRustで動作確認してみようと思います。Webフレームワークとしてactix-webを使用し、JWTのライブラリとしてjsonwebtokenを使用しました。
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で扱うかなどの判断は必要になりそうな気がしましたが、どうなのでしょうか。