Twilio Verifyを使ってkintoneのアプリに二要素認証をつけてみた

🎄こちらは [Twilio Advent Calendar 2019](https://qiita.com/advent-calendar/2019/twilio) 20日目の記事です🔔

# はじめに

[Twilio Verify](https://jp.twilio.com/docs/verify/developer-best-practices) は電話番号を使って`電話認証`や`SMS認証`ができるAPIです。
つまり **二要素認証** が簡単に実装できるAPIというわけです。

kintone にもいくつかセキュリティ機能がありますが、残念ながら二要素認証には対応していません。

> ▼ セキュリティ - kintone
> **IPアドレス制限 / セキュアアクセス / Basic認証**
> https://kintone.cybozu.co.jp/security/

ということで、今回はこの Twilio Verify を使って kintone アプリに二要素認証をつけるカスタマイズをします。

# 完成イメージ

アプリにアクセスすると、ポップアップで認証が出ます。二要素認証を突破するとアプリの画面が表示されます。
<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">Twilio Verifyで二要素認証！ <a href="https://t.co/tGitWpJiWT">pic.twitter.com/tGitWpJiWT</a></p>&mdash; BB@サブ (@BB_File) <a href="https://twitter.com/BB_File/status/1207194760743534593?ref_src=twsrc%5Etfw">December 18, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

# 構成図

いろいろと仕組みを作っていったらそこそこ複雑になりました 汗

<img src="https://docs.google.com/drawings/d/e/2PACX-1vR6dyylQpk4pKIYACl6hes8ztfzTH6QlyYvZzKZI9A_qXArJ933JLfmwoBfZXeEpkP5IEeKDSTpcuaC/pub?w=1263&amp;h=1032">

かなりセキュリティを意識しました・・・
が、どこかにセキュリティホールがありそう・・・怖い・・・

# kintoneアプリの構成

kintoneアプリの構成は比較的自由度高めです。

## メインアプリ

なんでも良いです！お好きにどうぞ！

## ユーザー管理用マスタアプリ

以下のフィールドは配置してください。あとは自由に追加可能です。

|フィールド|フィールドコード|用途|
|---|---|---|
|ユーザー選択|user|個人の識別|
|ドロップダウン|countryNum|国番号|
|リンク|tel|電話番号|
|日時|datetime|有効期限|

<img src="https://docs.google.com/drawings/d/e/2PACX-1vS45jCx9pV_R0jF4SuxNVyLQ55kYRh2R-mKPdpGbTpSCr3_PPAr1Vw5EHZZNyn_ct8H7Zc_e5S6R_UH/pub?w=756&amp;h=679">

利用するユーザーのレコードをあらかじめ登録しておく必要があるのですが、その時に重要なのが電話番号です。
Twilioは世界中で使える電話なので、
`090-XXXX-XXXX` の場合 `+8190XXXXXXXX` としないといけません。
(つまり、国番号を考慮して、最初の０は削除しないといけません)

プログラム内で処理しても良いのですが、今回はkintoneに登録するときに０を消して登録しています。ちょっと手抜きしましたm(_ _)m

# セキュリティを高めるポイント

- **kintoneから直接Twilio Verifyを呼ぶのではなく、Functionsを利用**
    - 開発者ツールからいじっても不正ができないようにしています
- **電話番号はパラメータにのせない**
    - 都度kintoneアプリからGETする形にしています （`３`,`7`部分）
- **kintone標準のアクセス権を利用**
    - ユーザー管理用マスタアプリ側の`有効期限フィールド`と`電話番号フィールド`は管理者以外、変更不可にしてください
- **ダイヤログは`alert()/confirm()/prompt()`を利用**
    - DOMをいじって不正ができないようにしています

セキュリティを考えると処理が増えてしまいますが、そこはトレード・オフなのかなと割り切りました 笑

# コード

プログラムは大きく３種類あります。

- フロントエンド側
    - メインアプリに適用する JavaScript
- バックエンド側
    - Twilio Functions（認証コード送信用） Node.js
    - Twilio Functions（認証コード確認用） Node.js

##  フロントエンド側

基本的にすべてのshowイベントで発火するようにしています。じゃないとURL直打ちでアクセスできてしまうので。

※ 実装にはコマンドライン上からファイルのアップロードができるツール [kintone-customize-uploader](https://developer.cybozu.io/hc/ja/articles/360015889812) を使うと楽です!

### メインアプリに適用する JavaScript
```javascript:main.js
(() => {
  'use strict';

  // kintoneアプリの設定項目
  const userManage = {
    appId: XXX, // ユーザー管理アプリのアプリID
    user: 'user', // ユーザー選択フィールドのフィールドコード
    datetime: 'datetime' // 有効期限のフィールドコード
  };

  // Twilio FunctionsのURL
  const twi = {
    verifyUrl: 'https://{XXXX}.twil.io/verification',
    verifyChecfkUrl: 'https:/{XXXX}.twil.io/verificationCheck',
  };

  // BODYを消す
  const body = document.getElementsByTagName('body')[0];
  body.style.visibility = 'hidden';

  // スピナー設定（API実行中のローディング用)
  const spinner = new Spinner({
    color: '#000'
  });

  // kintoneのログイン情報取得
  const loginUser = kintone.getLoginUser();

  // ユーザー管理アプリからログインユーザーのレコードを取得する処理
  const getUserRecords = () => {
    const params = {
      app: userManage.appId,
      query: `${userManage.user} in (LOGINUSER())`
    };
    return kintone.api(kintone.api.url('/k/v1/records', true), 'GET', params);
  };

  // ログアウト処理
  const logout = () => {
    location.href = '/logout'; // これだけでログアウトができる！！
  };

  // kintoneのイベント （showシリーズ）
  const events = [
    'app.record.index.show',
    'app.record.detail.show',
    'app.record.create.show',
    'app.record.edit.show',
    'app.record.print.show',
    'app.report.show'
  ];
  kintone.events.on(events, () => {
    // ユーザー管理アプリからログインユーザーのレコードを取得する
    getUserRecords()
      .then(res => {
        if (res.records.length !== 1) {
          let message = 'エラー';
          if (res.records.length === 0) message = `${loginUser.name} のレコードが見つかりませんでした。まずユーザー管理アプリにレコード登録をしてください。`;
          else message = `${loginUser.name} のレコードが重複しています。1件になるように修正してください。`;
          throw new Error(message);
        }

        const expireTime = moment(res.records[0][userManage.datetime].value);
        const nowTime = moment();
        // 日付フィールドの値が今日より後なら認証しない
        if (expireTime.isAfter(nowTime)) {
          body.style.visibility = 'visible';
          throw new Error('キャンセル');
        }

        if (!window.confirm(`${loginUser.name}さん、認証コードを送信して良いですか？`)) {
          logout();
          throw new Error('キャンセル');
        }
        spinner.spin(body.parentNode);
        return axios.post(twi.verifyUrl, {user: loginUser.code});
      })
      .then(() => {
        const code = window.prompt('認証コードを入力してください。');
        if (code === null) {
          logout();
          throw new Error('キャンセル');
        }
        return axios.post(twi.verifyChecfkUrl, {code: code, user: loginUser.code});
      })
      .then(res => {
        if (!res.data) {
          window.alert('認証に失敗しました。');
          location.reload();
          throw new Error('キャンセル');
        }
        window.alert('認証に成功しました。');
        location.reload();
      })
      .catch(err => {
        // メッセージが「キャンセル」じゃなければエラー
        if (err.message !== 'キャンセル') {
          window.alert('エラーが発生しました');
          console.dir(err);
        }
      });
  });
})();
```

kintoneのログアウトがまさか

```javascript
location.href = '/logout';
```

だけでできるとは思わなかった！これはいいナレッジが見つかった！
（特定アプリを開いたら即ログアウトとか・・・(`ω´)ｸﾞﾌﾌ）


<blockquote class="twitter-tweet"><p lang="und" dir="ltr"><a href="https://t.co/xRl8p2wrfa">pic.twitter.com/xRl8p2wrfa</a></p>&mdash; BB@サブ (@BB_File) <a href="https://twitter.com/BB_File/status/1207261526710616064?ref_src=twsrc%5Etfw">December 18, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>


##  バックエンド側

まずコマンドラインで`twilio`コマンドを使えるようにしてください。こちらの記事が参考になります。

> ▼ Twilio CLI（セットアップ編）
> https://qiita.com/mobilebiz/items/456ce8b455f6aa84cc1e

そしたら`serverless用プラグイン`も追加してTwilio Functionsを作りましょう。

> ▼ Twilio CLI（サーバーレス開発編）
> https://qiita.com/mobilebiz/items/fb4439bf162098e345ae


### 環境変数

上記の記事よりサンプルプロジェクトを作った場合、.envファイルが自動で生成されます。それが環境変数用のファイルです。
ここにTwilioの認証情報やkintoneの情報を書いておきます。

```bash:.env
ACCOUNT_SID=SKXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
AUTH_TOKEN=CXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
VERIFY_SERVICE_SID=VAXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
TWILIO_ACCOUNT_SID=ACXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

KINTONE_DOMAIN=XXXX.cybozu.com
KINTONE_APITOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
KINTONE_APPID=XXX
KINTONE_FIELDCODE_TEL=tel
KINTONE_FIELDCODE_COUNTRY_NUMBER=countryNum
KINTONE_FIELDCODE_USER=user
KINTONE_FIELDCODE_DATETIME=datetime
```

### Twilio Functions（認証コード送信用） Node.js

kintoneからユーザーコードを受け取って、そのユーザーコードを元に電話番号を取得して、SMSを送信する部分です。 （構成図の青部分）

```javascript:verification.js
const kintone = require('@kintone/kintone-js-sdk');
const twilio = require('twilio');

// CORS
const response = new Twilio.Response();
const headers = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': '*',
  'Access-Control-Allow-Headers': '*'
};
response.setHeaders(headers);

exports.handler = function(context, event, callback) {
  // kintone js sdk のコネクション
  const kintoneAuth = (new kintone.Auth()).setApiToken({apiToken: context.KINTONE_APITOKEN});
  const connection = new kintone.Connection({domain: context.KINTONE_DOMAIN, auth: kintoneAuth});
  const kintoneRecord = new kintone.Record({connection});

  const app = context.KINTONE_APPID;
  const query = `${context.KINTONE_FIELDCODE_USER} in ("${event.user}")`;

  kintoneRecord.getRecords({app, query})
    .then(resp => {
      const telNum = resp.records[0][context.KINTONE_FIELDCODE_TEL].value;
      const CNum = resp.records[0][context.KINTONE_FIELDCODE_COUNTRY_NUMBER].value;

      const client = twilio(context.ACCOUNT_SID, context.AUTH_TOKEN, {accountSid: context.TWILIO_ACCOUNT_SID});
      return client.verify.services(context.VERIFY_SERVICE_SID)
        .verifications
        .create({to: `${CNum}${telNum}`, channel: 'sms'});
    })
    .then(() => {
      callback(null, response);
    })
    .catch(err => {
      console.dir(err);
      callback(null, err);
    });
};
```

### Twilio Functions（認証コード確認用） Node.js

kintoneからユーザーコードと認証コードを受け取って、ユーザーコードを元に電話番号を取得して、電話番号と認証コードから認証結果を返す部分です。 （構成図の緑と赤部分）

```javascript:verificationCheck.js
const kintone = require('@kintone/kintone-js-sdk');
const twilio = require('twilio');
const moment = require('moment');

const response = new Twilio.Response();
// Build list of headers
const headers = {
  'Access-Control-Allow-Origin': '*',
  'Access-Control-Allow-Methods': '*',
  'Access-Control-Allow-Headers': '*'
};
// Set headers in response
response.setHeaders(headers);
response.setBody(true);

exports.handler = function(context, event, callback) {
  // kintone js sdk のコネクション
  const kintoneAuth = (new kintone.Auth()).setApiToken({apiToken: context.KINTONE_APITOKEN});
  const connection = new kintone.Connection({domain: context.KINTONE_DOMAIN, auth: kintoneAuth});
  const kintoneRecord = new kintone.Record({connection});

  const app = context.KINTONE_APPID;
  const authCode = event.code;
  const user = event.user;
  let recordId;

  kintoneRecord.getRecords({app, query: `${context.KINTONE_FIELDCODE_USER} in ("${user}")`})
    .then(resp => {
      const telNum = resp.records[0][context.KINTONE_FIELDCODE_TEL].value;
      const CNum = resp.records[0][context.KINTONE_FIELDCODE_COUNTRY_NUMBER].value;
      recordId = resp.records[0].$id.value;

      const client = twilio(context.ACCOUNT_SID, context.AUTH_TOKEN, {accountSid: context.TWILIO_ACCOUNT_SID});
      return client.verify.services(context.VERIFY_SERVICE_SID)
        .verificationChecks
        .create({to: `${CNum}${telNum}`, code: authCode});
    })
    .then((resp) => {
      if (!resp.valid) throw new Error('コードが違います。');

      const params = {
        app: app,
        id: recordId,
        record: {
          [context.KINTONE_FIELDCODE_DATETIME]: {
            value: moment().add(1, 'days').format('YYYY-MM-DDTHH:mm:ssZ')
          }
        }
      };
      return kintoneRecord.updateRecordByID(params);
    })
    .then(() => {
      response.setBody('true');
      callback(null, response);
    })
    .catch(err => {
      console.dir(err);
      response.setBody('false');
      callback(null, response);
    });
};
```

今回は`認証の有効期限を１日` として設定して、認証に成功すると
`moment().add(1, 'days').format('YYYY-MM-DDTHH:mm:ssZ')` という値でユーザー管理用マスタアプリの有効期限フィールドを更新します。
なので、次の日までは二要素認証をせずにアプリにアクセスができます！
（何かあればアプリ管理者がフィールドの値を変えてしまえばOK!）

# おわりに

Twilioは電話をかけるだけじゃなんだな〜。
ただ、他にも[Twilio Authy](https://twilio.kddi-web.com/function/authy/) という二要素認証用のサービスがあるらしい・・・。
（え？じゃあ Verify はナニモノなの！？）

> ・Twilio Verify
> **Phone and Email Verification**
> https://jp.twilio.com/docs/verify/authy-vs-verify

うーん、SMS認証とかEメール認証ってつまり二要素認証ってことではないのか・・・よくわからん！

それでは！≧(+･` ཀ･´)≦