AWS IoT MQTT over WebSocketをSORACOM EndorseのIMSI認証でSSOする

アイキャッチ

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

ども、大瀧です。

スケーラブルかつ従量課金で激安なマネージドMQTTブローカーのAWS IoT、皆さん試してますか?先日MQTT over WebSocketがサポートされWebブラウザから接続できるようになりました。弊社荒井が早速試しています。

IoTデバイスだけでなくWebアプリケーションやモバイルアプリケーションでもPub/Subブローカーの仕組みとしていろいろ応用できそうですよね。ただ、端末から直接AWS IoTに接続しにいく都合で、認証情報をどうやって持たせるかが課題として挙がってきます。例えば荒井の以下の記事の、認証なしでIDを付ける例があります。

これ以外に、CognitoでFacebook認証などと組み合わせたり、Web APIで独自の認証を作り込みAWSの一時クレデンシャルを発行する方法などが考えられますが、それなりの実装が必要ですしユーザーに認証情報の入力を強いることにもなってしまいます。

そこで今回は、IoTプラットフォームサービスSORACOMの認証サービスであるEndorseを利用してSIM単位の認証を実装しつつ、AWS IoTへのシングルサインオン(SSO)を構成してみたいと思います。

概要

荒井のソースを参考に(丸○クり)した、チャットアプリになっています。

test

※ 今回はAjaxを利用するためにローカルファイルでは無くS3 Static Website Hostingでホストしました。

遷移図でまとめてみました。

figure02_1

まず最初のポイントは、1のEndorseへのリクエストです。SORACOMでEndorseを有効化しSIMを装着したマシンからhttps://endorse.soracom.io/にリクエストを送出すると、IMSIを含めあらかじめ設定された文字列に署名が施され、JWT形式のトークンがレスポンスとして返ってきます。SORACOM以外の経路ではアクセスできず、また署名が施されるのでIMSIの詐称が出来ない仕組みになっています。

さらにhttps://endorse.soracom.io/?redirect_url=<リダイレクト先URL>とリクエストを送ると、レスポンスが302リダイレクトになり、Locationヘッダに<リダイレクト先URL>?soracom_endorse_token=<Endorseトークン>がセットされます。これを利用し、2の処理を行うWeb APIのエンドポイント(今回はAmazon API Gateway & AWS Lambda)にリダイレクトさせてシングルサインオンっぽく動かしてみます。

2のSIMの認証と一時クレデンシャル発行を扱うWeb APIは、AWSのマネージドサービスであるAPI Gateway + Lambdaで組んでみました。普通のHTTPSリクエスト&レスポンスが返せればなんでも良いので、EC2でもAWSでなくとも普通に動くと思います。認証の手順は、以下の記事の実装とほとんど変わりません。レスポンスにAWS IoTのWebsocketエンドポイントと署名済みAWSクレデンシャルをコミコミで返すようにしています。

では、行ってみましょう!

SORACOMの構成

SORACOMユーザーコンソールで、SIMが所属するグループのEndorse設定を有効にしましょう。項目のIMSIのチェックがオンになっていることを確認します。また、今回はHTMLファイルからAjaxでEndorseおよびAPI Gatewayに接続するので、HTMLファイルをホストするURL(今回はAmazon S3 Static Website Hostingを利用)についてCORS(許可するオリジン)を設定します。

websocket-endorse01

リダイレクトを許可するURLも合わせて指定するので、設定から流れがイメージできる感じで良いですね。

Websocketクライアント(HTML&JavaScript)の実装

荒井のソースと比べるとAWSクレデンシャルに関する記述がごっそり無くなっていて、クライアント側で認証情報を持たないことがわかりますね。MQTTクライアントのライブラリ(mqttws31.js)はあらかじめダウンロードし、HTMLファイルと一緒にWebサーバー(今回はAmazon S3)にアップロードしておきます。

