Cognitoを使ってブラウザアプリからAWS IoTへのPub/Sub権限を制御する

AWS IoTとCognitoを連携させて、AWS IoTへのPub/Sub権限を制御してみました!
2018.09.26

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

はじめに

サーバーレス開発部@大阪の岩田です。 前回のブログでAWS IoTのカスタムオーソライザーを用いた、ブラウザアプリの権限管理に挑戦して撃沈しました。

ブラウザアプリからAWS IoTのカスタムオーソライザーが使えなかった件

今回はベタにCognitoと連携した権限管理に挑戦したいと思います。

やりたい事

今回の目標は下記のようなシナリオを実現する事です。

  • Cognitoを利用してブラウザアプリにログインする
  • ブラウザアプリにログインしている場合、対象ユーザーが所有するデバイスに関連するトピックにPublish・Subscribeできるようにする
  • ブラウザアプリにログインしていない場合や、対象ユーザーが所有するデバイスに関連しないトピックへのPublish・Subscribeは拒否する

必要な設定

今回のシナリオを実現するに当たって、下記の設定が必要になります。

  • Cognito認証済みロールにAWS IoTの操作を許可するポリシーをアタッチ
  • CognitoアイデンティティIDに対して、AWS IoTの適切なポリシーをアタッチ
  • AWS IoTのモノにCognitoアイデンティティIDをアタッチ

それでは、1つづつ順を追って環境を構築していきます!!

必要なAWSリソースの作成

まず下記のCFnテンプレートから必要なAWSリソースを作成します。

AWSTemplateFormatVersion: '2010-09-09'
Description: Create Cognito User Pool
Resources:
  CognitoUserPoolMyUserPool:
    Type: "AWS::Cognito::UserPool"
    Properties:
      AdminCreateUserConfig:
        AllowAdminCreateUserOnly: false
        UnusedAccountValidityDays: 7
      AutoVerifiedAttributes:
        - email
      Policies:
        PasswordPolicy:
          MinimumLength: 8
          RequireLowercase: false
          RequireNumbers: true
          RequireSymbols: false
          RequireUppercase: false
      Schema:
        - AttributeDataType: "String"
          DeveloperOnlyAttribute: false
          Mutable: true
          Name: "email"
          StringAttributeConstraints:
            MaxLength: "2048"
            MinLength: "0"
          Required: true
        - AttributeDataType: "String"
          DeveloperOnlyAttribute: false
          Mutable: true
  UserPoolClient:
    Type: AWS::Cognito::UserPoolClient
    Properties:
      ClientName:
        Fn::Join:
          - ""
          - - Ref: AWS::StackName
            - UserPoolClient
      GenerateSecret: false
      RefreshTokenValidity: 7
      UserPoolId:
        Ref: CognitoUserPoolMyUserPool
  MyIdentifyPool:
    Type: "AWS::Cognito::IdentityPool"
    Properties:
      IdentityPoolName: "MyIdentifyPool"
      AllowUnauthenticatedIdentities: true
      CognitoIdentityProviders:
        - ClientId:
            Ref: UserPoolClient
          ProviderName:
            Fn::Join:
            - ""
            - - "cognito-idp."
              - Ref: "AWS::Region"
              - ".amazonaws.com/"
              - Ref: CognitoUserPoolMyUserPool
          ServerSideTokenCheck: false
  CognitoUnAuthRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Federated:
                - "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool
              ForAnyValue:StringLike:
                "cognito-identity.amazonaws.com:amr": "unauthenticated"             
  CognitoAuthRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AWSIoTFullAccess"
      AssumeRolePolicyDocument:
        Version: "2012-10-17"
        Statement:
          - Effect: "Allow"
            Principal:
              Federated:
                - "cognito-identity.amazonaws.com"
            Action:
              - "sts:AssumeRoleWithWebIdentity"
            Condition:
              StringEquals:
                "cognito-identity.amazonaws.com:aud": !Ref MyIdentifyPool
              ForAnyValue:StringLike:
                "cognito-identity.amazonaws.com:amr": authenticated
  CognitoRoleMapping:
    Type: AWS::Cognito::IdentityPoolRoleAttachment
    Properties:
      IdentityPoolId: !Ref MyIdentifyPool
      Roles:
        authenticated: !GetAtt CognitoAuthRole.Arn
        unauthenticated: !GetAtt CognitoUnAuthRole.Arn                
  IoTPolicy: 
    Type: AWS::IoT::Policy
    Properties: 
      PolicyDocument: 
        Version: "2012-10-17"
        Statement: 
          - 
            Effect: "Allow"
            Action: 
              - "iot:*"
            Resource: 
              - "*"

  IoTThing1:
    Type: AWS::IoT::Thing
    Properties:
      AttributePayload:
        Attributes: 
          myAttributeA: "MyAttributeValueA"
          myAttributeB: "MyAttributeValueB"
          myAttributeC: "MyAttributeValueC"        
