🍮

AWS IoT Coreにカスタム認証でブラウザから接続

2022/11/03に公開

はじめに

ブラウザからでもMQTTブローカーに接続したかったのでAWS IoT CoreにブラウザからMQTT over WebSocketとカスタム認証で接続した時のメモ。クライアント側の例がなかなか見つからなかったので参考になれば。

カスタム認証については「カスタム認証ワークフローについて」に詳しくかいてある。

設定

全体の流れは以下の通り

  1. Authorizerをつくる
  2. Authorizerが呼び出すLambda(index.js)をつくる。この関数が実際の認証・認可を行う
  3. AuthorizerとLambdaを紐つけて、クライアントからの認証リクエストがLamdaに届くようにする
  4. Lambda関数をAuthorizerがInvokeできるようにLambda側に権限(Resource-based policy)を設定する
  5. Default Authorizerを設定し、クライアントがAuthorizerの指定を省略できるようにする(オプション)
  6. クライアントから接続

1. Authorizerをつくる

  • AWS IoT > Security > Custom authorizers > Create authorizer
    • Authorizer name: Authorizer とする。

2. Authorizerが呼び出すLambdaをつくる

  • Authorizer function > Lambda function > Create a Lambda function

    • Select Author from scratch
    • FunctionName: AuthorizerFunction
    • Create function
  • Lambda > Functions > AuthorizerFunction

    • 以下のコードを index.jsに貼り付ける。コード内のアカウント番号、リージョン、MQTTトピック、MQTTクライアント名は自分のものに置き換えて、Deploy を押す。
index.js
// A simple Lambda function for an authorizer. It demonstrates 
// how to parse an MQTT password and generate a response.

exports.handler = function(event, context, callback) { 
    
    var uname = event.protocolData.mqtt.username;
    var pwd = event.protocolData.mqtt.password;
    console.log("pwd: " + pwd);
    
    var buff = new Buffer(pwd, 'base64');
    var passwd = buff.toString('ascii');
    console.log("passwd: " + passwd);
    
    switch (passwd) {
        case 'test':
        callback(null, generateAuthResponse(passwd, 'Allow'));
        default:
        callback(null, generateAuthResponse(passwd, 'Deny'));
    }
};

// Helper function to generate the authorization response.
var generateAuthResponse = function(token, effect) { 
    var authResponse = {}; 
    authResponse.isAuthenticated = true; 
    authResponse.principalId = 'TEST123'; 
    
    var policyDocument = {}; 
    policyDocument.Version = '2012-10-17'; 
    policyDocument.Statement = []; 
    var connectStatement = {};
    var receiveStatement = {};
    var subscribeStatement = {};
    
    connectStatement.Action = ["iot:Connect"];
    connectStatement.Effect = effect;
    connectStatement.Resource = ["arn:aws:iot:ap-northeast-1:<account-id>:client/browser"];
    
    subscribeStatement.Action = ["iot:Subscribe"]; 
    subscribeStatement.Effect = effect; 
    subscribeStatement.Resource = ["arn:aws:iot:ap-northeast-1:<account-id>:topicfilter/*"];
    
    receiveStatement.Action = ["iot:Receive"]; 
    receiveStatement.Effect = effect; 
    receiveStatement.Resource = ["arn:aws:iot:ap-northeast-1:<account-id>:topic/*"];
    
    policyDocument.Statement[0] = connectStatement;
    policyDocument.Statement[1] = subscribeStatement;
    policyDocument.Statement[2] = receiveStatement;
    
    authResponse.policyDocuments = [policyDocument]; 
    authResponse.disconnectAfterInSeconds = 3600; 
    authResponse.refreshAfterInSeconds = 300;
    
    return authResponse; 
}

公式のサンプルにはiot:Receiveがないが、私の環境ではiot:Receiveを指定しない場合、クライアントからのpublishもsubscribeもエラーとなった。このサンプルの Helper function わかりにくい... JSオブジェクトを直に書いた方がいいのでは?

3. AuthorizerとLambdaを紐つける

  1. AWS IoT > Security > Custom authorizers > Create authorizer に戻る。
  2. Authorizer function > Lambda function > Lambda function で上記で作った Function を選択してCreate
  3. AWS IoT > Security > Custom authorizersでActivate
aws iot describe-authorizer --authorizer-name Authorizer
{
    "authorizerDescription": {
        "authorizerName": "Authorizer",
        "authorizerArn": "arn:aws:iot:ap-northeast-1:<account-id>:authorizer/Authorizer",
        "authorizerFunctionArn": "arn:aws:lambda:ap-northeast-1:<account-id>:function:AuthorizerFunction",
        "status": "ACTIVE",
        "creationDate": "2022-09-07T10:01:25.419000+09:00",
        "lastModifiedDate": "2022-09-07T11:26:39.092000+09:00",
        "signingDisabled": true
    }
}

最初に試しで設定するときは、signingDisabled: trueのままで、してToken validationをスキップしたほうがよい。

4. AuthorizerがLambdaをInvokeできるように権限を設定する

  • Resource-based policyを設定する
