こんにちは、AWS担当のwakです。
弊社では全社的にG Suiteを導入しています。そこで社内向けWebサイトをS3+Lambda+API Gatewayで構築し、G Suiteのアカウント+AWS Cognitoで弊社社員のみ利用できるような認証をかけようとしたところ、何点かハマるポイントがあったので手順を書いておきます。
鋭い視線で部外者のアクセスを監視する猫
実現したこと
- S3に静的なページ(HTML+CSS+JavaScript)を配置した
- JavaScriptがAWS APIを叩いてS3とDynamoDBからデータを取得し、画面に表示する
- ページを利用する(=AWS APIを叩く)にはGoogleアカウント認証が必須
- 認証ができたらCognito経由でユーザーにIAM Roleを与え、S3とDynamoDBへのアクセス権限を付与する
- Googleアカウントのドメインは sanwasystem.com に限定する
- それ以外のアカウントではHTMLは見られてもS3とDynamoDBにはアクセスできないようにする
前置き: AWS Cognitoって
Cognitoの機能は主に2つあります。
今回はこの後者の機能だけを使っています。
Google側の準備
プロジェクト作成
Google側で認証を行うので、 Firebase console よりプロジェクトを新規作成します。既存のプロジェクトでも構いません。
プロジェクト初期化用コード取得
プロジェクトが作成できたら、「ウェブアプリに Firebase を追加」ボタンを押すとプロジェクト初期化用のJavaScriptのコードが取得できます。これを保存しておきます。
Google認証をONに
コンソールがずいぶん親切になっています。Authentication→「ログイン方法を選択」と進み、GoogleログインをONにします。
承認済みドメインを追加
認証済みドメインにWebサイトを配置するドメインを入力します。 sanwa.local
のようなGoogle側では名前解決できない値も受け付けてくれます。
OAuthクライアントID確認
旧来のプロジェクト管理画面 に移動します。 Credentials を見ると既にOAuthクライアントID(以下、単に「クライアントID」と呼びます)が自動生成されているので、このクライアントIDをメモしておきます。なお、このIDは認証時に暗黙的に利用される値なので、削除すると大変面倒なことになります*1。
AWS側の準備
IDプール作成
Cognitoの管理画面 でIDプールを作成します。認証プロバイダにはGoogle+を選択し、GoogleクライアントIDには先ほどメモっておいたクライアントIDを入力します。
ロール作成
認証時・非認証時に対応するロールの作成画面が表示されるので、そのまま作成してもらいます。
IDプールID(Identity pool ID)をメモする
サンプルコードが表示されます。その中のIdentity pool IDをメモしておきます。
ドメイン制限をかける
画面右上のリンクからIDプール編集画面を開きます。再度Authentication providersのGoogle+タブを見ると、今度はAuthenticated role selectionという項目が出現しています。ここで下記のような設定をすることで、Google認証されたユーザー情報のhd
(Hosted Domain)プロパティに対して制限をかけることができます(このhd
という名前はOpenIDで決められています)。
素直に考えるとロールのTrust relationshipsの方に accounts.google.com:hd = sanwasystem.com
となるような条件を書き込みたくなるのですが、こちらにはhd
(Hosted Domain), email
などの情報は渡されてこないようです。*2
ロールに権限を追加する
S3に対して読み書きしたりLambdaを実行したりしたいので、認証時に対応するロールに権限を適宜追加します。
Web側の準備
あとはHTMLとJavaScriptを書くだけです。Google認証には2通りの方法があります。
ポップアップまたはリダイレクトで認証を行うパターン
Firebaseを使うやり方です。とりあえずCDNを読み込んでJavaScriptをベタ書きしています。
<!DOCTYPE html> <html> <head> <script src="https://www.gstatic.com/firebasejs/5.0.4/firebase.js"></script> <script src="https://apis.google.com/js/platform.js" async defer></script> <script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script> <link type="text/css" rel="stylesheet" href="https://cdn.firebase.com/libs/firebaseui/2.5.1/firebaseui.css" /> <meta charset="utf-8"> <title>HELLO COGNITO</title> <script> // Firebaseの管理画面から取得可能 const config = { apiKey: "AIzaSyDWKx_xxyCTYiGjQt0wODTLMuXy7JOSbXk", authDomain: "sscaccountauthenticator.firebaseapp.com", databaseURL: "https://sscaccountauthenticator.firebaseio.com", projectId: "sscaccountauthenticator", storageBucket: "sscaccountauthenticator.appspot.com", messagingSenderId: "963005312419" }; firebase.initializeApp(config); const provider = new firebase.auth.GoogleAuthProvider(); provider.addScope("profile email"); const init = async () => { await new Promise((resolve, reject) => { setTimeout(resolve, 2000); }); // 初期化を待つための手抜き const result = await firebase.auth().signInWithPopup(provider); // Googleでログインできたら、トークンをAWSに渡して認証を行う AWS.config.region = "ap-northeast-1"; AWS.config.credentials = new AWS.CognitoIdentityCredentials({ IdentityPoolId: 'ap-northeast-1:ffffffff-ffff-ffff-ffff-ffffffffffff', // IdentityプールID Logins: {'accounts.google.com': result.credential.idToken} }); // 認証できたら呼び出される AWS.config.credentials.get(function() { const s3 = new AWS.S3(); const url = s3.getSignedUrl('getObject', {Bucket: "YOUR-BUCKET-NAME", Key: "PATH/TO/YOUR/FILE/neko.jpg"}); console.log(url); }); }; init(); function signOut() { firebase.auth().signOut(); } </script> </head> <body> <a href="#" onclick="signOut();">Sign out</a> </body> </html>
このコードには一つ問題があり、リロードするたびにポップアップが表示されてしまいます。既にログインしているときにはトークンIDが firebase.auth().currentUser.getIdToken(true)
で取得できるので本当は再ログインは不要なのですが、このトークンIDはProviderIdが firebase
の固定値となっており、Cognitoが受け付けてくれません。認証直後に result.credential.idToken
で取得できるトークンはProviderIdが google.com
なので問題は起きません。
サインインボタンを表示するパターン
Firebaseではなく旧来のフレームワークに沿って実装します。JavaScript で Google ログインを使用して認証する | Firebase Documentation に従いますが、Firebaseで作成したプロジェクトは互換性がイマイチなので前準備が必要です。
OAuthクライアントID再作成
旧来のプロジェクト管理画面 に移動し、自動作成されているクライアントIDを無視(もうこのクライアントIDは使いません)して新規にクライアントIDを作成し、Originの設定を済ませます。この新しいクライアントIDでCognitoの設定を上書きします。
この手順がなぜ必要なのかは謎ですが、自動生成されたクライアントIDをそのまま使ってOriginの設定を行っても反映されず、認証しようとしたときに Not a valid origin for the client というエラーが発生します(6/5現在)。
最後にHTML+JavaScriptを書きます。 meta
要素にクライアントIDを書くのを忘れないでください。
<!DOCTYPE html> <html> <head> <script src="https://apis.google.com/js/platform.js" async defer></script> <script src="https://sdk.amazonaws.com/js/aws-sdk-2.1.12.min.js"></script> <meta name="google-signin-client_id" content="999999999999-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx.apps.googleusercontent.com"> <meta name="google-signin-cookiepolicy" content="single_host_origin"> <meta name="google-signin-scope" content="profile email"> <meta charset="utf-8"> <title>HELLO COGNITO</title> <script> function onSignIn(googleUser) { console.log("サインインが成功したか、または既にサインインしていました"); const profile = googleUser.getBasicProfile(); // profileにはユーザー情報が入っている。 getName() で名前が取れたりする // Googleでログインできたら、トークンをAWSに渡して認証を行う AWS.config.region = "ap-northeast-1"; AWS.config.credentials = new AWS.CognitoIdentityCredentials({ IdentityPoolId: 'ap-northeast-1:ffffffff-ffff-ffff-ffff-ffffffffffff', // IdentityプールID Logins: {'accounts.google.com': googleUser.getAuthResponse().id_token} }); // 認証できたら呼び出される AWS.config.credentials.get(function() { const s3 = new AWS.S3(); const url = s3.getSignedUrl('getObject', {Bucket: "YOUR-BUCKET-NAME", Key: "PATH/TO/YOUR/FILE/neko.jpg"}); console.log(url); }); } function signOut() { var auth2 = gapi.auth2.getAuthInstance(); auth2.signOut(); } </script> </head> <body> <div class="g-signin2" data-onsuccess="onSignIn" data-theme="dark"></div> <a href="#" onclick="signOut();">Sign out</a> </body> </html>
GoogleのAPIキーが丸見えだけど……?
静的ファイルで実現しようとしている以上仕方がありません*3。ただ、今回のような利用用途ですと、誰かがこのAPIキーを悪用しようとしてもそのシナリオが思い付きません。問題ないと判断しました。
まとめ
CognitoのIDプールは作成は簡単なのですができることはかなり限定的です。ただ、今回のように使いどころがうまくハマれば非常に手軽に(ほぼコードを書かずに)AWSリソースへの権限を与えられます。これはバグやセキュリティホールも発生しづらいということでもあり、上手に活用していけると良いと感じました。