最適な認証基盤を構築する (Amazon Cognito調査編)
現在、仕事で認証基盤のリニューアルを計画しており、以下の記事の通りIDaaS周りについて調査を行いました。
https://tech-blog.tsukaby.com/archives/1548
結論としてはAuth0などは高くて採用できないため、そこそこ安いCognitoを選択しようと思っています。
しかし、本当にCognitoが要件に合うのか分からなかったため、Cognitoについて色々と調査を行いました。AWSのサポートに確認もしています。
前提
-
BackendはRuby, Ruby on Rails
-
FrontendはNext.js
-
ホスティング環境はECS Fargate
-
既に認証は自前実装のものが存在している
必須要件
-
認証をIDaaS化(Cognito化)してセキュアにする
-
TOTPによるMFAを任意で可能にする
-
セルフSignupを防ぐ (既存ユーザからの招待でのみアカウント作成可能)
任意要件
-
Googleアカウントによる認証を可能にする
-
BOT等によるブルートフォースやリバースブルートフォースへの対策を可能にする
-
パスワードポリシーを簡単に管理可能とする (例えば8文字以上、英数記号混在など)
-
普段と異なる環境からのログインをユーザに通知する
TL;DR
-
要件はCognitoで満たせる
-
Next.jsアプリ内でAmplifyのJavascriptライブラリを利用して認証画面を構築する必要があるが、ホスティング環境はAmplifyでなくともよい
-
認証リクエストはCognito側へ送り、成功であればidToekn, accessToken, refreshTokenが発行される。accessTokenを使ってBackend側(リソースサーバー側)と認証必須のリソースのやり取りを行う
-
Backend側はJWTの検証を行い、認証済み・有効期限内であればリソースを返す (HTTP 2XX)
-
デフォルトではrefreshTokenもlocalStorageに保存されるため、セキュリティの懸念がある
既存のユーザー情報をCognitoにimportする
Cognitoを利用するならばユーザーがログイン(サインイン)できるように、ユーザープールを作成し、そこへユーザーを登録する必要があります。登録は一括importが可能なので、既存のusersテーブルの情報などをまとめて登録することができます。
ただし、注意点があって、ここに記載されている通りパスワードimportはサポートされていません。
また、そもそも生のパスワードのDB保存はバッドプラクティスであるため、まともな企業ならばそのような実装になっていません。パスワードはハッシュ化されて保存されていると思います。
パスワードなしで一括importするわけですが、その後全てのユーザに対してパスワードの再設定を求める必要があります。
BOT対策
世の中の攻撃者はBOTを用意してブルートフォースアタックや辞書攻撃、リバースブルートフォース、パスワードスプレーなどの攻撃で認証を突破しようとしますが、これに対してIDaaS側で自動で対策が施されていると開発者としては助かります。
サインインの試行の失敗に対する、Amazon Cognito ロックアウト動作
サインインに 5 回失敗すると、Amazon Cognito はユーザーを 1 秒間ロックアウトします。ロックアウトの期間は、試行が 1 回失敗するたびに 2 倍になり、最大で約 15 分になります。ロックアウト期間中に試行するとPassword attempts exceeded
例外が生成され、その後のロックアウト期間の長さには影響しません。サインイン試行の累積失敗回数 n (Password attempts exceeded
例外を含まない) に対して、Amazon Cognito はユーザーを 2^(n-5) 秒間ロックアウトします。ロックアウトを初期状態にリセットするには、ユーザーは、ロックアウト後、連続 15 分間、サインイン試行を開始してはなりません。この動作は変更される可能性があります。
Cognitoではこのような仕組みを持っているのである程度安全なようです。ただし、ここで言うユーザーというのが攻撃者のIPなのか、認証を行うユーザーなのか、その点についてはよく分かりません。
IPであれば、攻撃者が巨大なBOTネットを持っていない限りはある程度効果があると言えます。BOTネットだとしても、それらのマシンのIPには限りがあるので、そういう点でもある程度効果はあると言えます。
ユーザーなのであれば、リバースブルートフォースやパスワードスプレーが気になるところです。これらはパスワードを固定してIDの方を可変にする攻撃なので、対策にはならないでしょう。
このあたりは認証の基本なので、流石にAWS側で考慮されていると思います。
BOT対策としてはこのようなロック機構以外にもCAPTCHAによる手法もあります。
2023年の現在においてAWS WAFがCPATCHAに対応しており、CognitoとWAFを組み合わせることができるようです。つまりCAPTCHAの成否をWAFで検証しつつ、それが通ればCognitoでの検証を行う、というようなことができそうです。しかし、AWS WAFのCAPTCHAは残念ながらあまり品質が良いようには見えず、個人的にはこれを使うくらいならGoogleのreCAPTCHA v2/v3, Cloudflare Turnstileを選択したいですね。
https://sgswtky.github.io/post/aws-cognito/
こちらの記事ではカスタム認証フローを使ってreCAPTCHAを組み込んでいるようなので、それを使えば可能なようではありますが、できればCognito側でデフォルトで用意しておいて欲しい仕組みではありますね。
パスワードポリシー
柔軟性は高くありませんが、簡単に管理はできます。個人的にはこれで十分だと思いました。
普段と異なる環境からのログインを検知する
Cognitoにはアドバンストセキュリティ(高度なセキュリティ)機能があります。
追加料金が発生しますが、ログイン時や怪しいログイン試行時のメールを受け取れたり、認証を弾くことが出来ます。
https://blog.serverworks.co.jp/tech/2020/02/20/cognito-advanced-security-feature/
こちらの記事が詳しく解説してくれています。
ログが残る点やCloudWatchにメトリクスとして残る点も良いですね。
公式docにカスタム認証フローでは有効にならないような制約が書かれているため、Cognitoが公式にサポートしていない手法(例えばreCAPTCHAやFIDO2)を入れようとすると使えなくなるのかと思います。この点とコストについては要注意ですが、概ね良さそうに見えます。ぜひ利用したいところです。
MFAを任意設定にしつつHosted UIを利用してもMFAを設定できない
CognitoにはHosted UIというAWS側が用意・ホスティングしているサインアップ・サインインの画面システムがあります。
ただし、これはロゴとCSS程度しかカスタマイズができないため、デザイン的な要件が合わずに採用できない組織もあるかと思います。
また、ユーザープールを作成する時にMFAの設定を必須・任意・無しの3つから選べます。Hosted UIでMFAを設定可能にする場合は必須を選ぶしかありません。
私の要件としてはMFAの登録は任意にしたいため、Hosted UIは使えないことになります。
セルフサインアップを防ぐ
ユーザープールの設定でセルフサインアップを無効化できます。
B2Cのサービスであればユーザーに自分でSignupして欲しいケースが多いと思いますが、B2Bの場合は、既存のユーザやサービス運営側がユーザー作成し、自身でのSignupを認めないケースはそれなりにあるかと思います。
そのようなケースで利用できますね。
Hosted UIを使わない場合の画面実装
AWSサポートの見解としてはNext.jsであればAmplifyのJavascriptライブラリを使うと良いそうです。認証に関する関数が既に定義しているので、それらを使って実装すれば良いそうです。
このあたりは既に実践している先人の記事は多いですし、
https://aws.amazon.com/jp/blogs/mobile/deploy-a-next-js-13-app-with-authentication-to-aws-amplify/
このような公式の記事も存在するのでそれらを参考にすると良いです。
https://qiita.com/too/items/54992bb871fc1a2ab101
こちらの記事ではAmplifyのAuthについて解説してくれています。
例えばAuth.signInでサインインを、Auth.confirmSignInでMFAの追加認証を行うなどが分かります。
Web上ではAmplifyにデプロイして試している人が多いですが、AWSサポートとしてはホスティング環境はAmplifyでなくとも良いようです。(であればライブラリの名称をAmplifyにすべきでないと思うけども)
なお、このような構成でソーシャルアカウントによる認証とMFAによる認証は可能なのかについても問い合わせましたが、それも可能なようです。一部の記事ではHosted UIでないと無理だと言っていたので気になっていましたが、どうやら可能なようで安心しました。
Amplifyのライブラリを使って自前で画面を実装する手間がかかり、この点はAuth0などの方が実装量が少なそうで良いです。ただ、サンプルコードなどを見る限りAmplifyライブラリを使った方式でもそれほど難しくは無さそうです。
AmplifyのAuth.signInでサインインをするわけですが、ここで認証が成功した場合に発行される情報はLocalStorageに格納されます。
コード的には上記の部分などが該当します。
refreshTokenもlocalStorageに保存されるため、これはJavaScriptからアクセス可能でありXSSによって読み取られてしまいます。これは少し心配な点ではあります。これについては先人が調査済みでした。以下のような記事が参考になります。
https://zenn.dev/chot/articles/6c52a62d2fee42
https://zenn.dev/lilac31/articles/c0f216d29ac2b9
このidTokenなどは、
などのツールでデコードすることができます。例えばidTokenをデコードしたものがこちらです。
idToken, accessToken, refreshTokenの違いについては以下の記事が参考になります。
https://dev.classmethod.jp/articles/study-tokens-of-cognito-user-pools/
https://dev.classmethod.jp/articles/auth0-access-token-id-token-difference/
ざっくりidTokenが認証、accessTokenが認可であると覚えておけば良いと思います。
idToken, accessTokenは前述の通りデコードでき、このJWTは認証サーバが署名しているため、認証サーバの公開鍵で改ざんされていないことを検証できます。検証成功であれば、このJWTの所有者(リクエスト)は正規の認証・認可を経たもの、と判断できます。
このtokenをBackend(リソースサーバー・APIサーバー)に送るわけですが、accessTokenの方を利用します。idTokenもaccessTokenも同時に発行されるため、accessTokenだけでも認証を通っていると判断できますし、リソースサーバーは認可の状態に応じてリソースへのアクセスを決定する必要があるため、accessTokenを送る、という訳です。
デフォルトではaccessTokenは1時間で失効するため、refreshTokenを使って新しいaccessTokenを取得する必要があります。この更新処理については以下の記事が参考になります。
Auth.currentSessionを呼び出すことで自動で更新され、localStorageに保存されます。このメソッドは呼び出すたびにCognito側へAPIコールされるわけではなく、accessTokenが失効している場合だけAPIコールされるようです。そのため、リソースサーバー(APIサーバ)へリクエストする前に必ずAuth.currentSessionで更新処理を行うと良さそうです。
Backendの実装
前述のAmplify Authを使って画面を作り、ユーザにID/PASSの入力を促し、場合によってはMFAのOTPを入力させます。この時認証のリクエストはCognitoのサーバに送信されます。つまりBackend側では認証リクエストが正しいかどうかの検証、いわゆるlogin/signinのAPIを用意する必要はありません。
ただし、認証完了後にログインしていないと見れないデータへのリクエストについてはBackend側で検証する必要があります。
前述の通り、accessTokenをFrontendから受け取るわけですが、これはlocalStorageに保存されているので、Frontend側でこれらをCookieかHeaderに載せてBackend側へHTTPで渡す必要があります。
CookieかHeaderどちらを利用するかについてですが、以下の記事が参考になると思います。
https://security.stackexchange.com/questions/180357/store-auth-token-in-cookie-or-header
もともとlocalStorageに保存されているし、CSRFを回避するためにもHeaderに乗せるほうが良さそうです。
ここまでで受け渡しについては明確になりました。次はどうやってaccessToken(JWT)の署名を検証するかについてです。これについては以下の記事などが参考になります。
大抵のケースではおそらくこの検証ロジックを自前で実装する必要はないと思います。何らかのライブラリやFWで用意されていると思います。
例えばrubyであれば
https://github.com/jwt/ruby-jwt
を使って検証すると良さそうです。
サーバ側では受け取ったaccessTokenを使って
こちらのメソッドを呼び出すこともできますが、このAPIの仕様を詳しく把握していないので、本当にこれを使ってよいかは怪しいところです。おそらくですが、accessTokenの検証まではしてくれないのでは?と思っています。その場合はこれは使えませんし、そうでなくともユーザーからリソースサーバーに対してリクエストがあるたびに、AWS側にAPIコールで正しいかどうかを問い合わせるのはパフォーマンスの劣化が気になります。
そういう点でもJWTの検証ロジックは自前で持っておき、いちいち外部への通信が発生しない作りにする必要があると思います。
その他
直近必要でないため、まだSAMLとOIDCによる連携の調査はできていません。B2Bのサービスでは、利用顧客が自社内で持っているユーザーディレクトリ(例えばActiveDirectory)を使ってSSOをしたいというケースが発生します。そのような機能を提供する場合はSAMLなどによる連携の設定が必要になります。
先程、localStorageにrefreshTokenが保存されている点について問題だと言いましたが、そのあたりについては以下の記事が参考になります。
https://tech.hicustomer.jp/posts/modern-authentication-in-hosting-spa/
https://zenn.dev/lilac31/articles/c0f216d29ac2b9#fn-f166-3
この問題はなかなか公式が修正しないため、リスクを受容するか、認証ロジックをCognito APIを使って自前で用意する必要があります。
他にもソーシャルログインについて今回詳しく調べていませんが、これはネット上に情報が多く、実現できることも分かっているため、今回は触れません。
まとめ
Cognitoですが、微妙な点もありますが、Auth0などよりは大分安くなりますし、それなりに機能も多くAmplifyのライブラリを使えば実装もある程度は楽になります。
まだ、私はCognitoの採用を決定したわけではありませんが、おそらくCognitoを採用すると思います。(できるだけセキュアにするためにIDaaSをほどほどの価格で導入したいし、アカウントロックなどを自作したくないし、MFAなども実現できるので、今の所Cognitoが一番バランスが良さそうという判断)
次の記事ではサンプルコードを用意して、実際にMFAによるサインアップなどができるかどうかを検証していこうと思います。