『入る学科間違えた高専生』の日記

プログラミングのコードを書いたりする予定です。あとは日記等。あといつまで高専生やねん

Vonage Verify API を使ってみた

この記事は Vonage Advent Calendar 2022 の10日目の記事です。

qiita.com


はじめに

今回は Vonage さんの提供しているサービスの1つの Verify API を使用してみたよという記事です。

Verify API とは

www.vonagebusiness.jp

まず今回使用する Verify API とは、二要素認証を開発しているサービスやソフトウェアに組み込むための API です。
二要素認証としてよくあるのはメールアドレスとパスワードを入力してもらい(知的要素) SMS を受け取れる電話番号に PIN コードを送信してユーザにその PIN コードをサービスやソフトウェアに入力してもらうような所有による認証(所有要素)が挙げられるかと思います。

Verify API でも上記のような所有要素による二要素認証ができるので今回もそれに従って実装をしていきます。

developer.vonage.com

初めてで何から手を付けたらいいのかわからないのでドキュメント通り進めて行こうと思います!
以前からアカウントは持っていたのでアカウントは作成済みです!( iOSDC Japan 2022 で中の人とお話をさせていただいた)

環境

デモを使ってみる

https://dashboard.nexmo.com/getting-started/verify

Dashboard に入るとどうやら登録している電話番号宛にデモを行えるようです。

こんな感じで実際にどのように行われるかのデモが行えました!
なおここでのデモも成功したときにクレジットが消費されます。

電話番号のレンタル

Vonage さんに問い合わせたところ、海外では仮想電話番号が必要になる国もあるらしいですが、日本では仮想番号がなくても使用可能とのことです。
なので仮想電話番号は特にレンタルすることなく進めます。

レンタルする場合は月20€とのことです。

仮想電話番号をレンタルしようとした

まずは仮想電話番号をレンタルをします。*1

以下の属性にしてみます。

Request SMS Mobile Numbers ボタンを押すと、なるほど?なにならポップアップが表示されました。
いくつ電話番号を購入しますか?その使用用途は?という内容っぽいです。

今回は1つだけ購入し、使用用途は今回は素直に「 Qiita Advent Calendar の記事作成のため」としておきます。

さすがに簡単にはレンタルをさせてはくれないらしいです。もしものためですかね。次へボタンを押すとリクエストが走りメールが飛んできました。

