マルチテナントなサービスにおける認証と権限管理

こんにちは!技術部の小池です。

この記事は Tech Kayac Advent Calendar 15日目の記事です。気付いたらもう12月も半分じゃないですか…もう今年の営業は終わりにしてお酒飲んで年越しを迎えたい気分ですね〜。

今回はマルチテナントなサービスにおける認証と権限管理についてのお話です。

要件

今回お話するサービスはカヤックグループ全体に提供する予定のマルチテナントのサービスです。カヤックの文化を支える 360度フィードバックスマイル給 などの複数のサービスが協調して動作するというサービス指向的なアーキテクチャで、既存のカヤック社内サービス群のリニューアルプロジェクトでもあります。

カヤックグループ向けに提供するサービスなので、認証をしないことには利用することはできません。また、カヤックの社員が他のグループ会社の情報を見れてしまうというのも困ったことになってしまうので、組織ごとの権限の管理も必要になってきます。

具体的には以下のような要件がありました。

- G Suite 上で無効、または削除されたユーザはシステム上で速やかに無効になる必要がある
- システムを利用していていきなりセッションが切れて再認証はユーザ体験を損なうのでやめたい
- ユーザは1つの組織のみに所属し、複数の組織に所属することはない
- 組織をまたいで API やアセットを利用することがある
- URL が流出しても悪意のある第三者が API やアセットを利用できない

認証

認証は Google の OpenID Connect の Auth Code Flow による認証を採用しています。ユーザと Google 間の認証後の callback で code を受け取り、code exchagne で ID token を取得します。

Google ID token は以下のような Google アカウントのユーザ情報を含む JWT です。

{
  "iss": "accounts.google.com",
  "at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q",
  "email_verified": "true",
  "sub": "10769150350006150715113082367",
  "azp": "1234987819200.apps.googleusercontent.com",
  "email": "jsmith@example.com",
  "aud": "1234987819200.apps.googleusercontent.com",
  "iat": 1353601026,
  "exp": 1353604926,
  "nonce": "0394852-3190485-2490358",
  "hd": "example.com"
}

Google 発行の ID token は exp(Expiration Time)クレームが1時間なので、この ID token をそのままセッションキーとして使ってしまうと1時間でセッションが切れて再認証が必要になるサービスになってしまいます。ひとつ工夫が必要ですね。

prompt=none によるサイレント認証(初期案)

当初は Google ID token が期限切れになったら authentication request の prompt パラメータに none を指定することによるサイレント再認証を検討していました。ドキュメント を見ると

The authorization server does not display any authentication or user consent screens; it will return an error if the user is not already authenticated and has not pre-configured consent for the requested scopes. You can use none to check for existing authentication and/or consent.

と記載してあります。none を指定するとブラウザと Google の間でセッションが生きていれば明示的に認証画面を出すことなく再認証ができ、認証画面を出したときと同様に callback が呼ばれるのでサーバ側で code exchagne をすればよいと考えていました。

このやり方であれば1時間毎にセッション切れとなり Google にリクエストを送ることになるので、 G Suite ユーザが無効になったら最長でも1時間でサービスが利用できなくなります。

しかし今回のサービスはフロントエンドを SPA で実装しており、accounts.google.com の X-Frame-Options ヘッダが sameorigin なので、iframe でひっそりと認証することができませんでした。試行錯誤をしましたが、最終的に認証時にウィンドウを一瞬出すという実装になってしまいユーザがびっくりしてしまうということで断念しました。

セッションキーとして独自に ID token を発行し、userinfo endpoint を定期的に叩く

上記の失敗を経て、Google ID token をセッションキーとして使用するのは断念し、長時間有効な自前の JWT 形式の ID token を発行してセッションキーとして使用するよう変更しました。これだけだと G Suite 上で無効になったユーザを exp クレームが期限切れにならない限り検知できないので、最後にユーザが有効になっているか確認した時刻を last_verified_at としてサーバ側で保持しておき、last_verified_at から一定時間が経過したら userinfo endpoint を叩いてユーザが有効になっているかを確認する、という実装にしました。こうすることでユーザに再認証をしてもらわなくてもサーバ側だけでアカウントが無効になっていないかを検知することができます。

これで web アプリケーション側は

- G Suite 上で無効、または削除されたユーザはシステム上で速やかに無効になる必要がある
- システムを利用していていきなりセッションが切れて再認証はユーザ体験を損なうのでやめたい

の要件を満たすことができますね。自前発行の ID token は定期的に refresh して期限が延びるようになっており、継続して利用していれば再認証を求められることはないようになっています。

