Cognitoを使用したブラウザアプリからIoT CoreへPubSubしてみる

Cognitoを使用したブラウザアプリからIoT CoreへPubSubしてみます
2023.02.15

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

こんにちは。CX事業本部Delivery部サーバーサイドチームの木村です。

概要

IoT Coreではデバイスやクライアントの認証用に複数の方法が用意されています。 X.509証明書を用いた方法はよく紹介されていますが、今回Cognitoを使用した方法について検証する機会がありましたので書いていこうと思います。

CognitoやIAMの準備

CDKで作成します。

以下のコマンドで雛形を作成します。

$ npx cdk init sample-app --language=typescript

主要な部分は以下です。

import * as cdk from 'aws-cdk-lib';
import * as cognito from 'aws-cdk-lib/aws-cognito';
import * as iam from 'aws-cdk-lib/aws-iam';
import * as iot from 'aws-cdk-lib/aws-iot';
import { Construct } from 'constructs';

export class CdkStack extends cdk.Stack {
  constructor(scope: Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const userPool = new cognito.UserPool(this, 'sample-user-pool-20230212', {
      userPoolName: 'sample-user-pool-20230212',
      signInCaseSensitive: false, 
      selfSignUpEnabled: false,
      signInAliases: {
        email: true,
      },
      autoVerify: { email: true },
      keepOriginal: {
        email: true,
      },
      mfa: cognito.Mfa.OFF,
      passwordPolicy: {
        minLength: 8,
        requireLowercase: true,
        requireUppercase: true,
        requireDigits: true,
        requireSymbols: true,
        tempPasswordValidity: cdk.Duration.days(3),
      },
      accountRecovery: cognito.AccountRecovery.NONE,
      email: cognito.UserPoolEmail.withCognito('no-reply@verificationemail.com'),
    });
    const userPoolClient = userPool.addClient('app-client', {
      supportedIdentityProviders: [
        cognito.UserPoolClientIdentityProvider.COGNITO,
      ],
    });

    const identityPool = new cognito.CfnIdentityPool(this, 'sample-identity-pool-20230212', {
      identityPoolName: 'sample-identity-pool-20230212',
      allowUnauthenticatedIdentities: false,
      cognitoIdentityProviders: [
        {
          clientId: userPoolClient.userPoolClientId,
          providerName: userPool.userPoolProviderName,
        },
      ],
    });

    const unauthenticatedRole = new iam.Role(
      this,
      'sample-unauthenticated-role-20230212',
      {
        assumedBy: new iam.FederatedPrincipal(
          'cognito-identity.amazonaws.com',
          {
            StringEquals: {
              'cognito-identity.amazonaws.com:aud': identityPool.ref,
            },
            'ForAnyValue:StringLike': {
              'cognito-identity.amazonaws.com:amr': 'unauthenticated',
            },
          },
          'sts:AssumeRoleWithWebIdentity',
        ),
      },
    );
    unauthenticatedRole.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions:  [
        "mobileanalytics:PutEvents",
        "cognito-sync:*",
        "cognito-identity:*"
      ],
      resources: ["*"]
    }))


    const authenticatedRole = new iam.Role(this, 'sample-authenticated-role-20230212', {
      assumedBy: new iam.FederatedPrincipal(
        'cognito-identity.amazonaws.com',
        {
          StringEquals: {
            'cognito-identity.amazonaws.com:aud': identityPool.ref,
          },
          'ForAnyValue:StringLike': {
            'cognito-identity.amazonaws.com:amr': 'authenticated',
          },
        },
        'sts:AssumeRoleWithWebIdentity',
      ),
      managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('AWSIoTFullAccess'),
      ],
    });

    authenticatedRole.addToPolicy(new iam.PolicyStatement({
      effect: iam.Effect.ALLOW,
      actions:  [
        "mobileanalytics:PutEvents",
        "cognito-sync:*",
        "cognito-identity:*"
      ],
      resources: ["*"]
    }))

    new cognito.CfnIdentityPoolRoleAttachment(
      this,
      'identity-pool-role-attachment',
      {
        identityPoolId: identityPool.ref,
        roles: {
          authenticated: authenticatedRole.roleArn,
          unauthenticated: unauthenticatedRole.roleArn,
        },
        roleMappings: {
          mapping: {
            type: 'Token',
            ambiguousRoleResolution: 'AuthenticatedRole',
            identityProvider: `cognito-idp.${
              cdk.Stack.of(this).region
            }.amazonaws.com/${userPool.userPoolId}:${
              userPoolClient.userPoolClientId
            }`,
          },
        },
      },
    );
    const iotPolicy = new iot.CfnPolicy(this, 'sample-iot-policy-20230212', {
      policyDocument: {
        "Version": "2012-10-17",
        "Statement": [
          {
            "Effect": "Allow",
            "Action": "iot:*",
            "Resource": "*"
          }
        ]
      },
      policyName: 'sample-iot-policy-20230212',
    });

    // Amplidfyの設定で使用します。 ... (1)
    new cdk.CfnOutput(this, 'identity-pool-id-output', {
      value: identityPool.ref!,
      exportName: 'identityPoolId',
    });
    // Amplidfyの設定で使用します。 ... (2)
    new cdk.CfnOutput(this, 'user-pool-id-output', {
      value: userPool.userPoolId,
      exportName: 'userPoolId',
    });
    // Amplidfyの設定で使用します。 ... (3)
    new cdk.CfnOutput(this, 'user-pool-web-client-id-output', {
      value: userPoolClient.userPoolClientId,
      exportName: 'userPoolWebClientId',
    });
    // CLIで使用します。 ... (4)
    new cdk.CfnOutput(this, 'iot-policy-name-output', {
      value: iotPolicy.ref,
      exportName: 'iotPolicyName',
    });

  }
}