We have received your request (#XXXXXXXX), and will be working on it as soon as possible. We respond to most requests within 2 business days of having been received. You can add any additional comments by replying to this email.

2営業日かかるらしいのでじっくり待ちます。(書いているのが日曜日...)

サーバサイド開発

SDK のインストール

さて、仮想電話番号レンタルが完了するまで待ってる間にサーバサイドでも開発して待ちましょう。

https://developer.vonage.com/tools こちらにアクセスすると SDK やツールの一覧が閲覧できるようです。
(ドキュメントの言語を日本語にして上記の URL を開くと Rails のエラー画面が表示されてちょっとびっくりしたのは秘密🤫)

複数言語に対応していますね。素晴らしい!!
私は今のところサーバサイドは Python と Swift (ちょっと) しか書けないので PythonSDK を使用させていたくだことにしました。

他にも Golang も Go Packages を見に行くとありそうです。*2

とりあえずインストールだけしておきます。

$ pip install vonage

パッケージのドキュメントについても README.md に書かれていますね。
今回の Verify API についても書かれていました。

https://github.com/Vonage/vonage-python-sdk#verify-api

AcmeInc これなんだ?って思ったら架空の企業らしいですね。面白いことしますねw

ja.wikipedia.org

ここの brand のところに自社名やサービス名を入れると SMS の文書内が置き換わるようです。

前準備

普段は Django でサーバサイド開発をすることが多いのですが今回は別にユーザ管理をする気はなく実際に Verify API を試してみたいだけなので FastAPI を使用します
どうやらいろいろ考慮する実装をし始めるとデータベース機能を持ち合わせた使い慣れているフレームワークを使ったほうが良さそうなので Django を使用します。

$ pip install django "gunicorn[gevent]" djangorestframework
$ django-admin startproject vonage_verify .
$ django-admin startapp webapi
$ python manage.py migrate

という感じでプロジェクト名は vonage_verify でアプリ名は webapi としておきます。
また、今回は Django の機能を扱いたいためなのでめっちゃ雑にサーバサイド側は実装しています。悪しからず。

$ gunicorn vonage_verify.wsgi:application -k gevent --reload

という感じで http://127.0.0.1:8000 でアプリケーションを起動できるようにしておきました。

また、 vonage_verify/settings.py に以下の様な記載をして API キーと API シークレットを同梱しています。

import os

...

VONAGE_API_KEY = os.getenv("VONAGE_API_KEY")
VONAGE_API_SECRET = os.getenv("VONAGE_API_SECRET")

こうすることで django.conf.settings からアクセスができますね。

Verify を始める API の作成

検証をする電話番号に PIN コードの送信をリクエストするための API です。 POST /verify

先に Serializer を準備しておきます webapi/serializers/verify.py としています。

from django.conf import settings
from rest_framework import serializer
from rest_framework.exceptions import ValidationError
from vonage import Client


class VerifyCreateSerializer(serializer.Serializer):
     phone_number = serializer.CharField(required=True, write_only=True)
     request_id = serializer.CharField(read_only=True)

    class Meta:
        fields = ("phone_number", "request_id",)

    @property
    def client(self):
        return Client(key=settings.VONAGE_API_KEY, secret=settings.VONAGE_API_SECRET)

    def validate_phone_number(self, obj):
        ... # 一旦省略
        phone_number = ...
        return phone_number

    def create(self, validated_data):
        phone_number = validated_data.pop("phone_number")
        res = self.client.verify.start_verification(
            number=phone_number,
            brand="nnsnodnb",
            code_length=4,
            lg="ja-jp",
            next_event_wait=120,
            workflow_id=1,
        )
        if res["status"] == "0":
            return {"request_id": res["request_id"]}

        raise ValidationError(detail={"status": res["status"], "error_text": res["error_text"]})

一旦雑にですが、 Serializer で Verify API に認証リクエストを開始するコードを書いてみました。
上記のコードで現れた workflow_id は以下のドキュメントを参考にするとよさそうです。こちらに関しては各々のビジネスモデルによって変えるべきなのかなと思います。
今回はあえて workflow_id=1 としていますが、デフォルトで指定がされているようです。

developer.vonage.com

他のパラメータについても公式のドキュメントに記述があるので参考ください。

https://developer.vonage.com/api/verify#requests

from rest_framework.generics import  CreateAPIView

from .serializer.verify import VerifyCreateSerializer


class VerifyCreateAPIView(CreateAPIView):
    serializer_class = VerifyCreateAPIView

また、ここでは {"phone_number": "<< PHONE_NUMBER >>"} としてリクエストボディを要求するようにしましたが、 Verify API としては E.164 フォーマットに従った電話番号のみを受け付けることができるのでそれぞれの国の電話番号をフォーマットに揃えてあげないといけません。*3
もしくは country パラメータを使い 08012345678 の形でもリクエストができるようにすることができるとのことです。*4

今回は日本の電話番号に前提をおくものとしたいので、 817012345678 のようにフォーマットして上げる感じです。
ドキュメントには

・Omit both the international access code such as +, 00 or 001.
・Contain no special characters, such as a space, (, ), or -.

と書かれています。

def validate_phone_number(self, obj):
    phone_number = obj.replace("(", "").replace(")", "").replace("-", "").replace(" ", "")
    if phone_number.startswith("+810"):
        return f"81{phone_number[4:]}"
    elif phone_number.startswith("+81"):
        return phone_number[1:]
    elif phone_number.startswith("810"):
        return f"81{phone_number[3:]}"
    elif phone_number.startswith("0"):
        return f"81{phone_number[1:]}"

    return phone_number

というような感じでクライアント側から電話番号が渡されたときにある程度はフォーマットできるようにしました。
よくあるパターンでとりあえず今回はフォーマットしてみました。

PIN コードを検証する API の作成

ユーザから送られてきた PIN コードを受け取って検証するための API です。 POST /verify/{request_id}

こちらも同様に先に Serializer の実装をします。 webapi/serializers/verify.py に追記します。

class CheckVerifySerializer(serializers.Serializer):
    request_id = serializers.HiddenField(default=None, write_only=True)
    code = serializers.CharField(required=True, write_only=True)
    event_id = serializers.CharFIeld(read_only=True)

    class Meta:
        fields = ("request_id", "code", "event_id",)

    @property
    def client(self):
        return Client(key=settings.VONAGE_API_KEY, secret=settings.VONAGE_API_SECRET)

    def validate_request_id(self, _):
        return self.context["request"].parser_context["kwargs"]["request_id"]

    def create(self, validated_data):
        request_id = validated_data.pop("request_id")
        code = validated_data.pop("code")

        res = self.client.verify.check(request_id=request_id, code=code)
        if res["stauts"] == "0":
            return {"event_id": res["event_id"]}

        raise ValidationError(detail={"status": res["status"], "error_text": res["error_text"]})

このように、 PIN コードを Verify API に送信し検証をする Serializer を実装しました。
また、 View は以下のように実装しました。

from .serializers.verify import CheckVerifySerializer


class CheckVerifyCreateAPIView(CreateAPIView):
    serializer_class = CheckVerifySerializer

ここまでの実装で以下のシーケンスを満たせるようになりました。

PIN コードが User に送られてるのはどうなんじゃい...っていうのはわかってる。許して...

キャンセルをする API の利用

Verify API は同じ電話番号に対して同時に認証リクエストをしようとするとエラーが返ってくるような仕組みになっています。
リクエストをキャンセルする仕組みを提供しておくべきかと思いました。

同時の認証リクエストをした場合のエラー発生パターン

上記のシーケンス図のようなエラーが発生してしまいます。
前述で作成した POST /verify に改良を加えます。

まずは、 webapi/models.py にモデルを生成しました。

from django.db import models


class RequestId(models.Model):
    class StatusChoices(models.TextChoices):
        IN_PROGRESS = "IN PROGRESS", "進行中"
        SUCCESS = "SUCCESS", "成功"
        FAILED = "FAILED", "失敗"
        EXPIRED = "EXPIRED", "期限切れ"
        CANCELLED = "CANCELLED", "キャンセル済み"

    phone_number = models.CharField("電話番号", max_length=20, default="", blank=False)
    request_id = models.CharField("リクエストID", max_length=40, default="", blank=False)
    status = models.CharField("ステータス", max_length=12, default=StatusChoices.IN_PROGRESS, choices=StatusChoices.choices)

    class Meta:
        db_table = "request_id"

フォーマット済みの電話番号とリクエスト ID とステータスを持つテーブル request_id を作成しました。

from .models import RequestId


class VerifyCreateSerializer(serializers.Serializer):
    ...

    def create(self, validated_data):
        phone_number = validated_data.pop("phone_number")
        return self._start_verification(phone_number=phone_number)

    def _start_verification(self, phone_number: str, retried: bool = False):
        res = self.client.verify.start_verification(
            number=phone_number,
            brand="nnsnodnb",
            code_length=6,
            lg="ja-jp",
            next_event_wait=120,
            workflow_id=1,
        )
        # 通常成功
        if res["status"] == "0":
            RequestId.objects.create(
                phone_number=phone_number, request_id=res["request_id"], status=RequestId.StatusChoices.IN_PROGRESS
            )
            return {
                "request_id": res["request_id"],
            }

        # リトライ or 同じ電話番号ではないエラー
        if retried or res["status"] != "10":
            result = {
                "status": res["status"],
                "error_text": res.get("error_text"),
                "network": res.get("network"),
            }
            raise ValidationError(detail=result)

        # 以前のリクエスト ID の取得
        try:
            obj = RequestId.objects.get(phone_number=phone_number, status=RequestId.StatusChoices.IN_PROGRESS)
        except RequestId.DoesNotExist:
            result = {
                "status": res["status"],
                "error_text": res.get("error_text"),
            }
            if network := res.get("network"):
                result["network"] = network

            raise ValidationError(detail=result)

        # 以前のリクエスト ID をキャンセル済みにする
        res = self.client.verify.cancel(request_id=obj.request_id)
        # 再度同じ電話番号で認証リクエストを開始
        if res["status"] == 0:
            obj.status = RequestId.StatusChoices.CANCELLED
            obj.save()

            return self._start_verification(phone_number=phone_number, retried=True)

        result = {
            "status": res["status"],
            "error_text": res["error_text"],
        }
        raise ValidationError(detail=result)

というような感じで大改造を施しました。

リクエストのキャンセルを行いリトライをする

上記のシーケンス図の青い部分が今回の改造した部分に実装になっています。

https://developer.vonage.com/api/verify#verifyControl

この API を経由することで新しくリクエストを送信できるようになったかと思います。

また、 POST control/cancel にももちろん制約がありリクエスト開始後30秒以内はキャンセルすることができません。
上記のシーケンス図の status == 19 がその制約に該当します。

上記で RequestId というモデルを生成しデータベースで管理したということは、認証成功したタイミングでこちらも変更を加えなければなりません。

+from django.shortcuts import get_object_or_404

...

class CheckVerifySerializer(serializers.Serializer):
    def create(self, validated_data):
        ...
+       obj = get_object_or_404(RequestId, request_id=request_id)
        if res["status"] == "0":
+           obj.status = RequestId.StatusChoices.SUCCESS
+           obj.save()

ちょっと省略しましたが、本来であればエラーであった場合もレスポンスの status を見てそれぞれのステータスに書き換えなければなりません🙇‍♂️

ここまで Verify APISDK を使ってサーバサイド開発を行ってきて思ったことは、申し訳ないですが、エラーであってもどうやら HTTP ステータスコードが 200 OK で返ってきている(要検証)ということです...
しかしながら、 Early Access らしいですが v2 API が開発されている*5ようなので今後にとても期待です!

クライアント開発

ということで、一旦サーバサイドの開発ができたと思います。
続いてクライアントの実装です。

私は一応 iOS アプリエンジニアなので iOS アプリでこの API を呼び出して実際に Verify API を使用してみようと思います。

ここでは紹介しませんが、めっちゃ簡単な実装をとりあえずしました。

上記では電話番号をプライバシー保護のため伏せていますが最初の画面で電話番号を入力しています。

[08/Dec/2022 00:08:47] "POST /verify HTTP/1.1" 201 49
[08/Dec/2022 00:08:58] "POST /verify/127f566b1a884465b39c640379a75572 HTTP/1.1" 201 2

Django 側のアクセスログもちゃんと確認できました。

思ったこととか

今回は、 Vonage さんの提供する Verify API を使用して電話番号による認証を実装してみました。
同じような仕組みを提供しているものとして Firebase Authentication が想像されるのではないでしょうか?
Firebase Authentication では1万回/月の無料枠があり、 超えた場合でも $0.06/認証 となっています。また成功時に課金される仕組みになっているようです。*6
今回紹介した Verify API では認証自体は認証成功時に課金され、 SMS の配信や自動音声による電話は利用による従量課金がされる仕組みになっています。
完全認証成功課金モデル*7もあるとのことですが、今回のような都度課金のモデルのほうが結果として安くなることが多いとのことでした。

ただ、 Firebase Authentication と違って良いなと感じたのはやはり、サーバサイドの実装でもちょっと触れたように workflow があることが挙げられるかと思います。
workflow_id: 1 でしか検証を行っていないのですが、 SMS を放置していたらしばらくすると自動音声による電話がかかってきて、またそれも放置すると自動音声の電話がかかってきていました。
Firebase Authentication はそんな機能ないですよね。この自動音声、ちゃんと日本語をしゃべって機械機械した音声じゃなかったことが驚きでした。 ワークフローによっては、 SMS なしですぐに自動音声による電話もかけられる *8 ので SMS が受け取れない電話番号への対応もできるのではないでしょうか?

実装面での話を書くとこちらも前述しましたが、現状の API だとどうしても API のレスポンス内の status について検証を行わなければならずステータスの振り分けが煩雑になりがちです。
なにより、レスポンスは辞書型として扱うことになると思うので絶対にstatus が入っているかどうかという保証が利用者側としてできないのが怖いところです。v2 API に期待
たた、今回はサーバサイド SDK を使用しましたが API エンドポイント自体は公開されているのでやろうと思えば iOS アプリなどのフロントエンド側からでも今回のような実装ができますね。

最後に

最後もちょっと長くなりましたが、普段はなかなか個人で有料サービスを使うことがないのですが企画ということもありクーポンを使用して記事を作成させていただく流れになり*9
改めて有料サービスの良さを感じられることもでき、なおかつ Verify API での認証についても知ることができるいい機会になりました。