前回のTOTP認証に続き、ついでにIVR認証をこの機会に実装してみた。
IVR認証とは、本人の電話番号に直接架電することで、認証ユーザーとしての正当性を担保する認証機能である。
今回は、二段階認証で用いられる一時的な認証コードを自動音声により通知し、その場で電話番号による認証コード入力による認証機能を実装した。
今回使用したTwilioは、自動音声機能はもちろん、SMS通知、チャット、等様々なコミュニケーション用APIを兼ね備えているサービスである。
また、WebhookURLで指定するURLには、当然Twilioからアクセス可能なサーバの宛先が必要なため、ngrokを使用することでAPIを外部公開させた。
※ ngrokについては、以下を参照。
https://qiita.com/hirokisoccer/items/7033c1bb9c85bf6789bd
簡単にいうと、ローカルサーバーを外部公開することが可能なツールである。
①Twilioアカウント発行
https://www.twilio.com/ja/
②アカウント発行すると、「ACCOUNT SID」/「AUTH TOKEN」が自動で発行される。追加でTwilioから電話番号を購入(※今回は無料トライアル枠を使用)
③WebhookURLに電話応答後のリダイレクト先URLを指定
④自動音声通知/認証コード認証API
package com.example.demo.ivr;
import static spark.Spark.get;
import static spark.Spark.post;
import java.net.URI;
import java.security.SecureRandom;
import com.twilio.http.HttpMethod;
import com.twilio.http.TwilioRestClient;
import com.twilio.rest.api.v2010.account.Call;
import com.twilio.rest.api.v2010.account.CallCreator;
import com.twilio.twiml.VoiceResponse;
import com.twilio.twiml.voice.Gather;
import com.twilio.twiml.voice.Say;
import com.twilio.twiml.voice.Say.Language;
import com.twilio.type.PhoneNumber;
public class TwilioOtpAuth {
private static final String ACCOUNT_SID = "AC8xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
private static final String AUTH_TOKEN = "c24xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
private static final String TWILIO_NUMBER = "+19xxxxxxxx";
private static final String NGROK_BASE_URL = "http://xxxxxxxxx.ngrok.io";
private static final int OTP_LENGTH = 6;
private static TwilioRestClient client = new TwilioRestClient.Builder(ACCOUNT_SID, AUTH_TOKEN).build();
public static void main(String[] args) {
/**
* ワンタイムパスコード認証
*/
post("/otp/check", (req, res) -> {
String digits = req.queryParams("Digits");
IvrOtpDAO ivrOtpDAO = new IvrOtpDAO();
String otp = ivrOtpDAO.getOtp("1a2b3c");
if (otp != null) {
if (digits.equals(otp)) {
Say success = new Say.Builder("認証に成功しました。").language(Language.JA_JP).build();
VoiceResponse successRes = new VoiceResponse.Builder().say(success).build();
return successRes.toXml();
}
}
Say fail = new Say.Builder("認証に失敗しました。").language(Language.JA_JP).build();
VoiceResponse failRes = new VoiceResponse.Builder().say(fail).build();
return failRes.toXml();
});
/**
* ワンタイムパスコード連絡
*/
post("/otp/announce", (request, response) -> {
byte[] bytes = new byte[OTP_LENGTH];
SecureRandom secRandom = SecureRandom.getInstance("SHA1PRNG");
secRandom.nextBytes(bytes);
StringBuilder otp = new StringBuilder();
for (int i = 0; i < bytes.length; i++) {
otp.append(secRandom.nextInt(10));
}
IvrOtpDAO ivrOtpDAO = new IvrOtpDAO();
ivrOtpDAO.setOtp("1a2b3c", otp.toString());
Say otpMsg = new Say.Builder(
"認証コードは" + otp.toString().substring(0, 1) + "、" + otp.toString().substring(1, 2) + "、"
+ otp.toString().substring(2, 3) + "、" + otp.toString().substring(3, 4) + "、"
+ otp.toString().substring(4, 5) + "、" + otp.toString().substring(5, 6))
.language(Language.JA_JP).build();
VoiceResponse voiceResponse = new VoiceResponse.Builder().say(otpMsg).build();
Gather gather = new Gather.Builder().action("/otp/check").method(HttpMethod.POST).say(otpMsg).timeout(20)
.build();
Say timeoutSay = new Say.Builder("入力が確認できなかったため、通話を終了します。").language(Language.JA_JP).build();
VoiceResponse timeoutRes = new VoiceResponse.Builder().gather(gather).say(timeoutSay).build();
return timeoutRes.toXml();
});
/**
* 架電処理
*/
get("/call/:number", (request, response) -> {
String phoneNumber = request.params(":number");
PhoneNumber to = new PhoneNumber(phoneNumber);
PhoneNumber from = new PhoneNumber(TWILIO_NUMBER);
URI uri = URI.create(NGROK_BASE_URL + "/otp/announce");
Call call = new CallCreator(to, from, uri).create(client);
return "calling authentication code...";
});
}
}
➄認証コード取得/保管DAO
package com.example.demo;
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
import redis.clients.jedis.exceptions.JedisConnectionException;
public class IvrOtpDAO {
private JedisPool pool;
private Jedis jedis = getResource();
/** 認証コード有効期限 */
private static int RETENTION = 300;
public IvrOtpDAO() {
}
private Jedis getResource() throws JedisConnectionException {
synchronized (this) {
if (pool == null || pool.isClosed()) {
pool = new JedisPool(new JedisPoolConfig(), "localhost");
}
}
return pool.getResource();
}
/**
* 認証コード設定
*
* @param deviceId 端末ID
* @param otp 認証コード
*/
public void setOtp(String deviceId, String otp) {
String result = jedis.setex(deviceId, RETENTION, otp);
System.err.println(result);
}
/**
* 認証コード取得
*
* @param deviceId 端末ID
* @return 認証コード
*/
public String getOtp(String deviceId) {
String otp = jedis.get(deviceId);
return otp;
}
}
⑥検証
6-1. GET /call/<電話番号>へリクエスト発行
6-2. 以下のように電話が掛かってくる。電話に出ると、「認証コードは、841086」とアナウンスが流れる。
6-4. 自動音声で**「認証に成功しました。」**とアナウンスが流れる。
6-5. nagrokのコンソール見ると、①の架電リクエスト(/call/<電話番号>)、②でのWebhookリクエスト(/otp/announce)、③での認証リクエストが正常に実行されていることが確認できる。
6-6. Twilioコンソールから認証リクエスト(/otp/check)へのリクエスト内容を見ると、Degitsパラメータとして、入力した認証コードがPOSTされていることが確認できる。