Skip to content

Instantly share code, notes, and snippets.

@akagire
Last active December 17, 2024 06:35
Show Gist options
  • Save akagire/ddb0eec6157d1a4ae4717ad1c2779ecd to your computer and use it in GitHub Desktop.
Save akagire/ddb0eec6157d1a4ae4717ad1c2779ecd to your computer and use it in GitHub Desktop.

NestJS における セッション ID を JWT でやりとりする方法の実現性の検証

JWT や session についての理解がペラペラだったことを突きつけられる事案が発生したので、おさらい兼ねて冬休みの宿題にした。

ちなみに JWT は "jot" と発音する って書いてあるけど Auth0 のイベントで中の人が ʤ ˈdʌb l̩ yu t って発音してたので、それに倣って自分も ジェイ・ダブリュー・ティー って言ってる。

TL;DR

やりたいことはこれ↓

https://zenn.dev/ritou/articles/4a5d6597a5f250

要件

  • JWT は使いたい
    • 認証に関する情報は API コールなしでフロントエンドでも参照したい
      • API 叩く前にいちいちトークンの有効期限の確認なんかしたくない
        • 有効期限の確認だけできればそれで良い
          • 有効期限が切れていたら再ログインするかリフレッシュトークンで JWT を再発行できれば十分
  • 明示的に JWT トークンも無効にしたい
    • ログアウト操作が行われた場合とかパスワード変更されたとか
    • JWT の仕様的に発行されたトークンは無効にできない
      • 全ての発行された JWT を無効にすることはできる ( 認証を常に失敗させれば良い ) が、それでは副作用が大きすぎる
    • ならばサーバーサイドで JWT の payload のセッション ID の有効性を確認して、失効してたら UNAUTHORIZED を返せば良い
  • 毎回ログイン操作をさせたくない
    • 可能な限りログイン状態は 7 日程度保持させたい

JWT の理解を深める / RFC 7519 を読む

「 JWT は発行したら無効にできない」は本当か?

exp に指定された時刻までは有効としか書いてないが、それ以外の捉え方ができない。 Firebase Authentication や Auth0 も同様の仕様なので、この理解が正しいとをするしかない。https://www.rfc-editor.org/rfc/rfc7519#section-4.1.4

「 JWT でセッション管理してはいけない」は本当か?

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
Loading

となり、狭義のセッションは3つ存在している。例えば、カートに商品がない状態でいきなり注文を確定することはできないし、もちろん未ログイン状態で注文することも許可しないだろう。

近年の SPA + API 方式の Web アプリケーションであれば、認証以外の内包するセッションはクライアント側の状態管理で行われているだろう。

じゃあ JWT というコンテキストでいうセッションはなんなのか、ということだが、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>( 必要がないならやらなくてもいい )
Loading

この実装を行うことで、管理画面から任意の 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 を返す
Loading

とまぁできるが、この方法の問題点を挙げるとすれば、1. 管理者が漏洩した JWT を知っている必要がある2. 特定ユーザーに紐づく JWT を一律無効にしたいようなユースケースには耐えられない、という欠点もある。

2 の欠点は、JWT の payload にユーザー ID を仕込んでおいて、特定ユーザーID からのアクセスであれば一度拒否してしまうという手が使えなくもない ( JWT には改竄検知の signature があるので、payload のユーザー ID をいじったら JWT 自体が使えなくなる ) 。その代わり、JWT のブラックリストに加えてユーザーのブロックリストも必要になり、アーキテクチャが複雑になる。

問題は 1 の欠点で、こっちはソーシャルエンジニアリングが発生するリスクがあるので得策ではない。上記の様な 2 の欠点を補う仕組みにしておけば 1 の欠点も補完できそう。

認証セッションの管理方法の比較

一旦まとめる。

1. JWT

  • 良い点 / あっているユースケース
    • 認証のみを行う仕組みであれば手放しで良い仕組み
    • JWT の払い出しをするサービスと API が別なサーバーでも問題なく認証できる ( 秘密鍵の共有は必要 )
    • セッション永続化のためのデータストアが不要なのでスケールしやすい
    • ステートレスな API の呼び出しを行いたい
    • トークンの改竄検知が可能
  • 悪い点
    • JWT の仕様に無効化する手段は定義されていない
    • JWT を無効化するためにブラックリストを管理すると永続化レイヤー必要になり Session と同様スケーラビリティが犠牲になる
    • JWT の漏洩リスクを考慮すると exp が短くなるので度々 JWT をリフレッシュさせる必要があり、パフォーマンスや UX を劣化させる可能性がある

2. Session

  • 良い点 / あっているユースケース
    • 認証サーバーと API サーバーが同一である
    • Session ID を httpOnly な cookie に保存できるのでフロントエンドの取り回しが不要でシンプル
    • 枯れた技術
  • 悪い点
    • スケーラビリティが悪い ( 永続化レイヤーがボトルネックになる )
    • Session ID の改竄は検出不可能
      • Session ID 自体にセキュアな仕組みを仕込んでおく必要がある

セッション ID を JWT で管理する方式ではどうなるか

ここからが本題。

改めてやりたいこと 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 を返す
Loading

これにより、ブラックリストでは実現できなかった下記のユースケースにも対応できるようになる。

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 を返す
Loading

これにより、JWT でありながらセッション管理ができるようになった。 正しくは、JWT を使ってセッション ID を管理することで、発行済みの有効な JWT であっても認可しない方法が確立できた が正しい。

秋田の猫さんと全く同じ話をしているが、これにより脆弱な手法で生成されたセッション ID を発行した場合でも、JWT の改竄検知機能によって、セッション ID に対するブルートフォースや逆アセンブル攻撃も回避できるようになった。これは従来のセッション管理方式ではなしえなかった。

