AWS IoTのMQTT over WebSocketをCognito(Unauth)で認証して使ってみた

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

前回「AWS IoTのMQTT over WebSocketにHTMLから接続してみた」という記事を書きました。
前回の記事では、AWS IoT Device Gatewayのアクセス権限を持ったIAM UserのAWS Credentialsソースコードにベタ書きして通信していました(※2016/02現在、AWSのドキュメントに従うとこの書き方になります)。
このままではセキュアでないですし、Credentialsをベタ書きしたコードをGithubの公開リポジトリにプッシュしてしまうと、たとえ対象ユーザーの権限が十分に絞られていたとしてもAWSから警告メールが届きます。
そこで、今回はAmazon Cognitoの未認証アイデンティティ(Unauthenticated identities)を使って一時的なAWS Credentialsを取得することで、ソースコードにAWS Credentialsをベタ書きしなくて済むように前回のサンプルチャットを修正してみましょう。

前回のおさらい

前回記事の「AWS IoTのMQTT over WebSocketにHTMLから接続してみた」に記載のソースコードやライブラリをそのまま使用します。
前回作ったサンプルチャットアプリは次のようなものでした。

test

前回のソースコードには、AWS Credentialsをベタ書きしている箇所がありました。

// 前略
    var endpoint = createEndpoint(
        'ap-northeast-1', // Your Region
        'yourendpoint.iot.ap-northeast-1.amazonaws.com', // Require 'lowercamelcase'!!
        'YOUR_AWS_ACCESS_KEY',
        'YOUR_AWS_SECRET_ACCESS_KEY');
// 後略

今回は、Amazon Cognitoを使ってこれらのベタ書きをなくしていきます。

Amazon Cognitoの設定

まずはAmazon Cognitoに今回のサンプルチャット用のIdentity Poolを作りましょう。
マネジメントコンソールのCognitoの画面からCreate new identity poolで作成画面を開くことができます。

スクリーンショット 2016-02-23 16.51.45

上記の画像のように Enable access to unauthenticated identities にチェックを入れて次にすすみます。これで未認証アイデンティティを使ってCognitoから一時的なAWS Credentialsを取得できるようになります。CognitoのUnauthenticated identitiesは、噛み砕くと非ログインユーザーに対してもIdentityIDを発行できるようにするための仕組みです。

スクリーンショット 2016-02-23 17.04.08

次の画面では、認証済ユーザー、未認証ユーザーそれぞれに割り当てるIAM Roleの設定ができます。既存のロールを流用したいなどの理由がなければ、両方とも「新しいIAM Roleの作成」で問題ないでしょう。

ここでは、次の通りIAM Roleが作成されます。

対象 ロール名 備考
認証済ユーザー Cognito_MqttChatSampleAuth_Role 今回は利用しない
未認証ユーザー Cognito_MqttChatSampleUnauth_Role 今回のチャットアプリにAssumeされるロール

最後にCreateボタンを押下して、Identity Poolとそれに紐づくIAM Roleの作成を完了します。

スクリーンショット_2016-02-23_17_29_11

すると最後にSample Codeの画面に飛ばされて、ここでIdentity Pool IDを確認できます。このIdentity Pool IDは後のHTML/JSの実装で使うのでメモしておいて下さい。

IAM Roleの編集

マネジメントコンソールのIAM画面から、今回Cognitoと紐付いているIAM Roleを検索します。

スクリーンショット 2016-02-23 17.11.01

ここから 未認証ユーザー用ロール(実際にサンプルチャットに割り当てられるロール) の権限編集を行います。今回の例で言うとCognito_MqttChatSampleUnauth_Roleですね。

初期設定として、以下のようなインラインポリシーが一つ設定されていると思います。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "mobileanalytics:PutEvents",
                "cognito-sync:*"
            ],
            "Resource": [
                "*"
            ]
        }
    ]
}

これはCognitoのリソースにアクセスするための権限です。今回はこのポリシーとは別に、管理ポリシーとして AWSIoTDataAccess をアタッチします。