パソコンが盗難にあったなど、緊急時にシステムの利用を停止したい場合はユーザが自分の Google アカウントのパスワードを変更することで refresh token が変わり、userinfo endpoint を叩いた際にエラーが返って来るのでこの時点でセッションを無効にすることができます。管理側で対応する場合は G Suite のアカウントを一時的に無効にする形になります。

権限

実は認証についてはマルチテナントゆえのややこしさはさほどありませんでした。ところが権限についてはマルチテナントであることがネックになってきます。

前提として画像などのアセットは S3 に保存しており、CloudFront 経由でアクセスします。CloudFront で S3 のアセットに対してアクセス制御をするやり方は何通りかあり、署名付き URL署名付き CookieLambda@Edge などのやり方が考えられると思います。

この中で署名付き URL は URL が流出してしまうと誰でも見れてしまうので、URL が流出しても悪意のある第三者が API やアセットを利用できない という今回の要件を満たすことができません。一方で署名付き Cookie は URL が流出しても Cookie によるアクセス制御があるので今回の要件を満たすことができそうです。また、Lambda@Edge はプログラムによるアクセス制御が行えるのでこちらも要件を満たすことができます。

署名付き Cookie(未採用)

署名付き Cookie は以下のような JSON でポリシーを定義します。

{
   "Statement": [
      {
         "Resource":"URL or stream name of the file",
         "Condition":{
            "DateLessThan":{"AWS:EpochTime":required ending date and time in Unix time format and UTC},
            "DateGreaterThan":{"AWS:EpochTime":optional beginning date and time in Unix time format and UTC},
            "IpAddress":{"AWS:SourceIp":"optional IP address"}
         }
      }
   ]
}

この構造を見ると Statement は複数記述することができそうですが、1つの Statement のみを含めることができると ドキュメントにはっきりと記述されています。これは Resource に含めることのできる URL は1つのみということを意味しています。今回は 組織をまたいで API やアセットを利用することがある という要件があるため、ポリシー1つでは要件を満たすことができません。

複数の署名付き Cookie を用意して path 属性で制御するというやり方も検討しましたが、path で区切られたサービス群に対して path 属性でアクセス制御をするのは危険が伴うので署名付き Cookie の採用は断念しました。詳しく知りたい方は RFC6265 の section 8 を参照してください。

なお Resource にはワイルドカード指定ができるので単一のサービスであれば署名付き Cookie を採用していたと思います。

Lambda@Edge による自前 ID token 検証

最終的に Lambda@Edge で検証を行う方法を採用しました。先述のとおり自前で ID token を発行する仕組みは実装してあるので、アセットの権限管理用の ID token を別途発行するという形で対応しました。

以下のような構造のペイロードになっています。

{
  "exp": 1353604926,
  "iat": 1353601026,
  "iss": "myservice.kayac.com",
  "user_id": 123456789012345680,
  "scope": [
    "myservice.kayac.com/service1",
    "myservice.kayac.com/service2"
  ],
  "email": "test-san@kayac.com",
  "tenant_name": "kayac.com"
}

scope という array のカスタムクレームを追加し、こちらにアクセスを許可するサービスの一覧を追加していきます。あとは request URL と scope クレームを ビューワーリクエストにて検証すれば適切な権限管理を実現できます。

権限管理用の ID token は exp クレームを1時間としており、期限が切れた際(実際は期限切れになる少し前ですが)は都度 web アプリケーション側で ID token を発行するようになっています。API を叩かずにアセットだけ取得するケースはないので web アプリケーション側で都度発行でも問題ないという想定です。

また、実装時は GitHub に公開されている JWT 検証のサンプルプロジェクトを参考にさせていただきました。github.com 私は現代の Node.js に疎いのでさてどうしたものかな、と思っていましたが比較的簡単に実装することができました。

これで

- 組織をまたいで API やアセットを利用することがある
- URL が流出しても悪意のある第三者が API やアセットを利用できない

このあたりの要件も満たすことができましたね。

まとめ

  • 自前発行の ID token と Google の userinfo endpoint の組み合わせで安全かつ長時間のセッションを実現した
  • 自前発行の ID token のカスタムクレームと Labmda@Edge による検証で S3 のアセットを適切な権限を持つユーザに対してだけ公開することができた

ただ適切な権限を持つユーザに対してサービスを提供したいだけなのになぜこんなに大変なのか…と思わないでもないですね。もっとよいやり方があるという方がいればぜひご教授くださいませ。

明日 16日目 は machida-yosuke さんによる VFX入門 です!みなさま良いお年をお迎えください〜。