この記事は公開されてから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)を構成してみたいと思います。
概要
荒井のソースを参考に(丸○クり)した、チャットアプリになっています。
※ 今回はAjaxを利用するためにローカルファイルでは無くS3 Static Website Hostingでホストしました。
遷移図でまとめてみました。
まず最初のポイントは、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(許可するオリジン
)を設定します。
リダイレクトを許可するURLも合わせて指定するので、設定から流れがイメージできる感じで良いですね。
Websocketクライアント(HTML&JavaScript)の実装
荒井のソースと比べるとAWSクレデンシャルに関する記述がごっそり無くなっていて、クライアント側で認証情報を持たないことがわかりますね。MQTTクライアントのライブラリ(mqttws31.js
)はあらかじめダウンロードし、HTMLファイルと一緒にWebサーバー(今回はAmazon S3)にアップロードしておきます。
chat.html
<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クレデンシャルを付与して接続します
AWS IoTへの接続URLの例
"wss://xxxxxxxxxxxxxx.iot.ap-northeast-1.amazonaws.com/mqtt
Web API(API Gateway/Lambda)の実装
API Gatewayは、最近実装されたCustom Authorizationを使おうと思ったのですが、残念ながら今回のEndorseの仕様が対応していなかった *1ため、以前の記事の[API Gatewayの設定]と同じくクエリストリングsoracom_endorse_token
をイベントオブジェクトに渡るよう設定し、バックエンドで認証処理を行っています。
こちらもHTMLファイルからAjaxでアクセスするため、API GatewayでCORS設定を追加しましょう。
Lambda関数の実装を以下に示します。それなりにいろいろやっていますが、コメントの(X-X)
で追って見てみてください。
index.js
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の縛りを強制することも難しくないと思います。
脚注
- Identity token sourceにクエリストリングが指定できない ↩