スクリーンショット_2016-02-23_17_22_06

前回記事の「AWS IoTのMQTT over WebSocketにHTMLから接続してみた」でも説明したとおり、実際の本番運用にのせるような場合には管理ポリシーではなくより限定的なカスタムポリシーを定義して利用して下さい。

これで未認証ユーザー用のIAM Role(今回のサンプルチャットに割り当てられるIAM Role)の設定が完了しました。

コードの変更

前回のコードを少し変更して、次のようにします。

ソースコード

<html lang="ja">
<body>
  <ul id="chat">
    <li v-for="m in messages">{{ m }}</li>
  </ul>
  <input type="text" name="say" id="say" placeholder="Input a message here...">
  <button id="send">Send</button>

  <script src="https://sdk.amazonaws.com/js/aws-sdk-2.2.37.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.16/vue.min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.11.2/moment.min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/core-min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/hmac-min.js" type="text/javascript"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/3.1.2/components/sha256-min.js" type="text/javascript"></script>
  <script src="./mqttws31.js" type="text/javascript"></script>
  <script type="text/javascript">
    var data = {
      messages: []
    };

    new Vue({
      el: '#chat',
      data: data
    });

    document.getElementById('send').addEventListener('click', function (e) {
      var say = document.getElementById('say')
      send(say.value);
      say.value = '';
    });

    function SigV4Utils(){}

    SigV4Utils.sign = function(key, msg) {
      var hash = CryptoJS.HmacSHA256(msg, key);
      return hash.toString(CryptoJS.enc.Hex);
    };

    SigV4Utils.sha256 = function(msg) {
      var hash = CryptoJS.SHA256(msg);
      return hash.toString(CryptoJS.enc.Hex);
    };

    SigV4Utils.getSignatureKey = function(key, dateStamp, regionName, serviceName) {
      var kDate = CryptoJS.HmacSHA256(dateStamp, 'AWS4' + key);
      var kRegion = CryptoJS.HmacSHA256(regionName, kDate);
      var kService = CryptoJS.HmacSHA256(serviceName, kRegion);
      var kSigning = CryptoJS.HmacSHA256('aws4_request', kService);
      return kSigning;
    };

    function getCredentials(done) {
      AWS.config.region = 'ap-northeast-1';
      var cognitoidentity = new AWS.CognitoIdentity();
      var params = {
        IdentityPoolId: 'YOUR_COGNITO_IDENTITY_POOL_ID'
      };
      cognitoidentity.getId(params, function(err, objectHavingIdentityId) {
        if (err) return done(err);
        cognitoidentity.getCredentialsForIdentity(objectHavingIdentityId, function(err, data) {
          if (err) return done(err);
          var credentials = {
            accessKey: data.Credentials.AccessKeyId,
            secretKey: data.Credentials.SecretKey,
            sessionToken: data.Credentials.SessionToken
          };
          console.log('CognitoIdentity has provided temporary credentials successfully.');
          done(null, credentials);
        });
      });
    }

    function getEndpoint(regionName, awsIotEndpoint, done) {
      getCredentials(function (err, creds) {
        if (err) return done(err);

        var time = moment.utc();
        var dateStamp = time.format('YYYYMMDD');
        var amzdate = dateStamp + 'T' + time.format('HHmmss') + 'Z';
        var service = 'iotdevicegateway';
        var region = regionName;
        var secretKey = creds.secretKey;
        var accessKey = creds.accessKey;
        var algorithm = 'AWS4-HMAC-SHA256';
        var method = 'GET';
        var canonicalUri = '/mqtt';
        var host = awsIotEndpoint;
        var sessionToken = creds.sessionToken;

        var credentialScope = dateStamp + '/' + region + '/' + service + '/' + 'aws4_request';
        var canonicalQuerystring = 'X-Amz-Algorithm=AWS4-HMAC-SHA256';
        canonicalQuerystring += '&X-Amz-Credential=' + encodeURIComponent(accessKey + '/' + credentialScope);
        canonicalQuerystring += '&X-Amz-Date=' + amzdate;
        canonicalQuerystring += '&X-Amz-SignedHeaders=host';

        var canonicalHeaders = 'host:' + host + '\n';
        var payloadHash = SigV4Utils.sha256('');
        var canonicalRequest = method + '\n' + canonicalUri + '\n' + canonicalQuerystring + '\n' + canonicalHeaders + '\nhost\n' + payloadHash;

        var stringToSign = algorithm + '\n' +  amzdate + '\n' +  credentialScope + '\n' +  SigV4Utils.sha256(canonicalRequest);
        var signingKey = SigV4Utils.getSignatureKey(secretKey, dateStamp, region, service);
        var signature = SigV4Utils.sign(signingKey, stringToSign);

        canonicalQuerystring += '&X-Amz-Signature=' + signature;

        var wssEndpoint = 'wss://' + host + canonicalUri + '?' + canonicalQuerystring;
        wssEndpoint += '&X-Amz-Security-Token=' + encodeURIComponent(sessionToken);
        done(null, wssEndpoint);
      });
    }

    var client;
    getEndpoint(
        'ap-northeast-1', 
        'yourendpoint.iot.ap-northeast-1.amazonaws.com',
        function(err, endpoint) {
          if (err) {
            console.log('failed', err);
            return;
          }
          var clientId = Math.random().toString(36).substring(7);
          client = new Paho.MQTT.Client(endpoint, clientId);
          var connectOptions = {
            useSSL: true,
            timeout: 3,
            mqttVersion: 4,
            onSuccess: subscribe
          };
          client.connect(connectOptions);
          client.onMessageArrived = onMessage;
          client.onConnectionLost = function(e) { console.log(e) };
        });

    function subscribe() {
      client.subscribe("Test/chat");
      console.log("subscribed");
    }

    function send(content) {
      var message = new Paho.MQTT.Message(content);
      message.destinationName = "Test/chat";
      client.send(message);
      console.log("sent");
    }

    function onMessage(message) {
      data.messages.push(message.payloadString);
      console.log("message received: " + message.payloadString);
    }
  </script>