さらに、ユーザーYのように、JWT でありながら多重ログインを拒否することもできる様になった。( これは、サービスの要件によって許可したいケースもあるので、必須のセキュリティ対策ではない )

一方、 JWT のメリットであるステートレスであることと、セッションストアが必要になったことでスケーラビリティが犠牲になった。

ただし、スケーラビリティについてはブラックリストを利用する時点で犠牲になるので、明示的ログアウトをしたい時点でスケーラビリティは犠牲にせざるを得ないため、これもサービス要件次第。

まとめると、こうなる。

  • 良い点 / あっているユースケース
    • JWT 漏洩対応が取りやすいので exp を長めに設定できる
      • サービスが担保したいセキュリティレベル次第なので、短くても良い
    • セッション ID を改竄したリクエストの検知が可能
    • 同一アカウントによる複数デバイスのログインを拒否できる
  • 悪い点
    • この認証アーキテクチャを想定したライブラリは現時点では存在しないので、フルスクラッチで実装する必要がある
      • 実装例もないに等しいので、セキュリティの懸念が払拭できない
    • セッション ID が httpOnly で管理されない
    • セッションストアが必要なのでスケーラビリティの懸念はセッション方式と変わらない

実際に実装してみた

こちらのリポジトリで実験。 最低限の実装なので、NestJS のドキュメントにあるコードをふんだんに利用している。

https://github.com/Akagire/nestjs-session-over-jwt

下記抜粋で掲載のコードでは実装が追いにくいので、深く理解したい場合はリポジトリを見てください。

ログイン時

ログインを試行したユーザー向けのセッションがあればセッションを削除した上で、セッションを生成し、セッション ID を payload に含めて JWT を生成し、クライアントに返却している。

https://github.com/Akagire/nestjs-session-over-jwt/blob/d53b6b0c79eefeb70f3307754d13e55eb53aa440/src/auth/auth.service.ts#L27-L39

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),
  };
}

認証が必要な API へのアクセス時

NestJS の場合 ( というか Passport )、validate メソッドで認証処理の後処理が可能なので、 @UseGuard デコレーターが付与されたルートにアクセスされた際に実行されるミドルウェアとして利用できた。

https://github.com/Akagire/nestjs-session-over-jwt/blob/d53b6b0c79eefeb70f3307754d13e55eb53aa440/src/auth/jwt.strategy.ts#L17-L27

// 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 を使って、ログアウト処理している。サーバーサイドのログアウト処理は、セッションストアから当該ユーザーのセッション情報を削除しているだけだ。

https://github.com/Akagire/nestjs-session-over-jwt/blob/ddcd05c03eeb746cdfa77b4754d5251b5941aa84/src/auth/auth.service.ts#L41-L46

// 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';
}

Refresh Token について考える

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 ) で定義されている。

Refresh Token をどこに保管すべきか

順当に考えれば、 httpOnly な Cookie に Refresh Token の有効期限と同じ期限付きで保存しておくのが、CSRF や XSS 対策として最もありきたりな保存方法になる。

ちなみに、Auth0 先生によれば、Refresh Token の有効期間を短くしてローテートする様にすれば localStorage に保管してもいいよね、という話をしており、ここはセキュリティポリシーやサービスの UX の要求レベルに応じて判断が変わってくるところ。

https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/#You-Can-Store-Refresh-Token-In-Local-Storage

ほかには、 Refresh Token を利用しないアプローチもあるみたい。Twitter OAuth のアプリである様な方法か?

その他の Refresh Token の保護方法

いろんなサービスの真似事。良いプラクティスは積極的に真似していく。

  • ユーザー当たりの有効な 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 の有効期間 + バッファ時間 になる。
  • Refresh Token を利用するときにパスワードも要求する
  • Refresh Token を利用しない
    • Access Token の有効期限が切れたらログアウトとする

この辺のテクニックの組み合わせになる。あとは実装コストと見合うかといったところ。

Refresh Token の利用の是非

OAuth 2.0 の場合、認可サーバーとリソースが異なるので、この様な認証認可フローが必要になるが、そもそも認可サーバーとリソースが同じ場所にある場合、Refresh Token はそもそも必要だろうか?また、Session over JWT アーキテクチャの場合 Refresh Token は必要だろうか?

結論、セキュリティポリシー次第だが、いらないんじゃないかというのが現時点での自分の考え方。

理由としては以下の2点

  1. 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: ログイン
Loading
@akagire
Copy link
Author

akagire commented Dec 16, 2024

読み返したらかきかけだった…。スマホで見ているのでメモで追記。

  1. DB と認可サーバーが同じであれば、リフレッシュトークンは実質認証セッション。アクセストークンに求められる機能と等しい。

サービスによってはリフレッシュトークンがJWTではなく純粋な文字列のケースもある(Firebase auth)。Cookieで管理する前提で、expires に依存するようなアーキテクチャ。

ことWebサービスにおいて、リフレッシュトークンを本当の意味で安全に管理させる方法はない(ユーザーがdev toolで見れる)。ネイティブアプリにおける認証永続化であれば、簡単に見れることはできないので、問題にならない(これはアプリ開発の経験が乏しいからな発想かもしれない点には留意)。
であれば、アクセストークンを実用の範囲で有効期限を払出し、定期的に再ログインさせる方がセキュア。

何度も言うがこれはセキュリティポリシーに依存している。厳格なセキュリティよりも、Amazonのように、ログインしたことのあるサービスへ定期的にアクセスしたときにログインされっぱなしにさせた方がユーザー体験がよく(またはマーケティングの観点でCVRが良く)、真にほいほい見せるべきではない情報はアクセスが試みられたときに改めてパスワードなどを要求するというアーキテクチャも全然ありえる。

@akagire
Copy link
Author

akagire commented Dec 16, 2024

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment