技術書典での爆死具合をリアルタイムで共有できるサービスを作った

# はじめに

六木本未来ラボとして技術書典6に初出典するのですが、
当日はメンバーがローテーションを組んで販売ブースに立つことになりました。

この六木本未来ラボ、初出典にもかかわらず**300冊**も刷ってしまいドキドキなため販売ブースにいない間も、
メンバー間で本の売れ具合を共有したいということで
先日、メンバーの一人@wpuzzle_minegishiさんが「売れたら押してね」とIoTボタンを押したらSlackに通知するシステムを作りました。

その後、「どうせなら自分たちの爆死具合を公表したいよね」からの、「誰でも使えるようにしたほうが楽しいよね？」
ということで、突貫作業でWEBサービスを作っちゃいました。

サービス名は「URETAYO（仮）」で、現在アルファ版として公開中していますので
ご自由にお使いください！（技術書典終了後、どこかのタイミングで閉じると思います）

サービスURL
https://dm1ba20lvc4ut.cloudfront.net/

このサービスのソースコードは以下のリポジトリで公開しています。
https://github.com/takaaki-s/uretayo

また、お試し用アカウントも作成しましたので、
こちらもご自由にお試しください。

ID: guest
パスワード： guestguest

# 利用イメージ

![uretayo-irasutoya.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262735/a786d703-2ea9-636a-413e-cb8c18f71bf5.png)

# 動作イメージ

![uretayo-douga2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262735/23c9ed7a-abf3-a87a-dee9-10c16e002387.gif)

# 利用技術

AWSフルマネージドのサーバーレス構成にしています。
また、実運用でも耐えうるようなセキュリティレベルを目指してみました。

**AWSリソース**

ServerlessFrameworkでプロビジョニングします。便利。

* Cognito
* SQS
* DynamoDB
* Lambda
* IoT Core
* S3
* KinesisFirehose
* Athena
* CloudFront

**フロントエンド側**

* AWS Amplify Framework
* React
* Typescript
* Semantic UI React

# 構成図

![uretayo-archtecture.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262735/cafa4eb5-6135-0d21-af2f-6b12d5948b71.png)


# 処理フロー

## ユーザー認証

はじめに閲覧者がURETAYOサイトにアクセスすると、
Cognito IdPoolから未認証ユーザー用のIAMロール（一時トークン）が払い出されます。
また、Cognito UserPoolへログインすると、IdPoolから今度は認証ユーザー用のIAMロールが付与されます。
AmplifyFrameworkではCognitoの認証状態に応じて閲覧できるページを制御しています。

**セキュリティポイント**

```
AmplifyFrameworkはフロント側（ブラウザ）で実行されるJavascriptなので、簡単に改変することが可能です。
閲覧ページの制限ももちろん解除可能ですが、AWSのIAMロールでAWSリソースの権限管理をしているので
Javascript側を改変してもAWSのリソースを変更することはできないようになっています。
また、一時トークンを利用されたとしても影響範囲はユーザーの権限範囲にのみ限定されます。
```

**ユーザーの権限**
上記の一時トークンの不正利用時の影響範囲は、ユーザーの権限範囲に限定されるのですが
逆に言えば、ユーザーに大きな権限を与えているとそれだけ影響範囲が広くなるので、なるべく権限を絞ります。
このサービスでは認証ユーザーに以下のような権限を付与しました。

* S3への書き込み権限（ユーザーIDプレフィックス付きのディレクトリ以下のみ）
* SQSヘのメッセージ送信権限
* IoTポリシーのアタッチ

**Cognitoについて**
Cognitoには２つの役割があり、
一つは、ユーザーを識別して一意なIDの付与と、認証状態に応じて一時的なIAMロールを付与するIDプール、
もう一つはユーザーの認証を行うユーザープールです。
ユーザープールは認証プロバイダの一つでもあります。

IDプールは認証プロバイダと連携して認証状態を判断します。
認証プロバイダはGoogleやFacebook等の外部の認証プロバイダを利用することも可能です。


## 売れた通知の仕組み

SQSへメッセージを送信すると裏でLambdaが動き以下の処理が行われます。

