ちょっとだけセキュアな認証システムをLINE Thingsでやってみた

LINE Thingsの自動通信機能を応用して、電波が届くエリアに入ったら認証コードを送信して
LINEのLIFFを使って認証している端末に認証コードを送信するシステムを作ってみました。

認証コードはAmazon Connectを使って電話がかかってくるパターンとLINE Notifyを使って通知する2パターンを試しました。

# 動作デモ
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">LINE Thingsの自動通信を応用した認証コードシステムのデモです。Amazon Connectで電話がかかってきて、認証コードを教えてくれます。LIFFアプリで認証コードをLINE Things経由でM5Stackに渡しているデモです。<a href="https://twitter.com/hashtag/linethings?src=hash&amp;ref_src=twsrc%5Etfw">#linethings</a> <a href="https://twitter.com/hashtag/linedc?src=hash&amp;ref_src=twsrc%5Etfw">#linedc</a> <a href="https://twitter.com/hashtag/AmazonConnect?src=hash&amp;ref_src=twsrc%5Etfw">#AmazonConnect</a> <a href="https://twitter.com/hashtag/M5Stack?src=hash&amp;ref_src=twsrc%5Etfw">#M5Stack</a> <a href="https://t.co/zq0ZBmt5LK">pic.twitter.com/zq0ZBmt5LK</a></p>&mdash; がおまる@スマートスピーカーアプリ開発入門発売中！ (@gaomar) <a href="https://twitter.com/gaomar/status/1132830442598481920?ref_src=twsrc%5Etfw">May 27, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# アーキテクチャ
LINE Thingsの自動通信でWebhookのURLへ飛ばして、AWS LambdaからAmazon ConnectとLINE Notifyの処理を実行しています。

<img width="960" alt="s100.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154355/54cf7e97-4f9f-7f6a-3f7c-d94b9a61a3c1.png">

# 一連の流れ

## 1. M5Stackのプログラム
特に難しいことはしていません。LINE Things連携済みの携帯がIoTデバイスに近づいたら認証コードを作成して端末に送信しています。