Outputs:
  UserPoolId:
    Description: "User Poll ID"
    Value:
      Ref: CognitoUserPoolMyUserPool
  UserPoolClient:
    Description: "User Pool Client ID"
    Value:
      Ref: UserPoolClient    
  MyIdentifyPool:
    Description: "Identify Pool ID"
    Value:
      Ref: MyIdentifyPool
  IoTPolicy:
    Description: "AWS IoT Policy"
    Value:
      Ref: IoTPolicy
  IoTThing1:
    Description: "AWS IoT Thing1"
    Value:
      Ref: IoTThing1

必要なCognitoユーザープール、 フェデレーティッドアイデンティティ、AWS IoTのモノやポリシーが作成されます。

ポイントとして

  CognitoAuthRole:
    Type: AWS::IAM::Role
    Properties:
      ManagedPolicyArns:
        - "arn:aws:iam::aws:policy/AWSIoTFullAccess"

の部分で、Cognito認証済みロールに対してAWS IoTへのフルアクセス権を付与しています。

動作確認用Reactアプリの作成

動作確認にはReactとAWS Amplifyを使って作成した簡易なアプリを使います。 AWS IoTでCognito認証を使う場合はHTTPリクエストにSigv4の署名を付ける必要があるのですが、AWS Amplifyを使えば勝手にやってくれるのでラクチンです。

バージョン等

検証に用いた環境の各種バージョンは下記の通りです。

  • Node.js:v8.10.0
  • aws-amplify:1.0.11
  • aws-amplify-react:2.0.1
  • react:16.5.0
  • react-dom:16.5.0
  • react-scripts:1.1.5

Reactアプリの作成

まずcreate-react-appでアプリのひな形を作成し、npm install --save aws-amplify aws-amplify-reactで追加のライブラリを導入します。

ライブラリが導入できたら、App.jsを下記のように修正します。 ハイライト箇所はCloudFormationの出力を参考に、自分の環境に合わせて修正して下さい。

import React, { Component } from 'react';
import './App.css';
import { withAuthenticator } from 'aws-amplify-react';
import Amplify, { PubSub } from 'aws-amplify';
import { AWSIoTProvider } from '@aws-amplify/pubsub';

Amplify.configure({
    Auth: {
        identityPoolId: 'us-east-1:xxxxxxxxxx', 
        region: 'us-east-1', 
        userPoolId: 'us-east-1_xxxxxxxxxx', 
        userPoolWebClientId: 'xxxxxxxxxx', 
    }
});

Amplify.addPluggable(new AWSIoTProvider({
  aws_pubsub_region: 'us-east-1',
  aws_pubsub_endpoint: 'wss://xxxxxxxxxx.iot.us-east-1.amazonaws.com/mqtt',
  clientId: 'xxxxxxxxxxxxxxxxxxxx' //AWS IoTに作成したモノの名前
}))

class App extends Component {

  constructor(props) {
    super(props);
    this.state = {
      messages: [],
      topic: ''
    }

    this.handleSubscribe = this.handleSubscribe.bind(this)    
    this.handlePublish = this.handlePublish.bind(this)
    this.handleChangeTopic = this.handleChangeTopic.bind(this)
    this.handleReceive = this.handleReceive.bind(this)
  }
 
  render() {
    return (
      <div className="App">
        <label>TOPIC:
          <input type="text" onChange={this.handleChangeTopic}></input>
        </label><br/>
        <button onClick={this.handlePublish}>publish</button>
        <button onClick={this.handleSubscribe}>subscribe</button>
        <MessageList messages={this.state.messages} />
      </div>
    )
  }

  handlePublish(){
    PubSub.publish(this.state.topic, 'pub message from browser!!')    
  }
  
  handleSubscribe(topic){
    PubSub.subscribe(this.state.topic).subscribe({
      next: this.handleReceive,
      error: error => console.error(error),
      close: () => console.log('Done'),
    })
  }

  handleChangeTopic(event) {
    this.setState({topic: event.target.value})
  }

  handleReceive(msg){
    const messages = this.state.messages
    messages.push(JSON.stringify(msg.value))
    this.setState({messages: messages})
  }
}
 
 
class MessageList extends React.Component {
 
  render() {
    let id = 1
    return (
      <ul>
        {this.props.messages.map(message => {
          id += 1
          return (
           <li key={id}>
             <div>
               {message}
             </div>
           </li>
         )
       })}
     </ul>
    )
  }
}
 
export default withAuthenticator(App)

動作確認

準備ができたので、実際に動作確認していきます。 npm startでアプリを起動し、サインアップ・サインイン後に、適当なトピックを入力してpublishしてみます。