</body>
</html>

これでソースコードにAWS Credentialsをベタ書きすることなくMQTT over Websocketに接続することができるようになりました。
ソースコードを確認する上でのポイントを幾つか解説します。

1. AWS SDK for Browserのインポート

Amazon Cognitoとやりとりをするために、AWS SDK for Browserをインポートしています。

2. Amazon Cognitoから一時的なAWS Credentialsを取得する

ソースコード上の getCredentialis 関数の定義で行っている処理です。
このソースコードに含まれる YOUR_COGNITO_IDENTITY_POOL_ID は先程メモしたIdentity Pool IDで置き換えてください。
具体的には未認証ユーザーとしてのIdentity IDを取得して、そのIDに対して発行される一時的なAWS Credentialsを抜き出しています。この一時的なAWS Credentialsは、デフォルト設定では1時間の有効期限を持っています。

3. WSSエンドポイントの最後にCognitoのセキュアトークンをつける

Amazon Cognitoにより貸与された一時的なAWS Credentialsを用いてAWSの署名バージョン4リクエストを生成する場合には、セキュアトークンをリクエストに付与する必要があります。
このセキュアトークンは署名バージョン4に必要な「リクエスト本文のハッシュ値」を生成するのに必要なリクエスト本文には 含めなくてよい ので、今回は getEndpoint の定義の最後でCognitoから返されたセキュアトークンをリクエストに付与しています。

ハマるかもしれないポイント

Cognitoから返されたセキュアトークンはBase64ですが、そのままではURIに含めることができないのでエンコードします。

まとめ

CognitoのUnauthenticated identitiesを使えば、AWS Credentialsをソースコードにベタ書きすることなく、WSSリクエストに必要な署名バージョン4のリクエストを生成することが可能です。
これでセキュアにMQTT over Websocketを使うことができますね!ではまた!