```c:M5Stack.ino
#include <M5Stack.h>
#include <BLEDevice.h>
#include <BLEServer.h>
#include <BLEUtils.h>
#include <BLE2902.h>

// Device Name: Maximum 30 bytes
#define DEVICE_NAME "LINE Things Meetup Demo"

// あなたのサービスUUIDを貼り付けてください
#define USER_SERVICE_UUID "＜あなたのサービスUUID＞"
// Notify UUID: トライアル版は値が固定される
#define NOTIFY_CHARACTERISTIC_UUID "62FBD229-6EDD-4D1A-B554-5C4E1BB29169"
// PSDI Service UUID: トライアル版は値が固定される
#define PSDI_SERVICE_UUID "E625601E-9E55-4597-A598-76018A0D293D"
// LIFFからのデータ UUID: トライアル版は値が固定される
#define WRITE_CHARACTERISTIC_UUID "E9062E71-9E62-4BC6-B0D3-35CDCD9B027B"
// PSDI CHARACTERISTIC UUID: トライアル版は値が固定される
#define PSDI_CHARACTERISTIC_UUID "26E2B12B-85F0-4F3F-9FDD-91D114270E6E"

BLEServer* thingsServer;
BLESecurity* thingsSecurity;
BLEService* userService;
BLEService* psdiService;
BLECharacteristic* psdiCharacteristic;
BLECharacteristic* notifyCharacteristic;
BLECharacteristic* writeCharacteristic;

bool deviceConnected = false;
bool oldDeviceConnected = false;
bool sendFlg = false;
bool unlockFlg = false;

// 認証コード
int randNumber;

class serverCallbacks: public BLEServerCallbacks {

  // デバイス接続
  void onConnect(BLEServer* pServer) {
    deviceConnected = true;

    // 一度認証されないとコードは生成しない
    if (unlockFlg) {
      sendFlg = false;
      unlockFlg = false;
    }
  };

  // デバイス未接続
  void onDisconnect(BLEServer* pServer) {
    deviceConnected = false;
    
    if (unlockFlg) {
      sendFlg = false;
      unlockFlg = false;
    }
  }
};

// LIFFから送信されるデータ
class writeCallback: public BLECharacteristicCallbacks {
  void onWrite(BLECharacteristic *bleWriteCharacteristic) {
    // LIFFから来るデータを取得
    std::string value = bleWriteCharacteristic->getValue();
    int myNum = atoi(value.c_str());

    // 認証コードと一致しているか確認
    if (randNumber == myNum) {
      M5.Lcd.fillScreen(GREEN);
      M5.Lcd.setCursor(0, 30);
      M5.Lcd.print("Unlock!");
      unlockFlg = true;    
    }    
  }
};

void setup() {
  Serial.begin(115200);
  BLEDevice::init("");
  BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT_NO_MITM);

  // Security Settings
  BLESecurity *thingsSecurity = new BLESecurity();
  thingsSecurity->setAuthenticationMode(ESP_LE_AUTH_BOND);
  thingsSecurity->setCapability(ESP_IO_CAP_NONE);
  thingsSecurity->setInitEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK);

  setupServices();
  startAdvertising();
  
  // put your setup code here, to run once:
  M5.begin();  
  M5.Lcd.fillScreen(BLACK);
  M5.Lcd.setTextSize(2);

}

void loop() {
  if (!deviceConnected && oldDeviceConnected) {
    delay(500); // Wait for BLE Stack to be ready
    thingsServer->startAdvertising(); // Restart advertising
    oldDeviceConnected = deviceConnected;
    Serial.println("Restart!");
  }

  // Connection
  if (deviceConnected && !oldDeviceConnected) {
    oldDeviceConnected = deviceConnected;
  }

  if (!sendFlg && deviceConnected){
    sendFlg = true;
    delay(5000);

    // 認証コード生成
    randNumber = random(1000, 10000);
    char newValue[16];
    sprintf(newValue, "%d", randNumber);

    // 認証コードをWebhookURLに送信
    notifyCharacteristic->setValue(newValue);
    notifyCharacteristic->notify();
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 30);
    M5.Lcd.print("Send!!");
    delay(5000);
    M5.Lcd.fillScreen(BLACK);
    M5.Lcd.setCursor(0, 30);
    M5.Lcd.print("Connected");  /*表示クリア*/
        
    delay(50);
    
  } else if (!deviceConnected) {
    M5.Lcd.setCursor(0, 30);
    M5.Lcd.print("Not Connected");

  }

  M5.update();
  
}

// サービス初期化
void setupServices(void) {
  // Create BLE Server
  thingsServer = BLEDevice::createServer();
  thingsServer->setCallbacks(new serverCallbacks());

  // Setup User Service
  userService = thingsServer->createService(USER_SERVICE_UUID);

  // LIFFからのデータ受け取り設定
  writeCharacteristic = userService->createCharacteristic(WRITE_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_WRITE);
  writeCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED | ESP_GATT_PERM_WRITE_ENCRYPTED);
  writeCharacteristic->setCallbacks(new writeCallback());
  
  // Notifyセットアップ
  notifyCharacteristic = userService->createCharacteristic(NOTIFY_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_NOTIFY);
  notifyCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED | ESP_GATT_PERM_WRITE_ENCRYPTED);
  BLE2902* ble9202 = new BLE2902();
  ble9202->setNotifications(true);
  ble9202->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED | ESP_GATT_PERM_WRITE_ENCRYPTED);
  notifyCharacteristic->addDescriptor(ble9202);

  // Setup PSDI Service
  psdiService = thingsServer->createService(PSDI_SERVICE_UUID);
  psdiCharacteristic = psdiService->createCharacteristic(PSDI_CHARACTERISTIC_UUID, BLECharacteristic::PROPERTY_READ);
  psdiCharacteristic->setAccessPermissions(ESP_GATT_PERM_READ_ENCRYPTED | ESP_GATT_PERM_WRITE_ENCRYPTED);

  // Set PSDI (Product Specific Device ID) value
  uint64_t macAddress = ESP.getEfuseMac();
  psdiCharacteristic->setValue((uint8_t*) &macAddress, sizeof(macAddress));

  // Start BLE Services
  userService->start();
  psdiService->start();
}

void startAdvertising(void) {
  // Start Advertising
  BLEAdvertisementData scanResponseData = BLEAdvertisementData();
  scanResponseData.setFlags(0x06); // GENERAL_DISC_MODE 0x02 | BR_EDR_NOT_SUPPORTED 0x04
  scanResponseData.setName(DEVICE_NAME);

  thingsServer->getAdvertising()->addServiceUUID(userService->getUUID());
  thingsServer->getAdvertising()->setScanResponseData(scanResponseData);
  thingsServer->getAdvertising()->start();
}
```

