AlgoliaのSREのGuillaume Truchotが書いた Adding OAuth2 authentication to an AWS S3 static bucket with Okta の翻訳です。
私たちのチームでは最近、社員向けに技術レポートをダウンロードできるようにするために、インターナル向けに静的なウェブサイトを構築しました。私たちはAWSのヘビーユーザーということもあり、当然のように静的なウェブサイトを構築するための機能を提供しているAmazon S3上でホストすることにしました(S3 static website hosting)。
しかし、すぐに問題に出くわしました: Amazon S3はネイティブでout-of-the-boxに使える認証/認可の仕組みを提供していません。私たちのウェブサイトはインターナル専用になるので、認証されていないユーザーがウェブサイトやレポートにアクセスしてしまうのを防ぐために何らかの認証のメカニズムが必要でした。
私たちはAmazon S3でのインターナルな静的ウェブサイトをセキュアにするためのソリューションを探す必要がありました。
CloudFrontとLambda@Edgeによるソリューションの深堀り
私たちはOktaを社内の全てのアイデンティティとユーザー管理に使っているため、どのようなソリューションを採用するにせよ、Oktaとプラグインさせる必要がありました。Oktaにはいくつか認証/認可のフローがありますが、どの方法にするにしてもOktaから返ってきたレスポンス/トークンが正当なものであるかの検証といったようなバックエンドでのアプリケーションでのチェックが必要になります。
S3の静的ホスティングにおいては私たちが管理することのないバックエンドなわけで、その中でそういったチェック/アクションを行う術を見つける必要があったわけですが、その中で、CloudFrontへのリクエストとレスポンスにおいて以下の図のような異なるステージでLambda@EdgeというLambdaファンクションを動作させる仕組みがあることを知りました。
上の図のように、4つの異なるステージでLambda Functionをトリガーさせることができます
- リクエストがCloudFrontに来た時 (
viewer-request
) - リクエストがオリジンに出ていく時 (
origin-request
) - レスポンスがオリジンから戻ってきた時 (
origin-response
) - レスポンスがCloudFrontから戻った時 (
viewer-response
)
私たちはまず課題へのソリューションとして、viewer-request
のステージでのユーザー認証がされているかどうかをチェックするLambdaファンクションをトリガーさせることを試みました
ここには2つの条件があります:
- ユーザーが認証されている場合、リクエストを継続させて制限されているコンテンツを返す
- ユーザーが認証されていない場合、HTTPレスポンスを返し、ログインページにリダイレクトさせる
Lambda@Edgeファンクションの実装
ここではキーとなる要素や私たちが遭遇した問題を取り上げますが、実装のコードはこちらで公開させていただいておりますので、どうぞご自由にあなたのプロジェクトでお使いください。
Lambda@Edgeの制限と注意事項
このソリューションを開発していく中で、Lambda@Edgeのいくつかの制限や注意点に出くわしました。
1 – 環境変数
Lambda@Edgeファンクションでは環境変数を扱うことができません。つまり、関数にデータを渡すための別の方法を探す必要がありました。私たちはSSMパラメーターとテンプレート化されたパラメーター名をNode.jsのコードの中で使うことにしました(Lambdaファンクションのデプロイの際にTerraformでテンプレートをレンダリングしています)。
2 – Lambdaパッケージのサイズ制限
Viewer events(リマインダ: 私たちは viewer-request
イベントを使います)においては、Lambdaのパッケージサイズは最大で1MBとなります。1MBというのはLambdaファンクションの全ての依存関係(ランタイムや標準ライブラリは除く)を含むことを考えるととても小さいと言えます。元々はPythonで書いていたLambdaファンクションをNode.jsに書き換えなければならなかったのはそのためです。
3 – Lambdaのリージョン
Lambda@Edgeファンクションが作成できるリージョンは us-east-1
だけです。これは大きな問題ではありませんが以下のような考慮が必要です:
- AWSのリソースをそのリージョンにプロビジョニングすることで管理が容易になります
- Terraformでは、使用するS3バケットが
us-east-1
にない場合は、別のAWSプロバイダを作る必要があります
4 – Lambdaロールのパーミッション
Lambda@Edgeファンクションに紐づくIAM実行ロールは、通常の lambda.amazonaws.com
に加えてedgelambda.amazonaws.com
というプリンシパルサービスを許可しなければなりません。こちらについてはAWSのドキュメントのSetting IAM permissions and roles for Lambda@Edgeをご参照のこと。
Oktaでの認証メカニズム
今まで書いてきたような制約や注意点をやりくりできるようになったら、いよいよ認証/認可の部分に着手していきます。
Oktaはユーザーの認証そして認可に関するいくつかのやり方を提供しています。私たちは認証のために業界標準のプロトコルであるOAuth2を採用することに決めました。
注: OktaはOpenID Connect(OIDC)標準を実装していて、薄い認証のレイヤーをOAuth2上に追加しています(以下に出てくるIDトークンの目的はこれです)。私たちのソリューションは、最小限の改修によりピュアなOAuth2でも動作します(コードの中のIDトークンの使用を消す)。
OAuth2そのものは、どのようなアプリケーションに使用うのかによって、いくつかの認証フローを提供しています。今回の場合、私達にはAuthorization Codeフローが必要でした。
以下は、developer.okta.comにあるAuthorization Codeフローの完全な図で、どのように動作するかが示されています。
このフローのまとめ
- LambdaファンクションはユーザーをOktaにリダイレクトしログインを促します
- Oktaはコードで私たちのWebサイト/Lambdaファンクションにリダイレクトします
- Lambdaファンクションはコードが正しいものであるかどうかをチェックしてOktaにリクエストしてアクセストークンとIDトークンを交換します
- Oktaから返された結果に応じて:
- 制限されたコンテンツへのアクセス許可もしくは拒否
- アクセスが許可されたら、そのアクセストークンとIDトークンをクッキーに保存して、ユーザーが訪れるページごとに再認証を行う必要がないようにします
JSONウェブトークンを認証結果の保存に利用
ここまでは正常な認証プロセスについて作業を進めてきました。しかし、リクエストごとにアクセスやIDトークンをチェックする必要があり(悪意あるユーザーは、無効なクッキーやトークンを偽造しようとするでしょう)、トークンをチェックするということは、Oktaにリクエストを送信し、ユーザーが訪れるすべてのページでレスポンスを待つことを意味します。これはロード時間を大幅に遅くし、最適とは言えないことが明らかです。
注: Oktaトークンのローカル検証は理論的には可能ではありますが、この記事を書いている時点ではOktaが提供するSDKはトークンのチェックに使用するキーを取得する際にLRU(インメモリ)キャッシュを使用します。私たちはステートレスなAWS Lambdaを使用しているため、プログラムのメモリ/状態はファンクションの呼び出しの間で保持されているわけではないので、SDKは私たちにとっては役に立ちませんでした: JWK(JSON Web Keys)を取得するために、すべてのユーザーリクエストに対してOktaに1つのHTTPリクエストを送信することになってしまいます。そして、さらに悪いことに、1分間に10回までのJWKリクエストという制限があるため、1分間に10回以上リクエストがあると、このソリューションは動作しなくなってしまいます。
こういった状況を回避するために、私たちの管理アプリケーションと同様にJSON Web Tokensを使用することにしました。最初の認証プロセスは、アクセス/ID トークンをクッキーに保存する代わりに、これらのトークンを含む JWT を作成してその JWT をクッキーに保存すること以外は同じです。
JWTは暗号化署名されているため:
- (署名するのに使われた秘密鍵が必要なことから)悪意のある偽造はすることが不可能
- すべてのリクエストに必要なチェックのステップは高速: I/Oに時間のかかるHTTPリクエストを、迅速な暗号チェックに置き換え
JWTの有効期限と更新に関する注意点
JWTは、有効期限が切れてしまったり、破棄されてしまったアクセス/IDトークンを含むような有効なJWTを保持することを回避するため、比較的短い有効期限があらかじめ定義されていますが、もう一つの選択肢としては、定期的にアクセス/IDトークンをチェックして、必要に応じて関連するJWTを失効させることが挙げられますが、その場合は失効のメカニズムが必要になってしまい、事態はより複雑になってしまいます。
最後に、上記で述べましたように、Oktaが提供するトークンには有効期限があります。リフレッシュトークンを使って透過的に更新することも可能ですが(そうすることで、トークンの有効期限が切れたときにユーザーが再ログインする必要がなくなります)、私たちはそれを実装しませんでした。
まとめ
Okta(もしくは他のOAuth2プロバイダ)を使ってS3の静的なバケットにOAuth2認証を追加することは、AWSに統合されている安全な方法で行うことができますが、簡単なこととは言い難いと思います。
それは、Lambda@Edgeを使ってAWSとOAuth2プロバイダ(私たちの場合はOkta)の間でミドルウェアを書く必要があるからなわけですが、実際に私たちは以下のことを自分たちでやらなければなりませんでした。
- ユーザー認証のバリデーション
- ユーザー認証の保存
- ユーザー認証の更新(私たちのソリューションには実装されていませんが…)
- ユーザー認証の失効(TTLは実装されているものの、TTL終了前の失効は実装されていません…)
そして、最終的にすべてをまとめて動作させるために、たくさんのAWSリソースを作成する必要がありました。
こういった努力の甲斐もあって、私たちはウェブサイトを安全に保つことができるようになりました。
Lambda@Edgeのコードとインフラ(Terraform)は https://github.com/GuiTeK/aws-s3-oauth2-okta にありますので是非ご覧ください。
コメント