JWTってなんや
モダンなWebアプリケーションを実装しようとすると、とても頻繁に聞くジェットとかなんとかいうやつ!ご存知ですか!
この子、JWTって書いて「ジョット」って読むらしいですね!
まぁよく聞くし、ログインの時に送ったり、受け取ったりするのは知ってるんですが、なんでこんな文字列が利用されるのかようわからんし、どうしてこれでログインできるのか理解できなくないです?
ということで、ちょっと調べてみました。
理解出来てる人は多分この記事に用はないと思うんですが、間違っているところを指摘していただけるのはとてもありがたいので、ぜひ見ていってください!
概要
上での説明の通り、「JWT」は「ジョット」と呼ばれることが多いですが、正式名称は「JSON Web Token」でその略で、JWTと書きます。
情報を安全に受送信するためのもので、「認証」とか「認可」の話題で使われることが多いです。
特徴
他にも色々あると思いますが、以下のような特徴を持っています。
-
ステートレス
- サーバー側でセッション情報を保持する必要がない
- DBでログイン状態を管理する必要がない
- サーバー側でセッション情報を保持する必要がない
-
セキュア
- デジタル署名により改ざんを防ぐ
- 今回メインの話
- デジタル署名により改ざんを防ぐ
-
拡張性
- 必要な情報をペイロードに含めることができる
- メールアドレス・ユーザー名など
- 必要な情報をペイロードに含めることができる
-
URLセーフ
- HTTPヘッダーやURLパラメータとして使用できる
注意点
ただ、いくら複雑になっていようと、ただの文字列でしかない(しかも容易にデコード可能)ので、以下のような点に注意する必要があります。
-
暗号化されていないため、機密情報は含めるべきでない
- パスワードとか住所とか
-
トークンの有効期限管理が重要
- DBでログイン状態を管理していないので、有効期限を設定しなければそのトークンで一生ログインできる
JWTの構造
JWTはこのような文字列になります。
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
この文字列は、以下の3つのセクションからなっています。
- ヘッダー
- トークンのタイプとハッシュアルゴリズムを指定
- ペイロード
- ユーザー情報や有効期限などのクレーム(claims)を含む
- 署名
- ヘッダーとペイロードの情報を元に生成され、トークンの正当性を検証するために使用される
これらの3つの部分はBase64 URLでエンコードされ、ピリオドで区切られて1つの文字列として表現される
- Base64 URL: Base64の中でもURLセーフなもの
具体例を示してみましょう!
ヘッダー部
ヘッダー部はこのようになっており、後述する「署名」アルゴリズムを示します。
{
"alg": "HS256",
"typ": "JWT"
}
ペイロード部
ペイロード部は「認証・認可」などで利用する場合は基本的なユーザーの情報などを格納しておくことが多いです。
ユーザーの識別子(sub)、発行者(iss)、有効期限(exp)などが格納されることが一般的です。
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
で、署名って?
ということで、JWTはWeb上での認証・認可に利用できる便利な文字列、というのが分かりましたが、問題はこれが何故「安全に」利用できるのか、という点が大事かなと思います。
JWTでは「署名」と「検証」というフェーズを設けているため安全なんですが、ここがよく分からんなと思ったので深ぼってみます。
「署名」とはトークンの安全性を検証するためのもので、以下のような手順で署名されます。
- ヘッダー部とペイロード部をそれぞれbase64 URLでエンコードし、ドット(.)で連結する
- 連結された文字列を、ヘッダー部で指定されたアルゴリズムと、秘密鍵を利用してハッシュ化する
- ハッシュ値をBase64 URLでエンコードしたものが署名となる
もう少し具体的に見てみましょう!
ちょっとしたプログラムを書いてみます。
use std::error::Error;
use base64::{engine::general_purpose, Engine as _};
use serde_json::json;
fn main() -> Result<(), Box<dyn Error>> {
// JSONを作成
let header = json!({
"alg": "HS256",
"typ": "JWT"
});
let payload = json!({
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
});
// JSONを文字列に変換
let header_string = serde_json::to_string(&header)?;
let payload_string = serde_json::to_string(&payload)?;
// base64 URLエンコード
let header_url = general_purpose::URL_SAFE_NO_PAD.encode(header_string);
let payload_url = general_purpose::URL_SAFE_NO_PAD.encode(payload_string);
let base64_encoded = format!("{}.{}", header_url, payload_url);
println!("Base64 URL encoded JSON: {}", base64_encoded);
Ok(())
}
以下のようなヘッダーとペイロードをそれぞれbase64 URLでエンコードして、尚且つドット(.)で連結すると
{
"alg": "HS256",
"typ": "JWT"
}
と
{
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
}
の2つのJSONは
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMzQ1Njc4OTAifQ
という文字列になりました。
(署名していないので、Invalid Signature
の文字が表示されていますが、ヘッダーとペイロードは想定したものになっていますね。)
では、「署名」をつけていきましょう。
署名といっても難しいことはなく(アルゴリズムがどうこう言い出すととても難しいが、行うだけなら簡単という意味)先ほどのコードのOk(())
の前にちょっと付け加えてみます。
(利用するクレートはちょっと増やしています。)
use std::error::Error;
use base64::{engine::general_purpose, Engine as _};
use hmac::{Hmac, Mac};
use serde_json::json;
use sha2::Sha256;
fn main() -> Result<(), Box<dyn Error>> {
// JSONを作成
let header = json!({
"alg": "HS256",
"typ": "JWT"
});
let payload = json!({
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022
});
// JSONを文字列に変換
let header_string = serde_json::to_string(&header)?;
let payload_string = serde_json::to_string(&payload)?;
// base64 URLエンコード
let header_url = general_purpose::URL_SAFE_NO_PAD.encode(header_string);
let payload_url = general_purpose::URL_SAFE_NO_PAD.encode(payload_string);
let base64_encoded = format!("{}.{}", header_url, payload_url);
println!("Base64 URL encoded JSON: {}", base64_encoded);
// 秘密鍵
let secret = "secret";
// HMAC-SHA256でハッシュ化
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?;
mac.update(base64_encoded.as_bytes());
let result = mac.finalize();
// ハッシュ値をbase64 URLエンコード
let signature = general_purpose::URL_SAFE_NO_PAD.encode(result.into_bytes());
println!("署名: {}", signature);
// 完全なJWTを作成
let jwt = format!("{}.{}", base64_encoded, signature);
println!("完全なJWT: {}", jwt);
Ok(())
}
「秘密鍵」(今回はただのsecret
という文字列)と生成した文字列を利用し、ヘッダー部に示されたアルゴリズムを使って、ハッシュ化とbase64 urlエンコードを行い、.(ドット)で接続するだけです。
すると
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpYXQiOjE1MTYyMzkwMjIsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMzQ1Njc4OTAifQ.ub7srKZNrlkC9jpqvPSYMwZp8IZQN1ZBCuld49qCqOs
のような文字列が生成されますね!
公式サイトに貼り付けると見事Signature Verified
の文字が出てきました!
検証ってなにを、どうするの
JWTの作成自体は完了しましたが、このトークンを安全に利用するためには適切な検証が不可欠です!JWTが悪用される可能性について考えてみましょう!
例えば、トークンが何らかの方法で盗聴されたケースを想定します(HTTPSを使用せず、セキュリティの低い公共Wi-Fiに接続して作業するなど)。
JWTはBase64 URLでエンコードされているだけなので、簡単にデコードしてJSONの内容を確認できます。攻撃者は以下のような改ざんを試みる可能性があります。
- 有効期限の改ざん:
ペイロード内の有効期限を将来の日付(例: 10年後)に変更し、長期間アクセスを維持しようとする - ユーザー識別子の改ざん:
ペイロード内のsub(subject)クレームを別のユーザーのIDに変更し、他のユーザーになりすまそうとする
これらの改ざんが成功すると、攻撃者は長期間にわたって不正アクセスを続けたり、システム内の任意のユーザーになりすましたりする可能性があります。
しかし、JWTは「トークンの検証」というステップを含むため、単純な改ざんは防ぐことができます。JWTの署名部分は、ヘッダーとペイロードの内容に基づいて生成されるため、内容が改ざんされると署名が無効になります。
検証プロセスでは以下のような検証が行われます。
- 署名の検証: トークンが改ざんされていないことを確認
- 有効期限の確認: トークンが期限切れでないことを確認
- 発行者の確認: トークンが信頼できるソースから発行されたことを確認
- など…
このような検証を行うことで、改ざんされたトークンや期限切れのトークンを拒否し、セキュリティを維持できます。
ただし、有効なJWTが盗聴された場合、そのトークンの有効期限内であれば攻撃者はそのユーザーとしてログインできてしまいます。そのため、JWTの安全な管理(HTTPSの使用、適切な有効期限の設定、必要に応じたトークンの無効化など)も重要です。
実際にこの検証もやっていきましょう!
別のクレートを追加して、以下のようにコードを書き換えます。
また、トークンには上記の通り「有効期限」を設定するのが一般的なので、それも追加しましょう。
use std::error::Error;
use base64::{engine::general_purpose, Engine as _};
use hmac::{Hmac, Mac};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
use serde_json::json;
use sha2::Sha256;
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
name: String,
iat: usize,
exp: i64,
}
fn main() -> Result<(), Box<dyn Error>> {
// JSONを作成
let header = json!({
"alg": "HS256",
"typ": "JWT"
});
let payload = json!({
"sub": "1234567890",
"name": "John Doe",
"iat": 1516239022,
"exp": chrono::Utc::now().timestamp() + 1000,
});
// JSONを文字列に変換
let header_string = serde_json::to_string(&header)?;
let payload_string = serde_json::to_string(&payload)?;
// base64 URLエンコード
let header_url = general_purpose::URL_SAFE_NO_PAD.encode(header_string);
let payload_url = general_purpose::URL_SAFE_NO_PAD.encode(payload_string);
let base64_encoded = format!("{}.{}", header_url, payload_url);
println!("Base64 URL encoded JSON: {}", base64_encoded);
// 秘密鍵
let secret = "secret";
// HMAC-SHA256でハッシュ化
type HmacSha256 = Hmac<Sha256>;
let mut mac = HmacSha256::new_from_slice(secret.as_bytes())?;
mac.update(base64_encoded.as_bytes());
let result = mac.finalize();
// ハッシュ値をbase64 URLエンコード
let signature = general_purpose::URL_SAFE_NO_PAD.encode(result.into_bytes());
println!("署名: {}", signature);
// 完全なJWTを作成
let jwt = format!("{}.{}", base64_encoded, signature);
println!("完全なJWT: {}", jwt);
// トークンの検証
let validation = Validation::new(Algorithm::HS256);
let token_data = decode::<Claims>(
&jwt,
&DecodingKey::from_secret(secret.as_ref()),
&validation,
)?;
println!("検証成功!");
println!("クレーム: {:?}", token_data.claims);
Ok(())
}
利用したクレート一覧も記載しておきます。
[package]
name = "workspace"
version = "0.1.0"
edition = "2021"
[dependencies]
base64 = "0.22.1"
chrono = "0.4.38"
hmac = "0.12.1"
jsonwebtoken = "9.3.0"
serde = { version = "1.0.204", features = ["derive"] }
serde_json = "1.0.120"
sha2 = "0.10.8"
実行すると、以下のような表示が出ると思います!
Base64 URL encoded JSON: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjA0OTU5NTQsImlhdCI6MTUxNjIzOTAyMiwibmFtZSI6IkpvaG4gRG9lIiwic3ViIjoiMTIzNDU2Nzg5MCJ9
署名: AsE-wkwAaNgPIXICSOV-S0TKFAusP4k8OY40EzOo6mY
完全なJWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MjA0OTU5NTQsImlhdCI6MTUxNjIzOTAyMiwibmFtZSI6IkpvaG4gRG9lIiwic3ViIjoiMTIzNDU2Nzg5MCJ9.AsE-wkwAaNgPIXICSOV-S0TKFAusP4k8OY40EzOo6mY
検証成功!
クレーム: Claims { sub: "1234567890", name: "John Doe", iat: 1516239022, exp: 1720495954 }
以下のようなフロー生成と検証が行われたので、まとめとして載せておきます!
ちなみにRS256の場合…
署名の作成は秘密鍵を使い、検証には公開鍵を利用する方式も広く知られていると思いますが、その場合は以下のようなフローになるかなと思います。
-
秘密鍵と公開鍵を生成する
- 一般的にはRS256 (RSA-SHA256)などが使われる
-
秘密鍵を使ってJWTトークンを発行する
- ヘッダー部を作成する
- ペイロード部にはアプリケーションやユーザーの情報(個人情報は秘匿する)を格納する
- ペイロード部には有効期限(exp)や発行者(iss)なども含めるのが一般的
- ヘッダー部とペイロード部をbase64URLでエンコードし、ドットで接続する
- base64URLにエンコードされた文字列と秘密鍵を利用して、ヘッダー部に記載されたアルゴリズムでハッシュ値を生成し、これを署名とする
- ヘッダー部とペイロード部をbase64URLエンコードした文字列と署名をドットで接続し、これをJWTトークンと呼ぶ
- アプリケーションではクライアントにJWTトークンを払い出す
- 保護されたエンドポイントにJWTトークン付きでアクセスされる
- JWTトークンはAuthorizationヘッダのBearerトークンとして送信されるのが一般的
-
公開鍵を使ってJWTトークンを検証する
-
ヘッダー部とペイロード部に記載された署名アルゴリズムと、公開鍵を利用して検証する
-
秘密鍵 + ヘッダー部・ペイロード部で署名が生成されるので、どれか一つでも改竄されていれば、署名と一致しないため、改竄されたと判定できる
-
改竄されていない場合はペイロード部の各種クレームを確認し、想定されるissとの一致や有効期限(exp)の検証など、重要な情報の検証ができれば完了
-
まともに秘密鍵とか公開鍵を生成して検証するコードも置いておきます。
use std::fs;
use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
use rand::rngs::OsRng;
use rsa::{pkcs1::EncodeRsaPrivateKey, pkcs8::EncodePublicKey, RsaPrivateKey, RsaPublicKey};
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
sub: String,
exp: i64,
}
fn generate_rsa_pem() {
// 秘密鍵と公開鍵を作成する
// 署名アルゴリズムも指定することができるが、デフォルトはSHA-256
let mut rng = OsRng;
let bits = 2048;
let private_key = RsaPrivateKey::new(&mut rng, bits).expect("failed to generate a key");
let public_key = RsaPublicKey::from(&private_key);
// 秘密鍵をPEMフォーマットで出力
let private_key_pem = private_key
.to_pkcs1_pem(rsa::pkcs1::LineEnding::LF)
.unwrap();
// 公開鍵をPEMフォーマットで出力
let public_key_pem = public_key
.to_public_key_pem(rsa::pkcs1::LineEnding::LF)
.unwrap();
// ファイルに書き込む
fs::write("private_key.pem", private_key_pem).expect("Failed to write private key");
fs::write("public_key.pem", public_key_pem).expect("Failed to write public key");
}
fn main() {
// 秘密鍵と公開鍵を生成する
generate_rsa_pem();
// 秘密鍵と公開鍵を読み込む
let private_key = fs::read_to_string("private_key.pem").expect("Failed to read private key");
let public_key = fs::read_to_string("public_key.pem").expect("Failed to read public key");
// JWTに埋め込むクレームを作成
let claims = Claims {
sub: "user123".to_owned(),
exp: chrono::Utc::now().timestamp() + 1000,
};
// 秘密鍵を使ってJWTトークンを発行する
let token = encode(
&Header::new(Algorithm::RS256),
&claims,
&EncodingKey::from_rsa_pem(private_key.as_bytes()).unwrap(),
)
.unwrap();
println!("Generated JWT: {}", token);
// 公開鍵を使ってJWTトークンを検証する
let decoded_token = decode::<Claims>(
&token,
&DecodingKey::from_rsa_pem(public_key.as_bytes()).unwrap(),
&Validation::new(Algorithm::RS256),
)
.unwrap_or_else(|e| {
panic!("Failed to decode JWT: {}", e);
});
println!("Decoded JWT: {:?}", decoded_token.claims);
}
- 利用したクレート
[package]
name = "playglound"
version = "0.1.0"
edition = "2021"
[dependencies]
rsa = "0.9.6"
rand = "0.8.5"
jsonwebtoken = "9.3.0"
serde = "1.0.204"
chrono = "0.4.38"
終わりに
ということで無事JWTの生成と検証ができました!
ただ生成して検証するだけであればあまり怖くないということが分かりましたね!
こういう基盤的な知識を少しずつ増やしていくと「何をすると危ないのか」がちょっとずつ分けるようになってくる気がするので、一緒に学んでいきましょうー!
以下、余談ですが、内部のアルゴリズムをちょっとだけ覗いてみたのでメモとして残しておきます。
ちょっと深掘り
エンコード
-
jsonwebtoken-9.3.0/src/encoding.rs
- 表面の部分はとてもシンプルな実装
- 署名アルゴリズムを受け取る
- ヘッダーとペイロードをbase64でエンコードする
- ドットで接続する
- 指定されたアルゴリズムを基に署名する
- 表面の部分はとてもシンプルな実装
pub fn encode<T: Serialize>(header: &Header, claims: &T, key: &EncodingKey) -> Result<String> {
if key.family != header.alg.family() {
return Err(new_error(ErrorKind::InvalidAlgorithm));
}
let encoded_header = b64_encode_part(header)?;
let encoded_claims = b64_encode_part(claims)?;
let message = [encoded_header, encoded_claims].join(".");
let signature = crypto::sign(message.as_bytes(), key, header.alg)?;
Ok([message, signature].join("."))
}
-
jsonwebtoken-9.3.0/src/crypto/mod.rs
- 結構色々アルゴリズムがあるっぽい
pub fn sign(message: &[u8], key: &EncodingKey, algorithm: Algorithm) -> Result<String> {
match algorithm {
Algorithm::HS256 => Ok(sign_hmac(hmac::HMAC_SHA256, key.inner(), message)),
Algorithm::HS384 => Ok(sign_hmac(hmac::HMAC_SHA384, key.inner(), message)),
Algorithm::HS512 => Ok(sign_hmac(hmac::HMAC_SHA512, key.inner(), message)),
Algorithm::ES256 | Algorithm::ES384 => {
ecdsa::sign(ecdsa::alg_to_ec_signing(algorithm), key.inner(), message)
}
Algorithm::EdDSA => eddsa::sign(key.inner(), message),
Algorithm::RS256
| Algorithm::RS384
| Algorithm::RS512
| Algorithm::PS256
| Algorithm::PS384
| Algorithm::PS512 => rsa::sign(rsa::alg_to_rsa_signing(algorithm), key.inner(), message),
}
}
デコード
-
jsonwebtoken-9.3.0/src/decoding.rs
- デコードはそれなりに大変そう
pub fn decode<T: DeserializeOwned>(
token: &str,
key: &DecodingKey,
validation: &Validation,
) -> Result<TokenData<T>> {
match verify_signature(token, key, validation) {
Err(e) => Err(e),
Ok((header, claims)) => {
let decoded_claims = DecodedJwtPartClaims::from_jwt_part_claims(claims)?;
let claims = decoded_claims.deserialize()?;
validate(decoded_claims.deserialize()?, validation)?;
Ok(TokenData { header, claims })
}
}
}
...
fn verify_signature<'a>(
token: &'a str,
key: &DecodingKey,
validation: &Validation,
) -> Result<(Header, &'a str)> {
if validation.validate_signature && validation.algorithms.is_empty() {
return Err(new_error(ErrorKind::MissingAlgorithm));
}
if validation.validate_signature {
for alg in &validation.algorithms {
if key.family != alg.family() {
return Err(new_error(ErrorKind::InvalidAlgorithm));
}
}
}
let (signature, message) = expect_two!(token.rsplitn(2, '.'));
let (payload, header) = expect_two!(message.rsplitn(2, '.'));
let header = Header::from_encoded(header)?;
if validation.validate_signature && !validation.algorithms.contains(&header.alg) {
return Err(new_error(ErrorKind::InvalidAlgorithm));
}
if validation.validate_signature && !verify(signature, message.as_bytes(), key, header.alg)? {
return Err(new_error(ErrorKind::InvalidSignature));
}
Ok((header, payload))
}
-
jsonwebtoken-9.3.0/src/validation.rs
- 「検証」の本体のコード
pub(crate) fn validate(claims: ClaimsForValidation, options: &Validation) -> Result<()> {
for required_claim in &options.required_spec_claims {
let present = match required_claim.as_str() {
"exp" => matches!(claims.exp, TryParse::Parsed(_)),
"sub" => matches!(claims.sub, TryParse::Parsed(_)),
"iss" => matches!(claims.iss, TryParse::Parsed(_)),
"aud" => matches!(claims.aud, TryParse::Parsed(_)),
"nbf" => matches!(claims.nbf, TryParse::Parsed(_)),
_ => continue,
};
if !present {
return Err(new_error(ErrorKind::MissingRequiredClaim(required_claim.clone())));
}
}
if options.validate_exp || options.validate_nbf {
let now = get_current_timestamp();
if matches!(claims.exp, TryParse::Parsed(exp) if options.validate_exp
&& exp - options.reject_tokens_expiring_in_less_than < now - options.leeway )
{
return Err(new_error(ErrorKind::ExpiredSignature));
}
if matches!(claims.nbf, TryParse::Parsed(nbf) if options.validate_nbf && nbf > now + options.leeway)
{
return Err(new_error(ErrorKind::ImmatureSignature));
}
}
if let (TryParse::Parsed(sub), Some(correct_sub)) = (claims.sub, options.sub.as_deref()) {
if sub != correct_sub {
return Err(new_error(ErrorKind::InvalidSubject));
}
}
match (claims.iss, options.iss.as_ref()) {
(TryParse::Parsed(Issuer::Single(iss)), Some(correct_iss)) => {
if !correct_iss.contains(&*iss) {
return Err(new_error(ErrorKind::InvalidIssuer));
}
}
(TryParse::Parsed(Issuer::Multiple(iss)), Some(correct_iss)) => {
if !is_subset(correct_iss, &iss) {
return Err(new_error(ErrorKind::InvalidIssuer));
}
}
_ => {}
}
if !options.validate_aud {
return Ok(());
}
match (claims.aud, options.aud.as_ref()) {
// Each principal intended to process the JWT MUST
// identify itself with a value in the audience claim. If the principal
// processing the claim does not identify itself with a value in the
// "aud" claim when this claim is present, then the JWT MUST be
// rejected.
(TryParse::Parsed(_), None) => {
return Err(new_error(ErrorKind::InvalidAudience));
}
(TryParse::Parsed(Audience::Single(aud)), Some(correct_aud)) => {
if !correct_aud.contains(&*aud) {
return Err(new_error(ErrorKind::InvalidAudience));
}
}
(TryParse::Parsed(Audience::Multiple(aud)), Some(correct_aud)) => {
if !is_subset(correct_aud, &aud) {
return Err(new_error(ErrorKind::InvalidAudience));
}
}
_ => {}
}
Ok(())
}