ポイントとしては、Cognitoの認証されたロールにAWS IoTのポリシーをアタッチしている箇所です。

const authenticatedRole = new iam.Role(this, 'sample-authenticated-role-20230212', {
    // ... 省略 ...
    managedPolicies: [
        iam.ManagedPolicy.fromAwsManagedPolicyName('AWSIoTFullAccess'),
    ],
});

また、各種アイデンティティとポリシーを紐づけていきます。

$ aws iot attach-policy --policy-name << CDKのoutputの④ >> --target << cognitoのアイデンティティ >>

cognitoのアイデンティティは以下のようにマネジメントコンソールから確認できます。

サンプルのThingを作成し、Cognitoのアイデンティティと紐付けます。

$ THING_NAME=sample-thing-20230212
$ aws iot create-thing --thing-name $THING_NAME
$ aws iot attach-thing-principal --thing-name $THING_NAME --principal << cognitoのアイデンティティ >>

完了するとIoT Coreのマネジメントコンソールのモノ > 証明書タブで以下のように確認できます。

Webフロントエンドのアプリの例

Reactを用いて作成しました。

$ npx create-react-app sample-app --template typescript --use-yarn

IoT CoreのDataエンドポイントを確認します。

$ aws iot describe-endpoint --endpoint-type iot:Data-ATS

/src/App.ts

import { FC, useState } from "react";
import { Amplify, PubSub, Hub } from "aws-amplify";
import { AWSIoTProvider } from "@aws-amplify/pubsub";
import {
  withAuthenticator,
  WithAuthenticatorProps,
} from "@aws-amplify/ui-react";
import "@aws-amplify/ui-react/styles.css";

Amplify.configure({
  Auth: {
    identityPoolId: "CDKのoutput(1)",
    region: "ap-northeast-1",
    userPoolId: "CDKのoutput(2)",
    userPoolWebClientId: "CDKのoutput(3)",
  },
});

Amplify.addPluggable(
  new AWSIoTProvider({
    aws_pubsub_region: "ap-northeast-1",
    aws_pubsub_endpoint:
      "wss://<< 上で確認したIoT CoreのDataエンドポイント >>/mqtt",
  })
);

const App: FC = ({ signOut, user }: WithAuthenticatorProps) => {
  const [messages, setMessage] = useState<string[]>([]);
  const [status, setState] = useState<string[]>([]);
  const [count, setCount] = useState(0);
  const [sub, setSub] = useState<any>();

  const handlePublish = async () => {
    setCount(count + 1);
    await PubSub.publish("myTopic1", { msg: `times: ${count}` });
    console.log("Published!");
  };

  const handleSubscribe = async () => {
    const sub = await PubSub.subscribe("myTopic1").subscribe({
      next: (data) => {
        console.log("Message received", data);
        setMessage((messages) => [...messages, JSON.stringify(data.value)]);
      },
      error: (error) => console.error(error),
      complete: () => console.log("Done"),
    });
    setSub(sub);
    console.log("subscribed!");
  };

  const handleUnsubscribe = async () => {
    await sub.unsubscribe();
    console.log("Unsubscribed!");
  };

  const handleChangeState = (data: any) => {
    console.log(data);
    setState((status) => [
      ...status,
      JSON.stringify(data.payload.data.connectionState),
    ]);
  };

  const handleHub = async () => {
    await Hub.listen("pubsub", (data) => handleChangeState(data));
    console.log("Hub listened!");
  };

  return (
    <>
      <h1>Hello {user?.username}</h1>
      <button onClick={signOut}>Sign out</button>
      <hr />
      <button onClick={() => handlePublish()}>publish</button>
      <button onClick={() => handleSubscribe()}>subscribe</button>
      <button onClick={() => handleUnsubscribe()}>unsubscribe</button>
      <ul>
        {messages.map((message, index) => {
          return <li key={index}>{message}</li>;
        })}
      </ul>
      <hr />
      <button onClick={() => handleHub()}>hub listen</button>
      <ul>
        {status.map((state, index) => {
          return <li key={index}>{state}</li>;
        })}
      </ul>
      <hr />
    </>
  );
};

export default withAuthenticator(App);

動作確認

Reactアプリをローカルで立ち上げます。

$ yarn start

①Hub

Hubモジュールを用いてsubscribe, unsubscribeの状態を確認できるよう待ち受けます。

②subscribe

また、topicをsubscribeします。

先ほどのHubモジュールで待ち受けた箇所で、「Connecting」, 「Connected」といったステータスが表示されます。

③publish

topicへpublishします。 受信したメッセージ (「{"msg":"times: 0"}」, 「{"msg":"times: 1"}」, 「{"msg":"times: 2"}」 といった感じのやつ)がアプリ上で表示されます。

④unsubscribe

topicをunsubscribeします

先ほどのHubモジュールで待ち受けた箇所で「ConnectedPendingDisconnect」, 「Disconnected」といったステータスが表示されます。

最後に

Cognitoで認証したユーザーのアイデンティティを元に、amplifyを使用してIoT Coreへアクセスしてみました。 どなたかのお役に立てれば幸いです。

参考

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

https://docs.aws.amazon.com/ja_jp/iot/latest/developerguide/cognito-identities.html

https://docs.amplify.aws/lib/pubsub/getting-started/q/platform/js/