NHN Cloud Meetup 編集部
JWT(JSON Web Token)の紹介
2020.06.17
14,260
はじめに
TOASTクラウドメッセージングプラットフォームサービスの1つであるPushに追加されたAPNs(Apple Push Notification service)JWT認証機能について、開発中に行った技術調査の内容を共有します。この記事は「JWTの概要」と「JWTをさらに詳しく」の2部構成になっています。「JWTの概要」では、JWTの構造や作成および検証方法について説明し、「JWTをさらに詳しく」では、JWTの特徴や使用事例について紹介します。JWTを利用した機能を開発する際や、JWTを使用する開発者の方に役立つ内容になれば幸いです。
気になった点があれば、LinkedInやGitHubからご連絡お願いいたします。
JWT(JSON Web Token)の概要
JWTは、一般的にクライアント-サーバー間、サービス-サービス間の通信時の認証(Authorization)に使用されるトークンです。URLに対して安全な文字列で構成されているため、HTTPのどの場所にも(URL、ヘッダー、…)位置することができます。これがJWTの正確な定義ではありませんが、詳細は「JWTをさらに詳しく」でもう一度説明します。
構造と作成
HEADER.PAYLOAD.SIGNATURE
ヘッダー(Header)、ペイロード(Payload)、署名(Signature)の3つの部分を、ドット(.)で区切りで連結させる構造です。
Header
JWTを検証するために必要な情報を持つJSONオブジェクトは、Base64 URL-Safeにエンコードされた文字列です。ヘッダー(Header)は、JWTの検証(Verify)方法の内容を含んでいます。参考までに、algは署名に使用するアルゴリズム、kidは署名に使用するキー(Public / Private Key)を識別する値です。
{ "alg": "ES256", "kid": "Key ID" }
上記のようなJSONオブジェクトを文字列に直列化し、UTF-8とBase64 URL-Safeにエンコードすると、次のようにヘッダーを作成することができます。
Base64URLSafe(UTF-8('{"alg": "ES256","kid": "Key ID"}')) -> eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9
Payload
ペイロード(Payload)の属性をクレームセット(Claim Set)と呼びます。クレームセットは、JWTに関する内容(トークンコンストラクターの情報、作成日時など)や、クライアントとサーバー間で送受信した値で構成されています。
{ "iss": "jinho.shin", "iat": "1586364327" }
上記のようなJSONオブジェクトを文字列で直列化し、Base64 URL-Safeにエンコードすると、次のようにペイロードを作成することができます。
Base64URLSafe('{"iss": "jinho.shin","iat": "1586364327"}') -> eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ
Signature
ドット(.)を区切り記号として、ヘッダーとペイロードを合わせた文字列に署名した値です。署名は、ヘッダーのalgに定義されたアルゴリズムと秘密鍵を使って作成し、Base64 URL-Safeにエンコードします。
Base64URLSafe(Sign('ES256', '${PRIVATE_KEY}', 'eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLnNoaW4ifQ'))) -> MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_MEXi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg
JWT
ドットを区切り記号にして、ヘッダー、ペイロード、署名を合わせると、JWTが完成します。
eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6ImppbmhvLn NoaW4ifQ.eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRC9.eyJpYXQiOjE1ODYzNjQzMjcsImlzcyI6Imp pbmhvLnNoaW4ifQ.MEQCIBSOVBBsCeZ_8vHulOvspJVFU3GADhyCHyzMiBFVyS3qAiB7Tm_ME Xi2kLusOBpanIrcs2NVq24uuVDgH71M_fIQGg
このように完成したJWTは、ヘッダーのalg、kid属性と公開鍵を使って検証が可能です。署名検証が成功すると、JWTのすべての内容を信頼できるようになり、ペイロードの値でアクセス制御や希望する処理を行うことができます。
実装方法
1. Public / Private Keyの生成
JWTの作成と検証に必要な公開鍵と秘密鍵を生成します。ここでは鍵生成アルゴリズムとして、ECDSA(Elliptic Curve Digital Signature Algorithm, 楕円曲線デジタル署名アルゴリズム)の1つであるES256(P-256 +SHA256)を使用します。ブロックチェーンで使用されるアルゴリズムですが、JWTでもよく使われています。
/** * Java APIを利用してES256キーを作成 * * @throws NoSuchAlgorithmException * @throws InvalidAlgorithmParameterException */ @Test public void test_pure_java_generateKeyPair() throws NoSuchAlgorithmException, InvalidAlgorithmParameterException { // Given final KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); // == P256 // When final KeyPair keyPair = keyPairGenerator.generateKeyPair(); // Then // Nothing Happen log.info("ecKey.publicKey: {}", Base64.encodeBase64String(keyPair.getPublic().getEncoded())); log.info("ecKey.privateKey: {}", Base64.encodeBase64String(keyPair.getPrivate().getEncoded())); }
公開鍵: MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEKY/2QKid9XCTRWCusDHUddgjWUTskYpY2wj WcgZ6vVfBlYRL0UhyLGbgBpucjGGjRAYoWRvn83f+GhAfiqmydw== 秘密鍵: MEECAQAwEwYHKoZIzj0CAQYIKoZIzj0DAQcEJzAlAgEBBCBfWNacqAsGHMnGbWiZXR81 mRvB4w/Icva0jGFPduwBxQ==
2. JWT作成
上記で作成されたキーをJavaの公開鍵(ECPublicKey)と秘密鍵(ECPrivateKey)にロードします。そして、ヘッダーとペイロードをエンコードして両者を合わせた文字列を秘密鍵で署名します。
private static ECPublicKey EC_PUBLIC_KEY; private static ECPrivateKey EC_PRIVATE_KEY; /** * PEM形式のキーをJavaのECPublicKey, ECPrivateKeyに変換 * * @throws NoSuchAlgorithmException * @throws InvalidKeySpecException */ @BeforeAll public static void beforeAll() throws NoSuchAlgorithmException, InvalidKeySpecException { final KeyFactory keyPairGenerator = KeyFactory.getInstance("EC"); // EC is ECDSA in Java EC_PUBLIC_KEY = (ECPublicKey) keyPairGenerator.generatePublic(new X509EncodedKeySpec(Base64.decodeBase64("上から作成した公開鍵"))); EC_PRIVATE_KEY = (ECPrivateKey) keyPairGenerator.generatePrivate(new PKCS8EncodedKeySpec(Base64.decodeBase64("上から作成した秘密鍵"))); } /** * Java APIを利用してJWTを作成 * * @throws NoSuchAlgorithmException * @throws IOException * @throws InvalidKeyException * @throws SignatureException */ @Test public void test_java_JWT() throws NoSuchAlgorithmException, IOException, InvalidKeyException, SignatureException { // Given final ObjectMapper objectMapper = new ObjectMapper(); final Map<String, Object> header = Maps.newLinkedHashMap(); header.put("kid", "キーID"); header.put("typ", "タイプ、一般的に'JWT'に設定"); header.put("alg", "アルゴリズム、一般的にES256使用"); final String headerStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(header)); final Map<String, Object> payload = Maps.newLinkedHashMap(); payload.put("iss", "JWTを作成した場所"); payload.put("iat", 0); // JWT作成時間 final String payloadStr = Base64.encodeBase64URLSafeString(objectMapper.writeValueAsBytes(payload)); // When // Java 9から可能(Java 8でエラーが発生 'java.security.NoSuchAlgorithmException: SHA256withECDSAinP1363Format Signature not available') // SHA256withECDSAと署名形式が異なり、一部のライブラリで検証に失敗する場合がある。 final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format"); signature.initSign(EC_PRIVATE_KEY); signature.update((headerStr + "." + payloadStr).getBytes()); byte[] signatureBytes = signature.sign(); final String signatureStr = Base64.encodeBase64URLSafeString(signatureBytes); final String jwt = headerStr + "." + payloadStr + "." + signatureStr; logJWT("java", jwt); // Then verifyJWTByJava(jwt, EC_PUBLIC_KEY); }
eyJhbGciOiJFUzI1NiIsImtpZCI6IktleSBJRCJ9.eyJpYXQiOjE1ODczNDk1MjcsImlzcyI6ImppbmhvLnNoaW4 ifQ.MEUCIGncUpdRpxO9glZi7aKrzXa06DFrWIfxPtEL7kLxcHtWAiEAqenTrf-nD8EucxhJBrBpZw5IuTDFxK1rtv20nF5SYZk
3. JWT検証(Verify)
公開鍵でJWTの署名を検証します。
public void verifyJWTByJava(String jwt, ECPublicKey publicKey) throws NoSuchAlgorithmException, InvalidKeyException, SignatureException { final String[] splitJwt = jwt.split("\\."); final String headerStr = splitJwt[0]; final String payloadStr = splitJwt[1]; final String signatureStr = splitJwt[2]; final Signature signature = Signature.getInstance("SHA256withECDSAinP1363Format"); signature.initVerify(publicKey); signature.update((headerStr + "." + payloadStr).getBytes()); assert signature.verify(Base64.decodeBase64(signatureStr)); }
ここでは、JWTの作成および検証過程が理解しやすいようにJava APIを利用しましたが、さまざまなJWTライブラリがあるため、開発時にはライブラリを使用した方が便利です。下記に、Nimubs、Auth0、jjwtなどを利用したJWTの作成や検証に関連するコードがありますので、開発時にご参考ください。
JWTをさらに詳しく
ここからは、JWTの特徴や使用事例について簡単に紹介します。
JWT、JWS、JWE、JWK、JWA …?
JWTは、URL、クッキー、ヘッダーのように使用できる文字を制限し、環境からの情報を送受信できるようにするデータ表現形式(Format)です。ところが、実際に私たちがJWTで利用する署名(Sign)や暗号化(Encryption)のスペックは、JWT下位のJWS(JSON Web Signature)とJWE(JSON Web Encryption)に存在します。分かりやすく説明すると、JWTは抽象クラス(Abstract Class)であり、JWSとJWEは抽象クラスを実装したコンクリートクラス(Concrete Class、具象クラス)と言えます。ほかにも、JWK(JSON Web Key)は、JSON形式で暗号化キーを表現したものであり、JWA(JSON Web Algorithm)は、JWS、JWE、JWKに使用されるアルゴリズムです。
以下は、JWT RFC-7519の一部を引用したものです。
JWS&Compact Serialization
私たちが一般的に使用するほとんどのJWTはJWSです。では、JWEはいつ使用するのかと疑問に思われるかもしれませんが、実際にはほとんど使用していないようです。なぜなら、JWEはその名前からもわかるようにデータを暗号化するのですが、私たちは一般的に通信時に拠点間の暗号化が必要な場合は、TLS(Transport Layer Security)を使用しているからです。したがって、JWEを使用してデータを暗号化する必要がありません。もう一度JWSに戻ってみましょう。先にJWTの構造で説明したHeader.Payload.Signature構造は、JWSの直列化の方法の1つであるコンパクトシリアル化形式に直列化したものです。整理すると、私たちが一般的に使用するJWTはJWSを使用し、JWS コンパクトシリアル化し直列化した文字列です。
以下は、JWS RFC-7515の一部を引用したものです。
Base64 URL-Safe!= Base64
Base64 URL-Safeエンコードは、基本的にBase64エンコードで「+」(plus)を「 – 」(minus)に、 ‘/’(slash)を「_」(underscore)に置き換えるエンコード方法です。これにより、JWTは設計が意図したとおり、URL、クッキー、ヘッダーなどをどこでも使用できる広い汎用性を持つようになりました。
Header&Payload
JWTのヘッダーはBase64でエンコードする前に、常にUTF-8にエンコードされた文字列でなければなりません。その理由は、ヘッダーが必ずJSONでなければならず、JSONのデフォルトのエンコードがUTF-8であるためです。正式名称は、JOSE(JSON Object Signing and Encryption)Headerです。では、ペイロードはJSONでなくても問題ないでしょうか?ペイロードは一般的にJSONを使用するだけで、必ずJSONでなければならないというわけではありません。したがって、ペイロードはヘッダーとは異なり、Base64 URL-Safeエンコードのみを行います。
以下は、JWS RFC-7515の一部を引用したものです。
自己完結(Self-Contained)とステータスレス(Stateless)
JWTはJWT自体に必要なすべての情報を含めることができます。ヘッダーはトークンの解釈方法を、ペイロードはトークンの内容や配信内容(ユーザー情報、権限、サービスに必要なデータ)を自由に入れます。また、署名でヘッダーとペイロードが改ざんされていないことを検証こともできます。サーバーはJWTを作成する際に、JWTに検証や認証時に必要な値を入れるため、JWTの状態を別途管理する必要はありません。たとえば、TOAST Meetup!のJWTペイロードを次のように定義すれば、TOAST Meetup!サーバーはJWT署名を検証したあと、権限を確認する際に追加の通信を行うことなくroles属性で進行することができます。
{ "iss": "meetup.toast.com", <- 発行者 "iat": 1586364327, <- 発行時間 "exp": 1586874996, <- 満了時間 "email": "email@email.com", <- ユーザーのメールアドレス "roles": ["read"] <- 読み取り権限 }
公開鍵暗号方式における署名(Signature)と暗号化(Encryption)
JWTでは基本的に公開鍵暗号方式(PKC、Public Key Cryptography)を使用します。非対称暗号方式を利用して公開鍵と秘密鍵を作成し、このキーを状況に応じて通信時に使用します。署名はデータのハッシュ値を秘密鍵で署名し、再び公開鍵で署名を検証(Verify)します。そして、署名は秘密鍵を持つ場所でのみ行うことができ、公開鍵を持つ場所ならどこでもデータの署名を検証することができます。一方、暗号化は、公開鍵でデータを暗号化(Encrypt)して秘密鍵でデータを復号化(Decrypt)します。公開鍵を持つ誰もがデータを暗号化してデータを送信することができますが、秘密鍵の場所でのみデータを復号化して内容を確認することができます。ここで注目すべきは、公開鍵暗号方式は、秘密鍵で暗号化したデータを公開鍵で復号化することができ、逆に公開鍵で暗号化したデータは、秘密キーで復号化できるという点です。当然ながら、秘密鍵で暗号化したものを秘密鍵で解除したり、公開鍵で暗号化したものを公開鍵で解くことはできません。
暗号化:公開鍵を持つ誰もがデータを暗号化できる。秘密鍵を持つごく少数のみデータを復号化して確認できる。
使用事例
JWT as API Key
AppleのPushメッセージ送信APIであるAPNs Provider APIは、2016年から認証のためJWTをサポートしています。従来は、1年間に使用可能な証明書をApple開発者コンソールから発行してもらい、mTLS(Mutual TLS)を使用してAPI認証を行っていました。JWTをAPI Keyとして使用しつつ、前述した性質によりAPNsはmTLS方式と比べ迅速にAPIを認証できるようになりました。JWTを作成してAPNs Provider APIを呼び出すプロセスは、以下のとおりです。
- Apple開発者コンソールからJWTの作成に必要なキーID(Key ID, kid)、発行者(Issuer, iss)、秘密鍵を発行する。
- API呼び出し前に発行された値を利用してJWTを作成する。
- API呼び出し時、AuthorizationヘッダーにJWTを追加する。
- APNsはAuthorizationヘッダーのJWTを認証する。
curl -X POST -H 'Authorization: bearer HEADER.PAYLOAD.SIGNATURE' -d '{"aps":{"alert":"Hello, JWT"}}' [https://api.push.apple.com/3/device/jinho-token](https://api.push.apple.com/3/device/jinho-token)
JWT in MSA
1. Access Token in MSA
一般的に、認証に応じたアクセス制御が必要なウェブサービスは、まずログインを通じてユーザー認証(Authentication)を行います。認証サービス(Authorizatioin Service)は、認証を通過したクライアントにアクセストークン(Access Token)を発行します。通常のアクセストークンは、認証を指す任意の文字列で構成されていますが、認証を参照するという意味で参照トークン(By Reference Token)と呼ばれています。モノリス(Monolith)アーキテクチャでは、参照トークンをアクセストークンとして使用しても大きな問題はありません。しかし、複数のサービス間のAPI呼び出しが発生するMSA(Micro Service Architecture)やクラウド環境では、アクセストークンが指す認証を確認するために、すべてのサービスが認証サービスと通信を行う必要があります。サービスが増えるほど、認証サーバーにかかる負荷が指数関数的に増える可能性があり、これはMSAの拡張性(Scalability)に負担を与えかねません。
2. JWT as Access Token in MSA
参照トークンの代わりに、JWTをアクセストークンとして使用することができます。JWTは独自に必要な情報をすべて含められるため、バリュートークン(By Value Token)と呼ばれます。JWTアクセストークンは、MSA(Micro Service Architecture)環境の認証とアクセス制御に適しています。サービスは、JWTに含まれる値に基づいて認証を確認することができます。サービスと認証サービスの通信は、JWT署名を認証するため公開鍵を照会します。しかし、JWTをアクセストークンとして使用した場合、メリットだけでなくデメリットも存在します。その例として、ユーザーの権限や情報が変更された場合は、JWTを新規に発行する必要があり、場合によってはJWTのサイズが大きくなることがあります。JWTのヘッダーやペイロードは、デコード(Decoding)するとすぐに内容を確認できるため、JWTのすべての値はクライアントに公開されてしまいます。外部に露出してはならない、またはセンシティブな値が公開されることがあり、セキュリティ上の問題につながる恐れがあります。
3. API Gateway between Access Token and JWT in MSA
クライアント、認証サービス、サービス間にAPI Gatewayを位置させると、JWTをクライアントに隠しつつサービス間の通信時に使用することができます。API Gatewayは、クライアントから受け取ったアクセストークンを認証サービスを通じてJWTで受け取り、アクセストークンの代わりにサービスに渡してくれます。
結論
JWTの広い汎用性、完全性の保証、必要な値を自己完結できる性質により、多くの場所でJWTが使用されており、今後ますます広範囲に使用されることでしょう。特に、MSAのサービス間通信時に認証サービスとの依存性を低減できるため、サーバーとサーバー間の通信に非常に有用です。MSA環境での認証の1つの方法としてJWTを使用すると、よりMSAに相応しいクラウドネイティブ(Cloud Native)なサービスを作ることができるでしょう。
参考
JWT RFC:https://tools.ietf.org/html/rfc7519
JWS RFC:https://tools.ietf.org/html/rfc7515
JWE RFC:https://tools.ietf.org/html/rfc7516
https://docs.oracle.com/javase/tutorial/security/apisign/gensig.html
https://docs.oracle.com/javase/tutorial/security/apisign/versig.html
https://ldapwiki.com/wiki/ ES256
https://developer.apple.com/documentation/usernotifications/setting_up_a_remote_notification_server/establishing_a_token-based_connection_to_apns
https://www.ibm.com/blogs/security-identity-access/oauth-jwt-access-token/
https://curity.io/resources/tutorials/howtos/advanced/jwt-assertion/
https://www.oauth.com/oauth2-servers/access-tokens/self-encoded-access-tokens/
https://auth0.com/blog/using-json-web-tokens-as-api-keys/
https://yos.io/2017/09/03/serverless-authentication-with-jwt/
https://techdocs.broadcom.com/content/broadcom/techdocs/us/en/ca-enterprise-software/layer7-api-management/api-management-oauth-toolkit/4-3/installation-workflow/configure-authentication/token-configuration/configure-jwt-access-tokens.html
https://medium.com/@rahulgolwalkar/pros-and-cons-in-using-jwt-json-web-tokens-196ac6d41fb4
https://www.scottbrady91.com/OAuth/OAuth-is-Not-Authentication
https://www.oauth.com/oauth2-servers/openid-connect/authorization-vs-authentication/