JWT や session についての理解がペラペラだったことを突きつけられる事案が発生したので、おさらい兼ねて冬休みの宿題にした。
ちなみに JWT は "jot" と発音する って書いてあるけど Auth0 のイベントで中の人が ʤ ˈdʌb l̩ yu t
って発音してたので、それに倣って自分も ジェイ・ダブリュー・ティー って言ってる。
やりたいことはこれ↓
https://zenn.dev/ritou/articles/4a5d6597a5f250
- JWT は使いたい
- 認証に関する情報は API コールなしでフロントエンドでも参照したい
- API 叩く前にいちいちトークンの有効期限の確認なんかしたくない
- 有効期限の確認だけできればそれで良い
- 有効期限が切れていたら再ログインするかリフレッシュトークンで JWT を再発行できれば十分
- 有効期限の確認だけできればそれで良い
- API 叩く前にいちいちトークンの有効期限の確認なんかしたくない
- 認証に関する情報は API コールなしでフロントエンドでも参照したい
- 明示的に JWT トークンも無効にしたい
- ログアウト操作が行われた場合とかパスワード変更されたとか
- JWT の仕様的に発行されたトークンは無効にできない
- 全ての発行された JWT を無効にすることはできる ( 認証を常に失敗させれば良い ) が、それでは副作用が大きすぎる
- ならばサーバーサイドで JWT の
payload
のセッション ID の有効性を確認して、失効してたらUNAUTHORIZED
を返せば良い
- 毎回ログイン操作をさせたくない
- 可能な限りログイン状態は 7 日程度保持させたい
exp
に指定された時刻までは有効としか書いてないが、それ以外の捉え方ができない。 Firebase Authentication や Auth0 も同様の仕様なので、この理解が正しいとをするしかない。https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4
https://qiita.com/hakaicode/items/1d504a728156cf54b3f8
このイシューの判断をする前に、そもそも「セッション」について一度掘り下げてみる。
cf) https://www.ipa.go.jp/security/awareness/vendor/programmingv2/contents/302.html
IPA によれば、セッションとは「複数ページからなる操作の流れを維持するための仕組み」。
一般的な手法としては「1. サーバー側がある識別子をブラウザに向けて発行」し「2. ブラウザからサーバーへリクエストする際にはその識別子を含」め「3. サーバーはその識別子を元にセッションを維持する」とある。
ここでいう「識別子」が、よく目にする セッション ID になる。
で、セッション ID の搬送する主要な方法として、 1. Cookie, 2. form の Hidden フィールド, 3. URL のリライティング を主要な方法とした上で、しっかり 4. アクセストークン も含まれている。 ( ちなみに参考にした情報は 2007 年版ということにも留意する ) 。もちろん、 URL リライティングについては Cookie が利用できない場合以外は推奨しない、としている。
ちょっと混乱してしまったのでちゃんと言語化すると、セッションという言葉はクライアントとサーバーが一連の流れを保持するための最も抽象度が高い言葉であって、Web アプリケーションの一連の流れには様々なセッションが存在していることは往々にしてあり得る。例として、MPA で構築された EC サイトで、ログインして商品をカートに入れて決済するようなユースケースであれば、
sequenceDiagram
actor user as ユーザー
participant ec as EC サイト
participant pay as 決済サービス
alt 認証セッション
user -->>+ ec: ログイン
ec -->> user: セッション ID 発行
alt カートセッション
user -->>+ ec: 商品をカートに入れる
alt 決済セッション
user -->>+ ec: 決済ページへ行く
ec -->> user: 確認ページを見せる
user -->> ec: 注文確定
ec -->>+ pay: 決済実行
pay -->>- ec: 決済成功
ec -->>- user: 注文確定
end
ec -->>- user: カートを空にする
end
user -->> ec: ログアウト
ec -->>- user: セッション破棄
end
となり、狭義のセッションは3つ存在している。例えば、カートに商品がない状態でいきなり注文を確定することはできないし、もちろん未ログイン状態で注文することも許可しないだろう。
近年の SPA + API 方式の Web アプリケーションであれば、認証以外の内包するセッションはクライアント側の状態管理で行われているだろう。
じゃあ JWT というコンテキストでいうセッションはなんなのか、ということだが、JWT を使って認証をしたい、という話なのだから、このコンテキストで、セッションは「認証セッション」のことを指している、としっかりと定義しておく。
「JWT は認証結果をやりとりするフォーマットで、認証セッションを管理するためのフォーマットではない」が正しい表現。
従来のセッションは、認証状態をサーバー側で管理しているので、未認証のセッションIDが付いたリクエストが来た場合は問題なくアクセスを遮断することが可能。
んで JWT はどうかというと、認証した時点で一定時間 ( exp
に指定した時間 ) 有効なアクセスに必要なトークンが発行されるだけであって、それ以外の操作すべては JWT の管理する範疇ではない。例えば、複数端末から同じアカウントでログインできるとか、ログアウトしても JWT を無効にすることができない、というのは JWT の問題ではなく、アプリケーションの作り方の問題で解決できる。
徳丸さんもおっしゃっている方法が、ブラックリスト方式。
単語はよく解説に上がるが、具体的にどういう仕組みなのかを説明しているケースがないので、明文化する。
認証が必要な API へリクエストが飛んできた時、ヘッダーに付与された JWT が、ブラックリストにないか検索する。この時に利用するブラックリストはなんらかのデータストア ( RDB や Redis になるかな ) に格納されている。
検索した結果該当がない場合は、通常の認証フローに載せる、該当のレコードが存在した場合は JWT として有効であっても API は UNAUTHORIZED
を返して終わりにする、というやり方。
どうやってブラックリストに JWT を追加するかも簡単で、ログアウト用の API を用意しておき、その時に付与されていた JWT をブラックリストに登録すれば良い。
sequenceDiagram
actor user as ユーザー
participant web as WEB アプリ
participant db as ブラックリスト
user -->>+ web: ログイン
web -->>- user: JWT を返す
user -->>+ web: ヘッダーに JWT(A) を<br>つけて認証が必要な API を叩く
web -->> db: JWT(A) がブラックリストにないか見に行く
db -->> web: なかった
web -->>- user: 結果を返す
user -->>+ web: ヘッダーに JWT(A) を<br>つけてログアウト API を叩く
web -->> db: JWT(A) をブラックリストに書き込む
web -->>- user: 結果を返す
user -->>+ web: cURL とかで取り出しておいた JWT(A) を<br>ヘッダーにつけて認証が必要な API を叩く
web -->> db: JWT(A) がブラックリストにないか見に行く
db -->> web: あった
web -->>- user: UNAUTHORIZED を返す
db -->> db: TTL でブラックリストをクリアしていく<br>( 必要がないならやらなくてもいい )
この実装を行うことで、管理画面から任意の JWT を無効にすることも可能。
sequenceDiagram
actor user as ユーザー
participant web as WEB アプリ
participant db as ブラックリスト
participant admin as 管理サイト
actor manager as 管理者
manager -->>+ admin: 失効させる JWT(B) を<br>ブラックリストに登録
admin -->> db: ブラックリストに書き込み
admin -->>- manager: 結果を返す
user -->>+ web: JWT(B) をヘッダーにつけて<br>認証が必要な API を叩く
web -->> db: JWT(B) がブラックリストに<br>ないか見に行く
db -->> web: あった
web -->>- user: UNAUTHORIZED を返す
とまぁできるが、この方法の問題点を挙げるとすれば、1. 管理者が漏洩した JWT を知っている必要がある、2. 特定ユーザーに紐づく JWT を一律無効にしたいようなユースケースには耐えられない、という欠点もある。
2 の欠点は、JWT の payload にユーザー ID を仕込んでおいて、特定ユーザーID からのアクセスであれば一度拒否してしまうという手が使えなくもない ( JWT には改竄検知の signature があるので、payload のユーザー ID をいじったら JWT 自体が使えなくなる ) 。その代わり、JWT のブラックリストに加えてユーザーのブロックリストも必要になり、アーキテクチャが複雑になる。
問題は 1 の欠点で、こっちはソーシャルエンジニアリングが発生するリスクがあるので得策ではない。上記の様な 2 の欠点を補う仕組みにしておけば 1 の欠点も補完できそう。
一旦まとめる。
- 良い点 / あっているユースケース
- 認証のみを行う仕組みであれば手放しで良い仕組み
- JWT の払い出しをするサービスと API が別なサーバーでも問題なく認証できる ( 秘密鍵の共有は必要 )
- セッション永続化のためのデータストアが不要なのでスケールしやすい
- ステートレスな API の呼び出しを行いたい
- トークンの改竄検知が可能
- 悪い点
- JWT の仕様に無効化する手段は定義されていない
- JWT を無効化するためにブラックリストを管理すると永続化レイヤー必要になり Session と同様スケーラビリティが犠牲になる
- JWT の漏洩リスクを考慮すると
exp
が短くなるので度々 JWT をリフレッシュさせる必要があり、パフォーマンスや UX を劣化させる可能性がある
- 良い点 / あっているユースケース
- 認証サーバーと API サーバーが同一である
- Session ID を httpOnly な cookie に保存できるのでフロントエンドの取り回しが不要でシンプル
- 枯れた技術
- 悪い点
- スケーラビリティが悪い ( 永続化レイヤーがボトルネックになる )
- Session ID の改竄は検出不可能
- Session ID 自体にセキュアな仕組みを仕込んでおく必要がある
ここからが本題。
改めてやりたいこと https://zenn.dev/ritou/articles/4a5d6597a5f250
つまり、ステートレスなはずの JWT において、認証セッションというステートフルなものを扱いたい、という話。
JWT のブラックリスト方式は、認証セッションをステートフルにする仕組みとしては最適。が、先述の通り失効させるためには漏洩した JWT を知っている必要があるなど、一定のハードルがあった。
ここで、 Session ID を JWT の payload に入れてしまうというのが秋田の猫さんの主張。
流れとしては、こんな感じになる。
sequenceDiagram
actor user as ユーザー
participant web as WEB アプリ
participant db as セッションストア
user -->>+ web: ログイン
web -->> web: セッション生成
web -->> db: セッションを永続化
web -->>- user: セッション ID の入った JWT(C) を返す
user -->>+ web: JWT(C) 付きで API を叩く
web -->> web: JWT(C) をデコードしてセッション ID を取り出す
db -->> web: セッション ID で永続化されたセッションを取り出す
web -->>- user: 結果を返す
user -->>+ web: JWT(C) 付きで ログアウト API を叩く
web -->> web: JWT(C) をデコードしてセッション ID を取り出す
db -->> web: セッション ID で永続化されたセッションを取り出す
web -->> db: セッション情報を破棄
web -->>- user: 結果を返す
user -->>+ web: JWT(C) 付きで API を叩く
web -->> web: JWT(C) をデコードしてセッション ID を取り出す
db -->> web: ID に紐づくセッション情報なし
web -->>- user: UNAUTHORIZED を返す
これにより、ブラックリストでは実現できなかった下記のユースケースにも対応できるようになる。
sequenceDiagram
actor user as ユーザー
participant web as WEB アプリ
participant db as セッションストア
participant admin as 管理サイト
actor manager as 管理者
user -->>+ web: JWT(C) のセッション ID を改竄した<br>JWT(C') 付きで API を叩く
web -->> web: JWT(C') をデコード(失敗する)
web -->>- user: UNAUTHORIZED を返す
manager -->>+ admin: パスワードが漏洩したユーザー(X) の ID に<br>紐づくセッション情報をクリア
admin -->> db: ユーザー ID に紐づくセッションを削除
admin -->>- manager: 結果を返す
user -->>+ web: ユーザー(X) が発行した JWT(D) 付きで API を叩く
web -->> web: JWT(D) をデコードしてセッション ID を取り出す
db -->> web: ID に紐づくセッション情報なし
web -->>- user: UNAUTHORIZED を返す
user -->>+ web: ユーザー(Y) が PC でログイン
web -->> db: ユーザー(Y) の他のセッションがないか見に行く
db -->> web: ない
web -->> web: セッション生成
web -->> db: セッションを永続化
web -->>- user: セッション ID の入った JWT(E) を返す
user -->>+ web: ユーザー(Z) が SP でログイン
web -->> db: ユーザー(Y) の他のセッションがないか見に行く
db -->> web: あった
web -->> db: ユーザー(Y) のセッションを削除
web -->> web: セッション生成
web -->> db: セッションを永続化
web -->>- user: セッション ID の入った JWT(F) を返す
user -->>+ web: ユーザー(Z) が PC からJWT(E) 付きで API を叩く
web -->> web: JWT(E) をデコードしてセッション ID を取り出す
db -->> web: ID に紐づくセッション情報なし
web -->>- user: UNAUTHORIZED を返す
これにより、JWT でありながらセッション管理ができるようになった。 正しくは、JWT を使ってセッション ID を管理することで、発行済みの有効な JWT であっても認可しない方法が確立できた が正しい。
秋田の猫さんと全く同じ話をしているが、これにより脆弱な手法で生成されたセッション ID を発行した場合でも、JWT の改竄検知機能によって、セッション ID に対するブルートフォースや逆アセンブル攻撃も回避できるようになった。これは従来のセッション管理方式ではなしえなかった。
さらに、ユーザーYのように、JWT でありながら多重ログインを拒否することもできる様になった。( これは、サービスの要件によって許可したいケースもあるので、必須のセキュリティ対策ではない )
一方、 JWT のメリットであるステートレスであることと、セッションストアが必要になったことでスケーラビリティが犠牲になった。
ただし、スケーラビリティについてはブラックリストを利用する時点で犠牲になるので、明示的ログアウトをしたい時点でスケーラビリティは犠牲にせざるを得ないため、これもサービス要件次第。
まとめると、こうなる。
- 良い点 / あっているユースケース
- JWT 漏洩対応が取りやすいので
exp
を長めに設定できる- サービスが担保したいセキュリティレベル次第なので、短くても良い
- セッション ID を改竄したリクエストの検知が可能
- 同一アカウントによる複数デバイスのログインを拒否できる
- JWT 漏洩対応が取りやすいので
- 悪い点
- この認証アーキテクチャを想定したライブラリは現時点では存在しないので、フルスクラッチで実装する必要がある
- 実装例もないに等しいので、セキュリティの懸念が払拭できない
- セッション ID が httpOnly で管理されない
- セッションストアが必要なのでスケーラビリティの懸念はセッション方式と変わらない
- この認証アーキテクチャを想定したライブラリは現時点では存在しないので、フルスクラッチで実装する必要がある
こちらのリポジトリで実験。 最低限の実装なので、NestJS のドキュメントにあるコードをふんだんに利用している。
https://github.com/Akagire/nestjs-session-over-jwt
下記抜粋で掲載のコードでは実装が追いにくいので、深く理解したい場合はリポジトリを見てください。
ログインを試行したユーザー向けのセッションがあればセッションを削除した上で、セッションを生成し、セッション ID を payload に含めて JWT を生成し、クライアントに返却している。
async login(user: any) {
// セッションストアから username が同じデータを全部引っ張ってくる
const sessions = await this.getExistSessions(user.username);
if (sessions.length > 0) {
// 有効なセッション情報があれば全部削除する
await this.deleteSessions(sessions);
}
// ここでセッションを生成して...
const sessionId = await this.createSession(user.username);
// JWT の payload に含めるデータを生成し...
const payload = { session_id: sessionId, sub: user.userId };
return {
// JWT を access_token としてクライアントに返却する
access_token: this.jwtService.sign(payload),
};
}
NestJS の場合 ( というか Passport )、validate
メソッドで認証処理の後処理が可能なので、 @UseGuard
デコレーターが付与されたルートにアクセスされた際に実行されるミドルウェアとして利用できた。
// validate では falsy な値を return すると未認証として扱われる
sync validate(payload: any) {
// JWT の payload から session_id を取り出す
const sessionId = payload.session_id as string;
// ない場合は異常な token なので未認証と扱う
if (!sessionId) return null;
// セッション ID を使ってセッションストアからデータを引っ張ってくる
const session = await this.authService.getSessionBySessionId(sessionId);
// セッション情報がないということは、異常な token であるか、TTL によって
// セッションストアから情報が削除されたかなので、未認証と扱う
if (!session) return null;
// 後述の req.user に含めるデータを返す
return { userId: payload.sub, username: session.email };
}
上記の validate
で返却している情報は、暗示的に NestJS の @Request
デコレーターがついた引数の req
の中の user
メンバーで取得することができる。
この情報を利用して、req.user
に含まれた username
を使って、ログアウト処理している。サーバーサイドのログアウト処理は、セッションストアから当該ユーザーのセッション情報を削除しているだけだ。
// app.controller.ts
@UseGuards(JwtAuthGuard)
@Get('/logout')
async logout(@Request() req) {
// こんな感じで validate で返却していた値を取得できる
return this.authService.logout(req.user.username);
}
// auth.service.ts
async logout(username: string) {
const sessions = await this.getExistSessions(username);
await this.deleteSessions(sessions);
return 'success logout';
}
JWT でセッション管理する仕組みの目処がたってきた。次に考える必要があるのは JWT の exp
に到達したときのトークンの再生成処理。
さて、一旦話がこじれそうなので、これ以降は JWT のことを Access Token と呼ぶ。なぜなら、今から考える Refresh Token にも JWT を使う可能性があるから。
- Access Token ... API を叩くために使う認証結果が含まれた Token
- Refresh Token ... Access Token を生成するために生成された Token
一般的には、Refresh Token と呼ばれる JWT 生成のみができる有効期限が JWT よりも非常に長いトークンを発行しておき、JWT の有効期限が切れたら Refresh Token を使って JWT を再生成する。
当然だが、Access Token 以上に厳格に管理しなければならないのがこの Refresh Token になる。万が一漏洩したら Access Token が生成し放題になるので。
もちろん、この対策として、パスワードリセット・メールアドレス変更・明示的なログアウトが行われた場合は Refresh Token も当然ブラックリストに入れる必要がある。
...となると、Refresh Token こそが本当の意味で認証セッションを管理しているのでは?と思ってきてしまった。
また、Refresh Token という仕組み自体は RFC 7519 で定義されたものではなく、 RFC 6749 ( OAuth 2.0 ) で定義されている。
順当に考えれば、 httpOnly な Cookie に Refresh Token の有効期限と同じ期限付きで保存しておくのが、CSRF や XSS 対策として最もありきたりな保存方法になる。
ちなみに、Auth0 先生によれば、Refresh Token の有効期間を短くしてローテートする様にすれば localStorage に保管してもいいよね、という話をしており、ここはセキュリティポリシーやサービスの UX の要求レベルに応じて判断が変わってくるところ。
ほかには、 Refresh Token を利用しないアプローチもあるみたい。Twitter OAuth のアプリである様な方法か?
いろんなサービスの真似事。良いプラクティスは積極的に真似していく。
- ユーザー当たりの有効な Refresh Token 発行数に制限をつける
- 最大の同時接続数を制限するアプローチ
- Refresh Token を使った Access Token の再生成にレートリミットをつける
- 万が一 Refresh Token が漏洩した際、大量の Access Token を作らせない
- パスワードリセット・メールアドレスの変更のタイミングに Refresh Token をローテートする
- このタイミングで過去の Refresh Token は revoke
- Refresh Token の有効期限を可能な限り短くする
- 要件次第。今回は7日になるのかな。Access Token を更新時に新しい Refresh Token も引っ張ってくるようにする必要がある。タイミング的に Refresh Token が有効期限切れになる直前に Access Token が再生成される可能性があるので、
Refresh Token の有効期限 = ログイン状態を維持したい期間長 + Access Token の有効期間 + バッファ時間
になる。
- 要件次第。今回は7日になるのかな。Access Token を更新時に新しい Refresh Token も引っ張ってくるようにする必要がある。タイミング的に Refresh Token が有効期限切れになる直前に Access Token が再生成される可能性があるので、
- Refresh Token を利用するときにパスワードも要求する
- Refresh Token を利用しない
- Access Token の有効期限が切れたらログアウトとする
この辺のテクニックの組み合わせになる。あとは実装コストと見合うかといったところ。
OAuth 2.0 の場合、認可サーバーとリソースが異なるので、この様な認証認可フローが必要になるが、そもそも認可サーバーとリソースが同じ場所にある場合、Refresh Token はそもそも必要だろうか?また、Session over JWT アーキテクチャの場合 Refresh Token は必要だろうか?
結論、セキュリティポリシー次第だが、いらないんじゃないかというのが現時点での自分の考え方。
理由としては以下の2点
- OAuth 2.0 の場合、以下の様なフローで安全に Refresh Token が管理できる
sequenceDiagram
actor user as ユーザー
participant web as WEB アプリ
participant db as DB
participant auth as 認可サービス
user -->>+ web: 外部サービスIDでログイン
web -->> user: 外部サービスでの認可を要求
user -->>+ auth: 外部サービスでログイン<br>( またはパスワード入力 )
auth -->>- user: アクセストークン & リフレッシュトークンの払い出し
user -->> web: リダイレクト
web -->> db: リフレッシュトークンと保存期限を保存
web -->>- user: ログイン
読み返したらかきかけだった…。スマホで見ているのでメモで追記。
サービスによってはリフレッシュトークンがJWTではなく純粋な文字列のケースもある(Firebase auth)。Cookieで管理する前提で、expires に依存するようなアーキテクチャ。
ことWebサービスにおいて、リフレッシュトークンを本当の意味で安全に管理させる方法はない(ユーザーがdev toolで見れる)。ネイティブアプリにおける認証永続化であれば、簡単に見れることはできないので、問題にならない(これはアプリ開発の経験が乏しいからな発想かもしれない点には留意)。
であれば、アクセストークンを実用の範囲で有効期限を払出し、定期的に再ログインさせる方がセキュア。
何度も言うがこれはセキュリティポリシーに依存している。厳格なセキュリティよりも、Amazonのように、ログインしたことのあるサービスへ定期的にアクセスしたときにログインされっぱなしにさせた方がユーザー体験がよく(またはマーケティングの観点でCVRが良く)、真にほいほい見せるべきではない情報はアクセスが試みられたときに改めてパスワードなどを要求するというアーキテクチャも全然ありえる。