おはようございます。ritou です。
5月下旬ぐらいにチーム内勉強会としてJSON Web Token(JWT)についてわいわいやりました。
その際に作成した資料に簡単な説明を添えつつ紹介します。
このブログではJWTについて色々と記事を書いてきましたが、その範囲を超えるものではありません。
ちょっとだけ長いですが、ちょっとだけです。お付き合いください。それでは始めましょう。
今回の勉強会では、JWTについて概要、仕様紹介という基本的なところから、業務で使っていくにあたって気をつけるべき点といったあたりまでカバーできると良いなと思っています。
JSON Web Token 概要
まずは概要から紹介していきます。
JSON Web Tokenの定義とはということで、RFC7519のAbstractの文章を引用します。
JSON Web Token (JWT) is a compact, URL-safe means of representing claims to be transferred between two parties.
JSON Web Token (JWT) は、2者間で転送されるクレームを表現するためのコンパクトで URLSafeな方法です。
補足します。
JWTはとにかく色々なデータ、例えば構造化されたものからバイナリデータまでを複数のサービス、システム間でやりとりするため、URLセーフな文字列にエンコードする仕組みです。
また、そのエンコード結果の文字列自体をJWTと呼ぶこともあります。
さらに、JSON Web Signatureという署名をつける仕組みを利用することで改ざん検知が可能になります。JSON Web Encryptionという暗号化を施すことにより、センシティブなデータのやりとりが可能になります。
この2つのうち、よく使われているのを見かけるのがJSON Web Signatureの方でしょう。
ということでここからはJSON Web Signatureについての解説を行います。
(ここからの文中でJWTと書いているものは署名つきのJWSを含みます。)
そもそも今回、なぜJWTを取り上げたのかにも触れておきます。
誕生のきっかけということで、JWTはOpenIDファウンデーションのWGで行われたID連携やソーシャルログインを実現するための仕組みであるOpenID Connectの仕様策定に合わせ、IETFのJOSE(読みはホゼ) WGにて仕様策定が開始されました。
Google、Appleでサインインの裏側で動いているOpenID Connectの中で、ID Tokenというユーザーの属性情報や認証時の情報などをやり取りする際にJWTが利用されています。
もともとSAMLというXMLベースのID連携の仕組みで使われていたXML署名という仕組みがあらゆるユースケースに対応できるようにした結果複雑になってしまったという経緯から、JWTはより容易に実装できてコンパクトに表現できるセキュリティトークンを目指しました。
私はこのOpenID Connectの仕様策定にContributorとして参加していたため、この後説明しますが2015年に複数のRFCになるまでの経緯を横目でチラ見していて最近のJWT単独の普及についても注目しております。
ということで今回、ネタとして取り上げました。
ここからはJWTのユースケースをいくつか見ていきましょう。
サービス、システム間のやりとりに使うという点を意識して、まずはJWTの発行者、そして受信者(利用者)という観点で同一か別かで整理していきます。
発行者、受信者が同一のユースケースとして、WebアプリケーションにおけるセッションCookieへのJWT適用があります。
(厳密にはmonorithなサービスであれば発行者、受信者が同一となりやすいしょうけれども、規模が大きくなれば発行者、受信者が別になるケースもあり得るでしょう。)
以前からHTTP Cookieにセッション情報を詰め込むという、Cookie Storeとか呼ばれてきたものがあり、以前から様々なWebApplicationFrameworkにてオレオレ署名つきエンコーディングなどで実装されていることはご存知の方が多いかもしれません。これにJWTを利用することができるでしょうというお話です。
さらに、セッションIDをHTTP Cookieに持ってDBなりでセッション情報を管理する実装についてもJWTを適用可能です。セッションIDと発行者、発行日時、有効期限などを合わせて署名つきのJWTに含むことで、ブラウザ上で手元にあるHTTP Cookieの内容をいじって挙動を調べてみるようなことを困難にできます。
処理の流れとしては、Webサーバーがセッション情報に更新があるたびにHTTP Responseとして発行されたものをブラウザがハンドリングし、次のHTTPRequestにてWebサーバーに送ります。
さらに、WebアプリケーションのHTML Formを用いたPOSTリクエストなどで利用されるCSRF対策トークンにもJWTを適用できます。
この例の方が発行者、受信者が同一である感じが出そうですね。
CSRF対策トークンで重要なのはセッションとの紐付けです。セッションIDそのものではなく鍵を用いて生成したハッシュ値などを格納しておくのが良いでしょう。
発行者、受信者が別であるユースケースとしては、WebAPIを利用する際の認可用トークンがあります。OAuthのアクセストークンで、発行者であるユーザー認証やアクセス管理を司るサーバー、受信者であるサービスを提供するサーバーが分かれている場合を想像してください。
よりシンプルなユースケースとして、
- 署名つきのAPIリクエストにJWTを適用
- 外部サービスにユーザー情報などを渡す際に署名つきJWTを利用
というものがあります。
ここまでゆるゆるとユースケースを紹介してきましたが、一旦署名つきのJWTを利用するメリット、デメリットを整理します。
柔軟なデータを文字列にしてやりとり可能なところ、署名とメタデータを組み合わせることで、発行者、受信者、有効期限の検証が可能となる点はとても魅力的だと思います。
一方で、暗号化ではないため、どんな値を含むのかを誰でも知ることができるという点、含まれる情報によるデータサイズの増加には気をつける必要があります。
JWTが使われている理由として、やはり標準化の強みがあるでしょう。
仕様はRFCになり、ライブラリも各種言語で出揃っています。
OAuth/OpenID Connectをはじめとした標準化プロトコルでの採用実績もありますし、ここまで紹介したように単体で使われるユースケースも増えています。これまで独自の署名つきエンコードを使っていたところからの移行などで利用するケースもあるでしょう。
さらに、最近RFC化されたJWT BCP(Best Current Practice)のように、良くも悪くも枯れてきたと言うか、ここ抑えると安全な実装を実現できると言うのが明確になっている点も様々なところで使われている理由だと思います。
JWTのユースケースでセッションCookieやAPIアクセス時のトークンの例を紹介しました。
このあたりで混乱を呼んでいるものの一つに、Single Page Application のログイン状態の実現方法があります。
この文脈では JWT はエンコードフォーマットだけではなく WebStorage にJWT形式のトークンを保存してAPIアクセスする方式 というような解釈がされがちです。
そのような解釈のまま、HTTP CookieにセッションIDを保存してHTTP Request時に送られるやり方 と比較されたりするんですが、比較の軸が増えてしまって話がまとまりません。
- セッションIDだけなのか色々詰め込む内包型とするのか
- 文字列自体に情報を詰め込むのか、ブラウザである程度編集可能なHTTP Cookieの属性値を使うのか
のような比較軸の細かい整理をすることが重要です。
ritou.hatenablog.com
そして、このCookieとの比較でもよく言及される点として、「JWT=ステートレスでなければならない」「JWTを使う場合は必要な情報を詰め込み、その内容だけで機能を実現させる必要がある」という一種の信仰のようなものがあるのではないかと考えています。
これはとても勿体無いなと感じていて、確かに情報を内包できるという特性は持っていますが、データストアなどを参照するためのキーを持ってはいけないというわけではありません。
例えば単純な識別子を用いたやりとりだったものにJWTを適用するだけで、有効期限や検証という機能を追加できる のです。この考えは既に色々なユースケースで使われているものではありますが、ステートフルなユースケースへも適用できるということを今一度意識してもらえると良いのかなと思っています。
ritou.hatenablog.com
ritou.hatenablog.com
JWTの概要は以上です。
仕様解説
ここからは、JWTの仕様について簡単に説明していきます。
JWTに関するコアな仕様が定義されているRFCは5個あります。
その内訳についてですが、
- 「いつ、誰が、誰に」発行したものか、というようなサービス/システム間のやりとりで必要なメタデータを標準化したもの : RFC7519 JSON Web Token
- 署名をつけ、検証するために必要なパラメータや実装方法 : RFC7515 JSON Web Signature
- (今回は紹介しませんが)暗号化周り : RFC7516 JSON Web Encryption
- 暗号化や署名利用時の鍵の種類、表現 : RFC7517 JSON Web Key
- アルゴリズム : RFC7518 JSON Web Algorithms
となっています。全てを細かく読んでいくのは時間がかかって大変なので、よく使われている署名つきJWT、JSON Web Signatureについて要点をかいつまんで紹介します。
RFC7515 JSON Web Signature
この文字列をご覧ください。皆さんには普通の文字列に見えるかもしれませんが、私にはこう見えます。
先頭が "eyJ" から始まり、"." で連結されています。
この文字列の正体としては、RFC7515で定義されている "JWS Compact Serialization" という単一の署名を持つフォーマットであり、世の中で広く使われているのはこのフォーマットです。
仕様ではより複雑なこともできるようになっており、複数の受信者に向けてそれぞれ向けの署名を含むことができる "JWS JSON Serialization" というのも定義されているのですが、使われているのをほとんど見たことがありません。
改めて先ほどの文字列に戻ります。
最初の eyJ から始まる文字列ですが、Encoded Headerと呼ばれています。
これはBase64URL EncodeしたJWS Header であり、JWS Header は JSON 形式で表現されます。このJSONの開始タグのあたりで {"
から始まる文字列を Base64URL Encodeした時に "eyJ" となるわけです。
内容は自身がどのような種類なのか、署名に関するパラメータを含みます。
次にPayloadです。2番目の文字列が Encoded Payloadと呼ばれています。
Base64URL EncodeされたJWS Payloadです。
JWS Headerとは異なり、JWS Payloadはやりとりしたいデータそのものであり、必ずしもJSON形式である必要はありませんが、「誰が」「いつ」「誰に」というような標準的なクレーム(パラメータ)の値が RFC7519 で定義されています。
これと署名検証の仕組みを組み合わせることで「改ざんされていないことが保証できる」文字列となるため、ユースケースはグッと広がります。
最後にSignatureです。これもEncoded Signatureと呼ばれています。
Header, Payloadと同様にBase64URL EncodeしたJWS Signatureであり、署名のアルゴリズムはRFC7518、鍵の表現がRFC7517にて定義されています。
JSON Web Signatureの特徴として、署名生成時に利用するBase Stringに対してパラメータのソートなどの正規化が不要であるという点があります。
Twitterなどで使われている OAuth 1.0 という仕組みでは署名つきのリクエストが定義されているのですが、Base Stringを生成するまでの正規化の処理はなかなか面倒です。
JSON Web SignatureではEncoded Header と Encoded Payloadを "." で連結させた値をBaseStringとして利用します。
RFC7519 JSON Web Token
RFC7519 JSON Web Tokenについて解説します。
Payloadに含まれるキーバリューの値をJWTクレームと呼びます。
- JWT自身の識別子である
jti
- host名やサービス内の識別子など、JWT発行者の識別子である
iss
- JWTの受信者、利用者の識別子である
aud
という「誰が誰に...」という値が定義されています。
さらに、「いつ発行され、いつからいつまで有効なのか」を表現するための
- 発行日時である
iat
- 有効期限
exp
- この日時以降、有効となる開始日時である
nbf
という値が定義されています。
ここまで紹介した値は「汎用的なクレームとして、識別が必要ならば使うと便利だよ。」という意味合いでこのRFC内では基本的にオプショナルです。
JWTを利用するプロトコルや各種プロファイルで「この値を必須として含む」などの定義がされます。
最初にJWT誕生のきっかけとなったと紹介した「Googleでログイン」を実現するためのプロトコルである「OpenID Connect」でユーザー情報などを伝達するために使われるID TokenはJWTクレームを利用します。
iss
がGoogleのアカウント周りを管理するドメイン
aud
はGoogleアカウントを受け入れるサービスに client_id
として割り当てられた識別子
sub
はGoogleアカウントの識別子
iss
, exp
で有効期限を表現
という値がOpenID Connect独自で定義された値と一緒に含まれています。
ここで紹介した値は全て利用必須なものではなく、これらを利用するプロトコルなどにより決まります。
JWTを扱うライブラリによっては、ここで定義されている値の検証を行うものもあります。
検証の粒度や利用方法については利用するライブラリのドキュメントや実装を追う必要があることに注意は必要でしょう。
RFC7518 JSON Web Algorithms
RFC7518 JSON Web Algorithmsについて解説します。
ここではJSON Web Signatureの署名生成に利用されるアルゴリズムについて取り上げます。
一番上にある none
については、JWT自体とは別のところで署名生成/検証や暗号化が行われるようなケースで利用します。これではBase64URL Encodeぐらいしかしてないことはここまでの説明でお分りいただけるかと思います。
HSXXX
となっているものは、SHA-256, 384, 512といったハッシュ関数を秘密の共有鍵と組み合わせて署名を生成し、同じ計算をして比較することで検証します。
鍵配送が不要な、JWTの発行者、検証者が同一の場合に使いやすいアルゴリズムです。
RSXXX
, PSXXX
はRSAの公開鍵暗号方式とハッシュ関数を組み合わせたデジタル署名を利用するアルゴリズムです。
秘密鍵を用いて署名を生成、公開鍵を用いて署名の検証ができるため、発行者から受信者で公開鍵を渡したり、発行者が公開鍵を提示することで発行者/受信者が別のケースで利用できます。
JWTが使われ始めた頃は、「公開鍵暗号だったら RS256
」 という感じで使われていましたが、最近はその状況も変わりつつあります。
ESXXX
は公開鍵暗号として楕円曲線暗号を用います。RSAよりも鍵長の短さや処理速度の面で優れているため、最近のプロトコルではこちらを選択するものも見受けられます。
アルゴリズムと鍵管理の関係は密です。
秘密鍵を公開して後悔しないように気をつけて利用しましょう。
RFC7517 JSON Web Key
RFC7517 JSON Web Keyについて解説します。
鍵に関する仕様として、鍵の表現と鍵のセットの表現が定義されています。
アルゴリズムに対応した鍵はどのように表現されるのか、そして鍵のローテーションやアルゴリズム変更時のスムーズな移行を考慮する際に鍵のセットをどう表現するかが重要となります。
鍵表現のためのパラメータがいくつか定義されています。
このうち重要なのは、鍵の種類である kty
, 対応するアルゴリズムである alg
, 鍵の識別子である kid
です。
他には鍵の利用用途(署名 or 暗号化)である use
やさらにその鍵の使い方まで踏み込んだ key_ops
があったり、X.509形式の証明書に関連するパラメータがあります。
HSXXX
で利用されるバイナリの秘密鍵はkty=oct
として表現されます。
RSXXX
で利用されるRSAの秘密鍵はkty=RSA
として表現されます。長いです。
公開鍵は少しすっきりと表現されます。alg
, kid
の値も含まれていますね。
ESXXX
で利用されるECDSAの公開鍵はkty=EC
として表現されます。
鍵のセットは keys
として鍵のリストを持つことで表現されます。
これは先ほども紹介したOpenID ConnectでGoogleから受け取ったJWTの署名検証を行うために公開されている、公開鍵のセットの値です。Appleなども同様に公開しています。
これらのユースケースとして、有効な鍵情報をjwks_url
として公開したり、設定ファイルにこの形式で保存するといったものがあります。
弊チームでは設定ファイルなどでPEM形式などを利用していますが、コンパクトに表現できるのであればこのような形式での保存も使っていきたいと思っています。
実装のポイント
仕様の解説は以上にして、実装のポイントを紹介します。
ここまで紹介した仕様がRFCになってから5年ぐらいが経ち、良くも悪くも枯れた技術となってきました。
そして最近、RFC8725 JSON Web Token Best Current PracticesとしてJWT実装のベストプラクティスがRFCとなりました。
Qiitaで解説記事を書きましたのでお時間がある方はみてください。
RFC 8725 JSON Web Token Best Current Practices をざっくり解説する - Qiita
個人ブログにも少し書いたのですが...
JWTを安全に使っていくためのポイントとして、難しい暗号化処理以外のものとして
- Payloadに含む情報はよく考えて決める
- 署名検証処理を確実に
- 複数のJWTを利用する際は、用途を指定/判別して排他的に検証する
というものが書いてあります。
Payloadに含む情報について、チーム内の勉強会ではせっかくJWTを使っていてもPayloadに含む情報が足りず、そもそもの目的を満たせていない例を紹介しました(が割愛します)。
これはユースケースに依存するというか、必要な情報が決まるものなのでよく考えましょう。
署名検証については「ちゃんとした」ライブラリを使うことで問題は回避できますが、簡単にこんな攻撃があったよというのを紹介します。
個人的には、最後の用途のあたりが一番重要かと思っているのでスライドのページを割いて紹介します。
JWTの署名検証時の脆弱性として有名なのが、alg
とSignatureの値を改ざんして検証をパスさせようとするものです。
JWT側の alg
に沿って署名検証をしてしまうと、alg=none
を指定してSignature部分を取り去って送られたものを受け入れてしまったり、alg=RS256
からHS256
に変えられて公開鍵を共有秘密鍵としてハッシュ値をSignatureに指定されたりしたものを受け入れてしまう例が報告されています。
これらを回避するためには、JWTに含まれるalg
をそのまま署名検証に使うのではなく、kid
に紐付く鍵に紐付くalg
を署名検証に利用することが重要です。
その両者が異なる場合はJWTの改ざんが考えられますが、これは署名検証で検知できます。
ここからはJWTの用途と署名検証について説明します。
ポイントは
です。
JWTで用途を表現するために使えるパラメータはいくつかあります。
まずはHeaderのtyp
です。
例として、Googleが連携済みのサービスにユーザーのセキュリティイベントをJWT形式で通知する仕組みがあって、そこでは secevent+jwt
という値を指定しています。
次に、Headerのkid
で用途ごとに鍵を分けるという運用もあります。
他には、Payloadにuse
やusage
のような値を含んで用途を表現するというやり方があります。
ritou.hatenablog.com
これらのどれを使うかについては、利用するサービスの設計やライブラリの状況から柔軟に判断すべきです。
例えば機能単位で完全に鍵をわけられるような場合はkid
に用途を含むことで排他的に検証が可能でしょう。
鍵の管理には手を出せないがHeaderを自由に指定できる場合はtyp
の値を利用できるでしょう。ちなみに、ライブラリでこの検証をするものもあります。
Payloadしか指定できない場合はPayloadに独自の値を指定する必要があるでしょう。
ライブラリでもサポートされていない部分なので、実装漏れがないように気をつける必要があります。
最後に紹介しますが、JWTの署名検証では Header -> Signature -> Payload
という順番に扱っていくため、署名検証の処理回数が多い場合はHeaderの参照だけで判断できる方がPayloadの値を参照するよりも負荷がちょっとだけ抑えられるのではと思います。
JWT生成/検証デモ
最後にJWTの簡単な生成と検証のデモをやってみます。
(予想通り時間もなかったのでざっと流しました。)
目的としては、簡単なやつでもこれまで紹介した仕様について手を動かすことで理解が深まるかなと言うところです。
業務では、ライブラリを使いましょう。Elixirでは KittenBlue っていうのが便利ですよきっと。
ここではJWT用のライブラリを使わず、これらの3つの機能を使ってやってみます。
Base64URL Encode/Decode は padding のオプションがあったりするので注意が必要です。
生成
生成の流れを説明します。
一言で言うと、Header, Payload の値を生成した後、Signature の値を生成して連結します。
1つずつ見ていきましょう。最初はHeaderです。
ここでJWTに含むHeaderパラメータは3つです。
typ
ではハンズオンっぽい文字列を用意して、用途を判別できるようにします
alg
署名アルゴリズムにはHMAC SHA-256を使います
kid
鍵管理を意識しましょうと言ったはずだ
まずは JSON Encode します。
それを Base64URL Encode すると Encoded Header が出来上がりです。
次にPayloadです。
ここでは送りたいデータとしてこんな感じのを用意します。
せっかく紹介したんだからRFC7519で定義されている iss
とか aud
とか exp
の値を使えばよかったなと後から思いましたがとりあえずこれでヨシ!
Headerと同様に、JSON Encode と Base64URL Encode したら Encoded Payload の出来上がりです。
次はSignatureを生成します。
Signature生成のもとになる文字列は、Encoded HeaderとEncoded Payloadを "." で連結させたものです。正規化不要!簡単ですね。
今回は鍵として "THIS_IS_SAMPLE_KEY_FOR_JWT_HANDSON" とかを用意して、それで HMAC-SHA256 した値をBase64URL EncodeするとEncoded Signatureの出来上がりです。
最後に連結させます。
Elixirだとこんな感じです。
iex(1)> encoded_header = %{"alg"=>"HS256", "kid"=>"handson01", "typ"=>"handson+JWT"} |> Jason.encode!() |> Base.url_encode64(padding: false)
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0"
iex(2)> encoded_payload = %{"Foo"=>"Bar", "Hoge"=>"Fuga"} |> Jason.encode!() |> Base.url_encode64(padding: false)
"eyJGb28iOiJCYXIiLCJIb2dlIjoiRnVnYSJ9"
iex(3)> signature_base_string = encoded_header <> "." <> encoded_payload
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0.eyJGb28iOiJCYXIiLCJIb2dlIjoiRnVnYSJ9"
iex(4)> encoded_signature = :crypto.hmac(:sha256, "THIS_IS_SAMPLE_KEY_FOR_JWT_HANDSON", signature_base_string) |> Base.url_encode64(padding: false)
"Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM"
iex(5)> jwt = encoded_header <> "." <> encoded_signature <> "." <> encoded_signature
"eyJhbGciOiJIUzI1NiIsImtpZCI6ImhhbmRzb24wMSIsInR5cCI6ImhhbmRzb24rSldUIn0.Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM.Tp0zcg2nEA1r94EijoymQTTVMwH6iaLoOpxEZf3KcVM"
公開鍵暗号系のalgを利用する場合は、Headerのパラメータで指定してSignature計算のところで適切な署名生成関数を利用します。
生成については割と簡単なので、Qiitaなどで生成を自前でやるのを見かけますが、複数箇所でハードに使っていくとなると共通処理がまとめられ、結果ライブラリを使うのと変わらなくなると思います。
検証
生成ができたので検証もやってみましょう。
検証の場合、順番が変わります。
Signatureの検証のために先にHeaderの値を参照する必要があり、Payloadの内容を参照するのはSignature検証の後となります。
生成と逆の手順で、Base64URL DecodeしてJSON Decodeして Header パラメータを取得します。ここで含まれる値であれば、
typ
パラメータの値で用途の検証
kid
の値が有効なものかどうか
alg
の値が kid
に紐づいた値と一致しているかどうか
と言う検証を行います。
先ほど述べたように、生成時に alg
の値を含まず、省略して3もスキップしても良いでしょう。
次にSignatureの検証をします。
Base StringはEncoded Header と Encoded Payload を "." で連結したものです。
kid
に紐づく鍵で生成時と同様に値を計算し、Base64URL Encodeした値と Encoded Signature の値を比較します。
Signatureの検証を終えたら、Payloadの値を参照します。
ここからはJWTを利用するサービスごとによしなにと言うところです。
RFC7519で定義されている iss
, aud
, exp
などのクレームを含む場合はここで検証します。
検証の手順は以上です。
外部サービス
jwt.io というJWTの生成/検証を行う外部サービスがあり、JWTのデバッガがついています。
jwt.io
ここまでで生成したJWTを入れて鍵情報も入れてやると、署名検証を行い成功したことがわかります。
公開鍵暗号を使うalg=RS256
も紹介したかったですが時間がなさそうなのでやめました。
興味がある方は試してみてください。質問などありましたらいつでもどうぞ。
JWTのデバッガは、他にもmicrosoft製のjwt.msなどがあります。
jwt.ms: Welcome!
外部サービスなので、本番環境で単体でAPIアクセスに使えるようなJWTを入力したり、業務で扱う秘密鍵を指定しての署名検証などは避けましょう。
まとめ
- JSON Web Tokenの概要を紹介しました
- 様々なサービスで使われているJSON Web Signatureと関連する仕様を紹介しました
- 実装で気をつけるべきポイントを紹介しました
- ハンズオン的にJWTの生成/検証を行う方法を紹介しました
初学者向けにまとめたつもりでしたが、もっとユースケース毎にこんな情報をこんな感じで含むといいよねみたいな話や実装についてももうちょっと紹介できると良かったかなとちょっと反省しています。
と、こんな感じでした。
今後もユーザー認証とかリソースアクセスとか、おじさんの守備範囲でその3ぐらいまではやってみたいと思っています。
speakerdeck.com
ではまた!