## 2. Lambdaの処理
Amazon Connectで電話をかける処理とLINE Notifyへ送信する処理を実装しています。

```javascript:index.js
const Util = require('util.js');

exports.handler = async (event) => {
    
    const body = JSON.parse(event.body);
    const thingsData = body.events[0].things.result;

    if (thingsData.bleNotificationPayload) {
        // LINE Thingsから飛んでくるデータを取得
        const blePayload = thingsData.bleNotificationPayload;
        var buffer1 = new Buffer(blePayload, 'base64');
        var m5Data = buffer1.toString('ascii');  //Base64をデコード
        
        const sendMessage = `認証コードは「${m5Data}」です。`;
        
        // Amazon Connect送信
        await Util.callMessageAction(m5Data);
        
        // LINE Notify送信
        await Util.postNotify(sendMessage);

    }  
};
```

```javascript:util.js
'use strict';
const request = require('request');
const AWS = require('aws-sdk');
var connect = new AWS.Connect();

// あなたのトークンを設定してください
const headers = {
  Authorization: `Bearer ${process.env.NOTIFY_TOKEN}`,
  'Content-Type': 'application/x-www-form-urlencoded'
};

// 電話をかける処理
module.exports.callMessageAction = async function callMessageAction(message) {
    return new Promise(((resolve, reject) => {
    
        // 1桁ずつ読ませるために分けている
        const no1 = message.substr(0, 1);
        const no2 = message.substr(1, 1);
        const no3 = message.substr(2, 1);
        const no4 = message.substr(3, 1);
        
        // Attributesに発話する内容を設定
        var params = {
            Attributes: {"message": `<speak><break time='2s' />認証コードは、<prosody rate="65%"><say-as interpret-as="digits">${no1}</say-as>、<say-as interpret-as="digits">${no2}</say-as>、<say-as interpret-as="digits">${no3}</say-as>、<say-as interpret-as="digits">${no4}</say-as></prosody>です。</speak>`},
            InstanceId: process.env.INSTANCEID,
            ContactFlowId: process.env.CONTACTFLOWID,
            DestinationPhoneNumber: process.env.PHONENUMBER,
            SourcePhoneNumber: process.env.SOURCEPHONENUMBER
        };
    
        // 電話をかける
        connect.startOutboundVoiceContact(params, function(err, data) {
            if (err) {
                console.log(err);
                reject();
            } else {
                resolve(data);
            }
        });
        
    }));
};

// LINE Notifyへ送信
module.exports.postNotify = async function postNotify(postMessage) {
    // オプションを定義
    const jsonData =
    {
      'message': postMessage
    };

    const options = {
      url: 'https://notify-api.line.me/api/notify',
      method: 'POST',
      headers: headers,
      form: jsonData
    };

    return new Promise(function (resolve, reject) {
      request(options, function (error, response, body) {
        if (!error) {
          console.log(body.name);
          resolve(true);
        } else {
          console.log('error: ' + response + body);
          resolve(true);
        }
      });
    });
};
```

## 3. Amazon Connect側の設定
Attributesのmessageの内容をAmazon Connectの問い合わせフローで発話するように設定しています。
全体のフローは以下の通り。`問い合わせ属性の設定`部分でmessageの内容を取得します。

聞き逃しがないように認証コードは2回ループさせています。

詳細は下記サイトをご確認ください。
https://dev.classmethod.jp/cloud/aws/ask-linebot-to-call-from-amazon-connect-api/

<img width="1121" alt="s101.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154355/308c67ac-e470-efb2-24ae-16d518a8e02d.png">

問い合わせ属性の設定は以下の通り。宛先キーと属性に`message`を設定しています。

<img width="470" alt="s102.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154355/37239551-8da9-46c0-e3ff-44920045649e.png">

## 4. LINE Notifyの設定
こちらからトークンを発行します。
https://notify-bot.line.me/my/

<img width="931" alt="s103.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154355/e545977e-a5b4-38a3-cf13-ce3ab93bec48.png">

うまく連携できればLINEに通知されます。
<img width="513" alt="s104.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154355/54384475-1f27-fd6e-0f43-d678fd069954.png">

## 5. LIFFアプリの設定
LIFF側は今回Vue.jsでやってみました。
プログラムはのサンプルはこちらで公開しています。

https://github.com/gaomar/linethings-meetup-demo