<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="http://code.jquery.com/jquery-1.12.0.min.js"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/vue/1.0.16/vue.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 = '';
    });

    var endorse_url = 'https://endorse.soracom.io/?redirect_url=https://XXXXXXXX.execute-api.ap-northeast-1.amazonaws.com/prod/';
    var client = null;
    $.get(endorse_url, function(endpoint) {
      console.log('Endpoint: '+ endpoint);
      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>
  • 28行目 : 上述のSORACOM EndorseのURLとリダイレクト先としてAPI GatewayのURLをセットします。API Gatewayは/リソースへのGETメソッド、prodステージでデプロイしました
  • 30行目 : jQuery.get()はリダイレクトに追随するので、引数endpointにAPI Gatewayのレスポンス(以下のようなAWS IoT Websocketエンドポイント&署名済みAWSクレデンシャル)が代入されます
  • 33-40行目 : MQTTクライアントでAWS IoT Websocketエンドポイントに署名済みAWSクレデンシャルを付与して接続します
"wss://xxxxxxxxxxxxxx.iot.ap-northeast-1.amazonaws.com/mqtt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=ASIAXXXXXXXXXXXXXX%2F20160301%2Fap-northeast-1%2Fiotdevicegateway%2Faws4_request&X-Amz-Date=20160301T081645Z&X-Amz-SignedHeaders=host&X-Amz-Signature=5d3f205ef9c9e7f3a0cadbad0e192f2e9bf8f6be9c3bb991c9a20687a16b6f93&X-Amz-Security-Token=AQoDYXdzEOn%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEakAIwHousIATWYKipXoZdV%2FTOWe0VJZSHLijh%2FcweABrpeP0pJ6nneWQEdiXVp58XwJNatCswJCZH6Dy9w0CU5nQ6lMBuJq6Uwt5w4fjJgYHdwEYNDuYGFVYnkPBJVXjbvwHadLahdRNpx4g9js9m%2BAwSjHFbZuxNko7ZkmPg7wBTGgHPzArjUc9iX4LG0LG6qOuJexP%2BUvFG0YZMnx8NfYMHaKGB2PWEj36beEnJ8dVBjtaEjtekixorEG9dytehBF5Czpr%2BJugOJFwHrAmiAx8prV4jx567KYXjqv4ExUhs%2BBlWhIqlIZkNKGa1u84B67kqG9EE7f3tJ9sGqW6rHBrsD0csC3YgrRI%2B4YEWWtXdPyDtn9W2BQ%3D%3D"

Web API(API Gateway/Lambda)の実装

API Gatewayは、最近実装されたCustom Authorizationを使おうと思ったのですが、残念ながら今回のEndorseの仕様が対応していなかった *1ため、以前の記事の[API Gatewayの設定]と同じくクエリストリングsoracom_endorse_tokenをイベントオブジェクトに渡るよう設定し、バックエンドで認証処理を行っています。

こちらもHTMLファイルからAjaxでアクセスするため、API GatewayでCORS設定を追加しましょう。

websocket-endorse02

Lambda関数の実装を以下に示します。それなりにいろいろやっていますが、コメントの(X-X)で追って見てみてください。

var request = require('request');
var aws     = require('aws-sdk');
var jwt     = require('jsonwebtoken');
var soracom = require('soracom');
var moment  = require('moment');
var crypto  = require('crypto-js');

// SORACOMの認証情報
var sora_operatorId = 'OP00XXXXXXXX';
var sora_authKeyId  = 'keyId-XXXXXXXXXXXXXXXXXXX';
var sora_authKey    = 'secret-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
// SORACOM Endorseの検証用公開キーストア(固定)
var sora_keystore = 'https://s3-ap-northeast-1.amazonaws.com/soracom-public-keys/';

// AWS IoTの情報
var aws_region = 'ap-northeast-1';
var aws_iot_endpoint = 'XXXXXXXXXXXXXX.iot.ap-northeast-1.amazonaws.com';
// Amazon STSでAssumeRoleするロールのARN
var aws_role_arn = 'arn:aws:iam::XXXXXXXXXXXX:role/YOUR_ROLE_NAME';

console.log('Loading event');
exports.handler = function(event, context) {
  // トークンをデコード
  var decoded = jwt.decode(event.token, {complete: true});
  // ヘッダに含まれるキー名からキーストアのURLを生成し、取得
  request(sora_keystore + decoded.header.kid, function (err, response, body){
    var pubkey = body;
    try {
      // トークンの検証が成功すればtryブロックを継続 ----------------------(2-1)
      jwt.verify(event.token, pubkey);
      // デコードしたペイロードからIMSIを取得
      var imsi = decoded.payload['soracom-endorse-claim'].imsi;
      // SORACOM APIにアクセス
      var sora_obj = new soracom({
        operatorId: sora_operatorId,
        authKeyId: sora_authKeyId,
        authKey: sora_authKey
      });
      sora_obj.post('/auth', function(err, res, auth) {
        if (!err) {
          sora_obj.defaults(auth);
          sora_obj.get('/subscribers', function(err, res, body) {
            // SIM一覧から、IMSIを照合 ----------------------------------(2-2)
            var result = body.some(function(subscriber) {
              return subscriber.imsi == imsi;
            })
            if(result) {
              // IMSIの照合が成功したら、AWSの一時クレデンシャルを取得 -----(2-3)
              var sts = new aws.STS();
              var params = {
                RoleArn: aws_role_arn,
                RoleSessionName: 'nominator-temp'
              };
              sts.assumeRole(params, function (err, data) {
                if (!err) {
                  // 一時クレデンシャルを含むレスポンスを返送 --------------(2-4)
                  var endpoint = createEndpoint(
                    aws_region,
                    aws_iot_endpoint,
                    data.Credentials.AccessKeyId,
                    data.Credentials.SecretAccessKey,
                    data.Credentials.SessionToken
                  );
                  context.succeed(endpoint);
                } else {
                  console.log(err, err.stack);
                }
              });
            }else {
              // IMSI照合に失敗した場合
              context.fail('ERROR : Not authorized your device');
            };
          });
        }
      });
    } catch(err) {
      // トークンの検証に失敗した場合
      console.log(err, err.stack);
      context.fail(err);
    }
  });
};

// AWS署名ユーティリティオブジェクト
function SigV4Utils(){}
SigV4Utils.sign = function(key, msg) {
  var hash = crypto.HmacSHA256(msg, key);
  return hash.toString(crypto.enc.Hex);
};
SigV4Utils.sha256 = function(msg) {
  var hash = crypto.SHA256(msg);
  return hash.toString(crypto.enc.Hex);
};
SigV4Utils.getSignatureKey = function(key, dateStamp, regionName, serviceName) {
  var kDate = crypto.HmacSHA256(dateStamp, 'AWS4' + key);
  var kRegion = crypto.HmacSHA256(regionName, kDate);
  var kService = crypto.HmacSHA256(serviceName, kRegion);
  var kSigning = crypto.HmacSHA256('aws4_request', kService);
  return kSigning;
};

// MQTT over Websocket用エンドポイントの生成
function createEndpoint(region, awsIotEndpoint, accessKeyId, secretAccessKey, sessionToken) {
  var time = moment.utc();
  var dateStamp = time.format('YYYYMMDD');
  var amzdate = dateStamp + 'T' + time.format('HHmmss') + 'Z';
  var service = 'iotdevicegateway';
  var algorithm = 'AWS4-HMAC-SHA256';
  var method = 'GET';
  var canonicalUri = '/mqtt';
  var host = awsIotEndpoint.toLowerCase();

  var credentialScope = dateStamp + '/' + region + '/' + service + '/' + 'aws4_request';
  var canonicalQuerystring = 'X-Amz-Algorithm=AWS4-HMAC-SHA256';
  canonicalQuerystring += '&X-Amz-Credential=' + encodeURIComponent(accessKeyId + '/' + 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(secretAccessKey, dateStamp, region, service);
  var signature = SigV4Utils.sign(signingKey, stringToSign);

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

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

  return wssEndpoint;
}

依存モジュールをnpmでインストールし、zipで固めてデプロイしましょう。lambchopというツールが便利です。

$ npm install request aws-sdk jsonwebtoken soracom moment crypto-js
$ vim index.js # lambchopのヘッダ(Lambdaの設定)を先頭に追加
$ chmod +x index.js
$ ./index.js
  :(以下略)

これでおもむろにHTMLファイルをホストするURLにアクセスすると、ユーザーが認証情報を入力すること無くAjaxでEndorseの署名による認証とクレデンシャル取得が実行、MQTTクライアントでAWS IoTのMQTTブローカーに接続してくれます。

まとめ

ユーザーの認証情報をオフロードする方法の一つとして、SORACOM Endorseのトークンを利用する例をご紹介しました。 SORACOMのSIM単位で認証できるので、複数のSIMのIMSIをグループ化してグループチャットを作ったりすることもできそうですね!また、今回は実装しませんでしたがIMEI(デバイス固有のID)を送ることもできるので、IMEIのリストをWeb API側で持ち認証ロジックをいじれば、デバイスとSIMの縛りを強制することも難しくないと思います。

脚注

  1. Identity token sourceにクエリストリングが指定できない

AWS Cloud Roadshow 2017 福岡