S3 + AWS Cognito + Google認証でドメイン制限のついた非公開サイトを作る

こんにちは、AWS担当のwakです。

弊社では全社的にG Suiteを導入しています。そこで社内向けWebサイトをS3+Lambda+API Gatewayで構築し、G Suiteのアカウント+AWS Cognitoで弊社社員のみ利用できるような認証をかけようとしたところ、何点かハマるポイントがあったので手順を書いておきます。

f:id:nurenezumi:20180607105132j:plain

鋭い視線で部外者のアクセスを監視する猫

実現したこと

  • S3に静的なページ(HTML+CSS+JavaScript)を配置した
    • JavaScriptAWS APIを叩いてS3とDynamoDBからデータを取得し、画面に表示する
  • ページを利用する(=AWS APIを叩く)にはGoogleアカウント認証が必須
    • 認証ができたらCognito経由でユーザーにIAM Roleを与え、S3とDynamoDBへのアクセス権限を付与する
  • Googleアカウントのドメインは sanwasystem.com に限定する
    • それ以外のアカウントではHTMLは見られてもS3とDynamoDBにはアクセスできないようにする

前置き: AWS Cognitoって

Cognitoの機能は主に2つあります。

  • ユーザー認証を取り扱う
  • Google, FacebookなどのOpenIDプロバイダーからの認証を受け付けてIAMロールを付与する

今回はこの後者の機能だけを使っています。

Google側の準備

プロジェクト作成

Google側で認証を行うので、 Firebase console よりプロジェクトを新規作成します。既存のプロジェクトでも構いません。 f:id:nurenezumi:20180605112932p:plain

プロジェクト初期化用コード取得

プロジェクトが作成できたら、「ウェブアプリに Firebase を追加」ボタンを押すとプロジェクト初期化用のJavaScriptのコードが取得できます。これを保存しておきます。 f:id:nurenezumi:20180605113123p:plain

Google認証をONに

コンソールがずいぶん親切になっています。Authentication→「ログイン方法を選択」と進み、GoogleログインをONにします。 f:id:nurenezumi:20180605112053p:plain

承認済みドメインを追加

認証済みドメインにWebサイトを配置するドメインを入力します。 sanwa.local のようなGoogle側では名前解決できない値も受け付けてくれます。

OAuthクライアントID確認

旧来のプロジェクト管理画面 に移動します。 Credentials を見ると既にOAuthクライアントID(以下、単に「クライアントID」と呼びます)が自動生成されているので、このクライアントIDをメモしておきます。なお、このIDは認証時に暗黙的に利用される値なので、削除すると大変面倒なことになります*1

AWS側の準備

IDプール作成

Cognitoの管理画面 でIDプールを作成します。認証プロバイダにはGoogle+を選択し、GoogleクライアントIDには先ほどメモっておいたクライアントIDを入力します。 f:id:nurenezumi:20180605131003p:plain

ロール作成

認証時・非認証時に対応するロールの作成画面が表示されるので、そのまま作成してもらいます。

IDプールID(Identity pool ID)をメモする

サンプルコードが表示されます。その中のIdentity pool IDをメモしておきます。

ドメイン制限をかける

画面右上のリンクからIDプール編集画面を開きます。再度Authentication providersのGoogle+タブを見ると、今度はAuthenticated role selectionという項目が出現しています。ここで下記のような設定をすることで、Google認証されたユーザー情報のhd(Hosted Domain)プロパティに対して制限をかけることができます(このhdという名前はOpenIDで決められています)。

f:id:nurenezumi:20180605131956p:plain

素直に考えるとロールの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の設定を上書きします。

f:id:nurenezumi:20180605131135p:plain

この手順がなぜ必要なのかは謎ですが、自動生成されたクライアント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>

GoogleAPIキーが丸見えだけど……?

静的ファイルで実現しようとしている以上仕方がありません*3。ただ、今回のような利用用途ですと、誰かがこのAPIキーを悪用しようとしてもそのシナリオが思い付きません。問題ないと判断しました。

まとめ

CognitoのIDプールは作成は簡単なのですができることはかなり限定的です。ただ、今回のように使いどころがうまくハマれば非常に手軽に(ほぼコードを書かずに)AWSリソースへの権限を与えられます。これはバグやセキュリティホールも発生しづらいということでもあり、上手に活用していけると良いと感じました。

*1:プロジェクトを作り直すしか方法が見つかりませんでした

*2:https://forums.aws.amazon.com/thread.jspa?messageID=527303

*3:以前AndroidTwitterクライアントからConsumer Keyを取り出す競技が流行したことがありましたが、本質的にはこれと同じです