* Jwtの検証
* DynamoDBのカウントアップ
* Firehoseへのログ送信
* MQTTへのパブリッシュ


認証ユーザーにはSQSへのメッセージ送信権限が付与されているので、
売れたボタンをトリガーにして、SQSへ下記のJSON形式でメッセージを送信します。

```json
{
  "jwt": "{CognitoのアクセスIDトークン}",
  "book_id": "本のID"
}
```

送信されたメッセージはSQSの裏でLambdaが受信して、DynamoDBテーブルの該当の本の売れた数をカウントアップし
ログをFirehoseに送信して、MQTTのTopicに下記のJSON形式でPublishします。

```json
{
  "count": "{売れた数}",
  "book_id": "本のID",
  "sub": "ユーザーの一意ID"
}
```

**セキュリティポイント**

```
認証ユーザーであればSQSへ自由にメッセージが送信可能で、一見セキュリティリスクになりそうですが、
メッセージに含まれたjwtの検証を行うことで、送信したユーザーの特定を行い
その特定されたユーザーが持つ本IDのカウントアップを行うようにしています。
例え第三者がjwtを手に入れ、でたらめなメッセージを送信したとしても、最悪で影響範囲はそのユーザーのみに限定されます。
```

## 売れた通知の受信と画面への反映

サークル一覧画面や頒布物画面では、
AmplifyFrameworkのPubSubでMQTTのトピックをサブスクライブしていて、
受信したデータを元にリアルタイムに画面に反映しています。

### はまりどころ

AmplifyのPubSubですが[Amplifyのドキュメント](https://aws-amplify.github.io/docs/js/pubsub
)通りにしても
下記のエラーが出てうまくサブスクライブできませんでした。

```
{invocationContext: undefined, errorCode: 8, errorMessage: "AMQJS0008I Socket closed."}
```

これは、Amplifyのドキュメントで指定しているIoTポリシーでは権限が足りないのと、
そのIoTポリシーにユーザーをアタッチしていないためでした。
ですので、IoTポリシーを下記のように定義して

```yaml
    IoTPolicy:
      Type: AWS::IoT::Policy
      Properties:
        PolicyDocument:
          Version: "2012-10-17"
          Statement:
            - Effect: Allow
              Action:
                - iot:Receive
                - iot:Connect
                - iot:Subscribe
              Resource:
                - "arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topic/{トピック名}/*"
                - "arn:aws:iot:${AWS::Region}:${AWS::AccountId}:topicfilter/{トピック名}/*"
                - "arn:aws:iot:${AWS::Region}:${AWS::AccountId}:client/*"

```


IoTポリシーへのアタッチと、トピックのサブスクライブは
こんな感じで処理してうまく動くようになりました。

```typescript
import { Iot } from "aws-sdk";
import { Auth, PubSub } from "aws-amplify";
import Config from "../config";

const iotSubscribe = async (topic: string, cb: any) => {
  const credentials = await Auth.currentCredentials();

  const iot = new Iot({
    region: Config.region,
    credentials: Auth.essentialCredentials(credentials)
  });
  const policyName = Config.iotPolicyName;
  const target = credentials.identityId;

  const { policies } = await iot.listAttachedPolicies({ target }).promise();
  if (policies && !policies.find(policy => policy.policyName === policyName)) {
    await iot.attachPolicy({ policyName, target }).promise();
  }

  return PubSub.subscribe(topic, {}).subscribe({
    next: cb,
    error: error => console.error(error),
    complete: () => console.log("Done")
  });
};

export default iotSubscribe;
```




# さいごに

これを作ろうとなったのが1週間前で、大丈夫だろうとタカをくくっていたのですが
完全に見積もりが甘すぎて、もうこんなギリギリに。。。
Firehoseのログをサークル詳細画面にグラフ表示をしようとか、
いろいろ考えたりしたのですが、全然間に合いませんでした！

ぱっと見わかりにくさ満点のサービスですが、実際に触って確かめてみていただけると嬉しいです。
それから、バグを発見したらそっと教えてください。

われわれ六木本未来ラボは「え２８」で技術書典初参加させていただきます。
はじめての技術書典でドキドキしていますが、
参加サークルのみなさま、よろしくお願いします。