aws lambda add-permission --function-name AuthorizerFunction --principal iot.amazonaws.com --source-arn arn:aws:iot:ap-northeast-1:<account-id>:authorizer/Authorizer --statement-id Id-123 --action "lambda:InvokeFunction"
{
    "Statement": "{\"Sid\":\"Id-123\",\"Effect\":\"Allow\",\"Principal\":{\"Service\":\"iot.amazonaws.com\"},\"Action\":\"lambda:InvokeFunction\",\"Resource\":\"arn:aws:lambda:ap-northeast-1:<account-id>:function:AuthorizerFunction\",\"Condition\":{\"ArnLike\":{\"AWS:SourceArn\":\"arn:aws:lambda:ap-northeast-1:<account-id>:function:AuthorizerFunction\"}}}"
}

ちなみにGUIだとここで設定できる。

  • Lambda > Functions > AuthorizerFunction > Configuration tab > Resource-based policy

5. Default Authorizerを設定する(オプション)

設定

aws iot set-default-authorizer --authorizer-name Authorizer
{
    "authorizerName": "Authorizer",
    "authorizerArn": "arn:aws:iot:ap-northeast-1:<account-id>:authorizer/Authorizer"
}

(参考) Default authorizerのクリアと確認方法

  • aws iot clear-default-authorizer
  • aws iot describe-default-authorizer

テスト

Authorizerの動作を確認してみる。

awscli
aws iot test-invoke-authorizer --authorizer-name Authorizer  \
--mqtt-context '{"username": "test", "password": "dGVzdA==", "clientId":"browser"}'
{
    "isAuthenticated": true,
    "principalId": "TEST123",
    "policyDocuments": [
        "{\"Version\":\"2012-10-17\",\"Statement\":[{\"Action\":[\"iot:Connect\"],\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:iot:us-east-1:<account-id>:client/browser\"]},{\"Action\":[\"iot:Publish\"],\"Effect\":\"Allow\",\"Resource\":[\"arn:aws:iot:us-east-1:<account-id>:topic/notification/browser\"]}]}"
    ],
    "refreshAfterInSeconds": 300,
    "disconnectAfterInSeconds": 3600
}

awscliでテストする時は、パスワードはbase64エンコード echo -n test | base64dGVzdA== が必要。エンコードしないと結果が "isAuthenticated": true にはなるが \"Effect\":\"Deny\" になってしまう。

6. クライアントから接続

まずはMosquittoクライアントからMQTT over WebSocketで接続する

mosquitto_pub
mosquitto_pub \
--cafile AmazonRootCA1.pem \
-h <endpoint>-ats.iot.ap-northeast-1.amazonaws.com \
-p 443 \
-u 'test' \
-P 'test' \
-t 'notification/browser' \
-i browser \
--tls-alpn mqtt \
-l \
-d
mosquitto_sub
mosquitto_sub \
--cafile thing/AmazonRootCA1.pem \
-h <endpoint>-ats.iot.ap-northeast-1.amazonaws.com \
-p 443 \
-u 'test?x-amz-customauthorizer-name=Authorizer' \
-P 'test' \
-t 'notification' \
-i browser \
--tls-alpn mqtt \
-d
  • ポートは443
  • WebSocketの上のプロトコルを指定するためにTLS-ALPN(Application Layer Protocol Negotiation) を指定 --tls-alpn mqtt
  • Default Authorizer が設定されてない場合は -u 'test?x-amz-customauthorizer-name=Authorizer' として指定する。

MQTT.js クライアントからMQTT over WebSocketで接続する

mqtt.js
const mqtt = require('mqtt');
const fs = require('fs');

const options ={
  clean: true,
  connectTimeout: 4000,
  clientId: 'browser',
  username: 'test',
  password: 'test',
}
const client = mqtt.connect('wss://<endpoint>-ats.iot.ap-northeast-1.amazonaws.com:443?x-amz-customauthorizer-name=Authorizer', options);

client.on('connect', () => {
  console.log('connected');
});

ここではURLのQuery stringでAuthorizerの名前を指定しているが、これは以下のように usernameにつけてもよい。

username: 'test?x-amz-customauthorizer-name=Authorizer'

デバッグモードはONにしておいたほうがよい

npm install debug
DEBUG='mqttjs*' node my_mqtt_client.js

あとは、CDNからMQTT.jsを読み込むなどして、上のコードを貼り付ければブラウザから動くでしょう。

うまくいかない時は

うまくいかない時は以下の方法でログを確認するとよい。

  • IoT Core の Authrizer aws logs tail --follow AWSIotLogsV2
  • Lamda AuthrizerFunction aws logs tail --follow /aws/lambda/AuthorizerFunction

まとめ

  • 公式の Lambda関数のサンプル だと、ブローカーからのPublish-Outが失敗する。おそらくPermission周りが原因。そのため、index.jsはちょっと書き換えている。
  • サンプルではパスワードをLambdaにハードコーディングしているが、実際はパスワードDBを別に持つことになるだろう。
  • 今回はAuthorizerの Token validation は使っていない。これGUIで一回ONしたら、OFFにできなくなってしまって、Authorizerを作り直すハメになった(謎)。作り直してAuthorizerの名前が変わったらLambdaのresouce-policyのAWS:SourceArn部分もアップデートが必要なので注意
  • 一般的に証周りの設定はどこでコケているのか見えないとつらい。そのため最初からAWS IoT Core, Lambda のログをCloudWatchで見られるように設定しておくと切り分けが早い。

Discussion