🎄こちらは Twilio Advent Calendar 2019 20日目の記事です🔔
はじめに
Twilio Verify は電話番号を使って電話認証
やSMS認証
ができるAPIです。
つまり 二要素認証 が簡単に実装できるAPIというわけです。
kintone にもいくつかセキュリティ機能がありますが、残念ながら二要素認証には対応していません。
▼ セキュリティ - kintone
IPアドレス制限 / セキュアアクセス / Basic認証
https://kintone.cybozu.co.jp/security/
ということで、今回はこの Twilio Verify を使って kintone アプリに二要素認証をつけるカスタマイズをします。
完成イメージ
アプリにアクセスすると、ポップアップで認証が出ます。二要素認証を突破するとアプリの画面が表示されます。
Twilio Verifyで二要素認証! pic.twitter.com/tGitWpJiWT
— BB@サブ (@BB_File) December 18, 2019
構成図
いろいろと仕組みを作っていったらそこそこ複雑になりました 汗
かなりセキュリティを意識しました・・・
が、どこかにセキュリティホールがありそう・・・怖い・・・
kintoneアプリの構成
kintoneアプリの構成は比較的自由度高めです。
メインアプリ
なんでも良いです!お好きにどうぞ!
ユーザー管理用マスタアプリ
以下のフィールドは配置してください。あとは自由に追加可能です。
フィールド | フィールドコード | 用途 |
---|---|---|
ユーザー選択 | user | 個人の識別 |
ドロップダウン | countryNum | 国番号 |
リンク | tel | 電話番号 |
日時 | datetime | 有効期限 |
利用するユーザーのレコードをあらかじめ登録しておく必要があるのですが、その時に重要なのが電話番号です。
Twilioは世界中で使える電話なので、
090-XXXX-XXXX
の場合 +8190XXXXXXXX
としないといけません。
(つまり、国番号を考慮して、最初の0は削除しないといけません)
プログラム内で処理しても良いのですが、今回はkintoneに登録するときに0を消して登録しています。ちょっと手抜きしましたm(_ _)m
セキュリティを高めるポイント
-
kintoneから直接Twilio Verifyを呼ぶのではなく、Functionsを利用
- 開発者ツールからいじっても不正ができないようにしています
-
電話番号はパラメータにのせない
- 都度kintoneアプリからGETする形にしています (
3
,7
部分)
- 都度kintoneアプリからGETする形にしています (
-
kintone標準のアクセス権を利用
- ユーザー管理用マスタアプリ側の
有効期限フィールド
と電話番号フィールド
は管理者以外、変更不可にしてください
- ユーザー管理用マスタアプリ側の
-
ダイヤログは
alert()/confirm()/prompt()
を利用- DOMをいじって不正ができないようにしています
セキュリティを考えると処理が増えてしまいますが、そこはトレード・オフなのかなと割り切りました 笑
コード
プログラムは大きく3種類あります。
- フロントエンド側
- メインアプリに適用する JavaScript
- バックエンド側
- Twilio Functions(認証コード送信用) Node.js
- Twilio Functions(認証コード確認用) Node.js
フロントエンド側
基本的にすべてのshowイベントで発火するようにしています。じゃないとURL直打ちでアクセスできてしまうので。
※ 実装にはコマンドライン上からファイルのアップロードができるツール kintone-customize-uploader を使うと楽です!
メインアプリに適用する JavaScript
(() => {
'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';
だけでできるとは思わなかった!これはいいナレッジが見つかった!
(特定アプリを開いたら即ログアウトとか・・・(`ω´)グフフ)
— BB@サブ (@BB_File) December 18, 2019
バックエンド側
まずコマンドラインでtwilio
コマンドを使えるようにしてください。こちらの記事が参考になります。
▼ Twilio CLI(セットアップ編)
https://qiita.com/mobilebiz/items/456ce8b455f6aa84cc1e
そしたらserverless用プラグイン
も追加してTwilio Functionsを作りましょう。
▼ Twilio CLI(サーバーレス開発編)
https://qiita.com/mobilebiz/items/fb4439bf162098e345ae
環境変数
上記の記事よりサンプルプロジェクトを作った場合、.envファイルが自動で生成されます。それが環境変数用のファイルです。
ここにTwilioの認証情報やkintoneの情報を書いておきます。
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を送信する部分です。 (構成図の青部分)
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からユーザーコードと認証コードを受け取って、ユーザーコードを元に電話番号を取得して、電話番号と認証コードから認証結果を返す部分です。 (構成図の緑と赤部分)
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メール認証ってつまり二要素認証ってことではないのか・・・よくわからん!
それでは!≧(+・` ཀ・´)≦