コンソールにエラーメッセージが表示され失敗しました。 Cognito認証済みロールへの権限付与に加えて、サインアップ時に作成されたCognitoアイデンティティIDに対して権限設定が必要なためです。

CognitoアイデンティティIDに対して、AWS IoTの適切なポリシーをアタッチ

2018年9月現在、マネジメントコンソールからの操作がサポートされていないので、AWS CLIを利用して下記のコマンドでポリシーをアタッチします。

aws iot attach-policy --policy-name <作成したポリシー名> --target <作成されたCognitoアイデンティティID>

CognitoアイデンティティIDはマネジメントコンソールのIDブラウザ等から確認可能です。

なお、アタッチ作業自体はマネジメントコンソールから行えませんが、CLIからアタッチした後はマネジメントコンソールから参照できるようになります。

ポリシーアタッチ後に改めてブラウザアプリから動作確認します。

成功です!!

Pub・Sub可能なトピックを対象ユーザーが所有するデバイスに関連するトピックに制限

このままだと、何でもかんでもPub・Subできてしまうので、権限を絞っていきます。

作成したAWS IoTのポリシーを下記のように修正します。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Condition": {
        "Bool": {
          "iot:Connection.Thing.IsAttached": [
            "true"
          ]
        }
      },
      "Action": [
        "iot:Connect"
      ],
      "Resource": [
        "*"
      ],
      "Effect": "Allow"
    },
    {
      "Action": [
        "iot:Publish",
        "iot:Receive"
      ],
      "Resource": [
        "arn:aws:iot:us-east-1:xxxxxxxxxx:topic/${iot:Connection.Thing.ThingName}/*"
      ],
      "Effect": "Allow"
    },
    {
      "Action": [
        "iot:Subscribe"
      ],
      "Resource": [
        "arn:aws:iot:us-east-1:xxxxxxxxxx:topicfilter/${iot:Connection.Thing.ThingName}/*"
      ],
      "Effect": "Allow"
    }
  ]
}

ポイントとしてポリシー変数iot:Connection.Thing.IsAttachediot:Connection.Thing.ThingNameを使用して制御を行なっています。 それぞれのポリシー変数の説明をAWSのドキュメントから引用します。

iot:Connection.Thing.IsAttached

ポリシーが評価されているモノに、証明書または Amazon Cognito ID がアタッチされている場合は、true に解決されます。 このポリシー変数を使う事で、サインインしていないユーザーの接続を拒否します。

iot:Connection.Thing.ThingName

これは、ポリシーが評価されているモノの名前に解決されます。モノ名は、MQTT/WebSocket 接続のクライアント ID に設定されます。このポリシー変数は、MQTT または MQTT over WebSocket プロトコルに接続するときにのみ使用できます。 モノのポリシー変数

Cognito認証を使ってAWS IoTに接続した場合は、${iot:Connection.Thing.ThingName}の部分がMQTTのクライアントIDに置きかわります。 このポリシー変数を使う事で、<対象ユーザーが所有するモノの名前>/から始まるトピックにのみPub・Subを許可するように制御します。

動作確認

トピック名の指定を<作成したAWS IoTのモノの名前>/に変更し、改めて動作確認してみます。

失敗しました。 今度はCognitoアイデンティティIDとAWS IoTのモノを紐付けてやる必要があります。

AWS IoTのモノにCognitoアイデンティティIDをアタッチ

AWS CLIで下記のコマンドでポリシーをアタッチします。

aws iot attach-thing-principal --thing-name <ユーザーに紐付けるモノの名前> --principal <対象ユーザーのCognitoアイデンティティID>

これでAWS IoTのモノとCognitoアイデンティティIDが紐付きました。

MQTTのクライアントIDはブラウザ側で自由に設定可能な項目ですが、こうやってCognitoアイデンティティIDと紐付けておく事で、セキュリティが担保されます。

改めて動作確認してみます。

成功です! 念のためマネジメントコンソールの方で同じトピックをSubscribeしつつ、ブラウザからパブリッシュしてみます

マネジメントコンソール側でもブラウザから正常にPublish出来ている事が確認できます。

最後に、別のユーザーでサインインし直して、同じトピックに対してPub・Subしてみます。

想定通り失敗しました!! これで設定完了です。

まとめ

Cognitoを利用したAWS IoTへの権限管理について調べてみました。

  • ポリシーをアタッチすべき箇所が多いことと
  • ポリシー変数の挙動を理解していないと思うような権限制御が行えない

というところに苦戦しました。

実案件に利用する際は、CognitoアイデンティティIDに対するAWS IoTのポリシーのアタッチと、AWS IoTのモノに対するCognitoアイデンティティIDのアタッチをどのように実現するか、業務フローの検討や周辺アプリの作り込みが重要になりそうです。

誰かの参考になれば幸いです。