LoginSignup
4
1

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-12-19

🎄こちらは Twilio Advent Calendar 2019 20日目の記事です🔔

はじめに

Twilio Verify は電話番号を使って電話認証SMS認証ができるAPIです。
つまり 二要素認証 が簡単に実装できるAPIというわけです。

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

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

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

完成イメージ

アプリにアクセスすると、ポップアップで認証が出ます。二要素認証を突破するとアプリの画面が表示されます。

構成図

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

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

kintoneアプリの構成

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

メインアプリ

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

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

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

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

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

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

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

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

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

コード

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

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

フロントエンド側

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

※ 実装にはコマンドライン上からファイルのアップロードができるツール kintone-customize-uploader を使うと楽です!

メインアプリに適用する 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のログアウトがまさか

location.href = '/logout';

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

バックエンド側

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

▼ Twilio CLI(セットアップ編)
https://qiita.com/mobilebiz/items/456ce8b455f6aa84cc1e

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

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

環境変数

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

.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を送信する部分です。 (構成図の青部分)

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からユーザーコードと認証コードを受け取って、ユーザーコードを元に電話番号を取得して、電話番号と認証コードから認証結果を返す部分です。 (構成図の緑と赤部分)

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);
    });
};

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

おわりに

Twilioは電話をかけるだけじゃなんだな〜。
ただ、他にもTwilio Authy という二要素認証用のサービスがあるらしい・・・。
(え?じゃあ Verify はナニモノなの!?)

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

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

それでは!≧(+・` ཀ・´)≦

4
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
1