index.htmlでLIFFのSDKを設定しています。UIライブラリはVuetifyを使っています。

```html:public/index.html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width,initial-scale=1.0">
    <link rel="icon" href="<%= BASE_URL %>favicon.ico">
    <script src="https://d.line-scdn.net/liff/1.0/sdk.js"></script>
    <title>linethings-meetup-demo</title>
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900">
    <link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Material+Icons">
  </head>
  <body>
    <noscript>
      <strong>We're sorry but linethings-meetup-demo doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div id="app"></div>
    <!-- built files will be auto injected -->
  </body>
</html>
```

実際の画面はこちら

```vuejs:src/components/Helloworld.vue
<template>
  <v-app id="inspire">
    <v-container>
      <v-layout
        text-xs-center
        wrap
      >
        <v-flex mb-4>
          <h1 class="display-2 font-weight-bold mb-3">
            認証画面
          </h1>
        </v-flex>

        <v-flex xs12>
          <v-form>
            <v-container>
              <v-layout row wrap>
                <v-text-field
                  v-model="code"
                  @keyup.enter="say"
                  @keypress="setCanSubmit"
                  outline
                  label="認証コードを入力してください"
                ></v-text-field>
              </v-layout>
            </v-container>
          </v-form>  
        </v-flex>

        <v-flex xs12>
          {{this.bleStatus}}
        </v-flex>
      </v-layout>
    </v-container>
  </v-app>
</template>

<script>
  export default {
    data () {
      return {
        USER_SERVICE_UUID: '＜あなたのサービスUUID＞',
        LED_CHARACTERISTIC_UUID: 'E9062E71-9E62-4BC6-B0D3-35CDCD9B027B', /* トライアルは固定値 */
        bleConnect: false,
        canSubmit: false,
        bleStatus: '',
        state: false,
        characteristic: '',
        code: '',
        user: {
          image: '',
          userId: ''
        }        
      }
    },
    methods: {
      setCanSubmit () {
        this.canSubmit = true
      },
      say () {
        this.sendData()
      },
      sendData () {
        this.bleStatus = `送信:${this.code}`

        let ch_array = this.code.split("");
        for(let i = 0; i < 16; i = i + 1){
          ch_array[i] = (new TextEncoder('ascii')).encode(ch_array[i]);
        }

        this.characteristic.writeValue(new Uint8Array(ch_array)
        ).catch(error => {
          this.bleStatus = error.message
        })
      },
      // BLEが接続できる状態になるまでリトライ
      liffCheckAvailablityAndDo: async function (callbackIfAvailable) {
        try {
          const isAvailable = await liff.bluetooth.getAvailability();
          if (isAvailable) {
            callbackIfAvailable()
          } else {
            // リトライ
            this.bleStatus = `Bluetoothをオンにしてください。`
            setTimeout(() => this.liffCheckAvailablityAndDo(callbackIfAvailable), 10000)
          }
        } catch (error) {
          alert('Bluetoothをオンにしてください。')
        }
      },
      // サービスとキャラクタリスティックにアクセス
      liffRequestDevice: async function () {
        const device = await liff.bluetooth.requestDevice()
        await device.gatt.connect()
        const service = await device.gatt.getPrimaryService(this.USER_SERVICE_UUID)
        service.getCharacteristic(this.LED_CHARACTERISTIC_UUID).then(characteristic => {
          this.characteristic = characteristic
          this.bleConnect = true
          this.bleStatus = `デバイスに接続しました！`
        }).catch(error => {
          this.bleConnect = true
          this.bleStatus = `デバイス接続に失敗=${error.message}`
        })
      },
      initializeLiff: async function(){
        await liff.initPlugins(['bluetooth']);
        this.liffCheckAvailablityAndDo(() => this.liffRequestDevice())

        // プロフゲット
        const profile = await liff.getProfile()
        this.user.image = profile.pictureUrl
        this.user.userId = profile.userId
      }
    },
    mounted: function () {
      liff.init(
          () => this.initializeLiff()
      )
    }
  }
</script>
```

# まとめ
認証コードはM5Stackで保持しているので、ちょっとだけセキュアです。
一度認証させると次回接続時は別のIDが生成されるようにもしています。一種のワンタイムパスワードみたいなものですね。
LINE Thingsを使えば、様々な連携をすることができます。是非試してみてください！

システム化のご検討やご相談は弊社までお問い合わせください。
https://i-enter.co.jp/contact/