ngrok + Spark + TwilioでIVR認証を実装してみた

前回の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から電話番号を購入**(※今回は無料トライアル枠を使用)
![キャプチャ1.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/466e09ff-86af-4f86-7981-8a9e1fa44a2b.jpeg)

**③WebhookURLに電話応答後のリダイレクト先URLを指定**
![キャプチャ.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/2a96eda2-7297-5ad5-cd68-5eb3b464a3e1.jpeg)


**④自動音声通知/認証コード認証API**

```java

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**

```java

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**」とアナウンスが流れる。
![IMG_0056.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/d32f0e33-96d5-7d8a-c8dc-50efad57124f.png)

6-3.電話番号に「**841086**」を入力する。
![IMG_0059.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/e0405dab-14e2-a4fd-2aee-02ce723ad15d.png)

6-4. 自動音声で**「認証に成功しました。」**とアナウンスが流れる。

6-5. nagrokのコンソール見ると、①の架電リクエスト(/call/<電話番号>)、②でのWebhookリクエスト(/otp/announce)、③での認証リクエストが正常に実行されていることが確認できる。
![test.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/dd7a1ca1-8d1f-eb11-7750-1ab2dd4fe9f9.jpeg)

6-6. Twilioコンソールから認証リクエスト(/otp/check)へのリクエスト内容を見ると、Degitsパラメータとして、入力した認証コードがPOSTされていることが確認できる。
![キャプチャ.JPG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/398780/6db52c08-3dab-dae8-d7ca-7225df7a8fd2.jpeg)