5
3

More than 1 year has passed since last update.

Vonage API + React(SPA) + Expressで電話番号認証とSMS送信アプリを作る

Last updated at Posted at 2022-12-20

概要

本記事では、VonageAPIのVerify APISMS APIを用いて、電話番号認証をして認証済み電話番号に対してSMSでメッセージを送信するWebアプリケーションを作成するハンズオン記事になっています。

MFA(多要素認証)は今やアプリケーションのセキュリティを向上させる上で重要な役割を担っています。各個人でMFAのための認証基盤を作成するのはコスト的にも技術的にも大変だと思います。VoyageAPIを使用することで認証方法の1つである電話番号認証が簡単に実装できることがわかりました。
本記事では、まずVonageが提供している Verify APIを用いて SPAによる電話番号認証を作成していきます。

(おまけ) セクションで、この電話番号認証をより堅牢にするために Number Insight API を用いて電話番号の有効性チェックの導入も行います。

そして、認証後の電話番号とSMS APIを用いて認証済みの電話番号端末に対して任意のメッセージでSMSを送信機能まで作成します。

以下のような画面構成を持つSPAのWebアプリケーションを作成していきます。

ホーム 認証コード発行 認証
スクリーンショット 2022-12-20 0.21.24.png スクリーンショット 2022-12-20 0.44.09.png スクリーンショット 2022-12-20 0.44.38.png

作成するアプリケーションの全体像です。 緑の四角がSPAのページを示していて、赤の四角がバックエンドサーバーの機能を示しています。直線がSPA内でのページの遷移、破線がそれぞれのAPIへのリクエストの流れを示しています。

作成したコードはこちらに置いてありますので参考にしてください。

それではアプリケーションを作成していきましょう。

アプリケーションを作成する

Vonageアカウントの作成する

Vonageアカウントの作成をしておきます。

プロジェクトを作成する

Reactプロジェクトの作成(フロントエンド)

$ npx create-react-app vonage-app

必要なパッケージインストール

アプリケーションを完成させるために必要なパッケージをインストールしていきます。

$ npm install express express-session body-parser dotenv @vonage/server-sdk
パッケージ名 内容
express Webアプリケーションフレームワーク
express-session ユーザーのログイン状態を管理する
body-parser POSTリクエストをパースする
dotenv VonageAPI KEY, SECRET, BRAND NAME(アプリケーション名)を環境変数管理
@vonage/server-sdk VonageAPIを扱うためのSDK

server.jsのフレームを作成(バックエンド)

require('dotenv').config();

const path = require('path')
const express = require('express');
const session = require('express-session');
const bodyParser = require('body-parser');
const app = express();
const { Vonage } = require('@vonage/server-sdk');

const VONAGE_API_KEY = process.env.VONAGE_API_KEY;
const VONAGE_API_SECRET = process.env.VONAGE_API_SECRET;
const VONAGE_BRAND_NAME = process.env.VONAGE_BRAND_NAME;

let verifyRequestId = null;
let verifyRequestNumber = null;

// Location of the application's CSS files
app.use(express.static('build'));

// The session object we will use to manage the user's login state
app.use(session({
    secret: 'loadsofrandomstuff',
    resave: false,
    saveUninitialized: true
}));

app.use(express.json());
app.use(bodyParser.urlencoded({ extended: true }));

// Define your routes here

// Run the web server
const server = app.listen(3000, () => {
    console.log(`Server running on port ${server.address().port}`);
});

dotenvで.envファイルによって、環境変数が使用できるようになっているので.envファイルにVONAGE_API_KEY、VONAGE_API_SECRETをVonageAPIDeveloperページから取得して環境変数に追加します。VONAGE_BRAND_NAMEは任意で適当な値を設定できます。

スクリーンショット 2022-12-17 22.59.18.png

.env
VONAGE_API_KEY=YOUR_API_KEY
VONAGE_API_SECRET=YOUR_API_SECRET
VONAGE_BRAND_NAME=<アプリケーション名>

package.jsonにexpressサーバー起動ようのスクリプトを追加していきます。

package.json
// ...
 "scripts": {
    "start": "react-scripts start",
    "build": "react-scripts build",
    "test": "react-scripts test",
    "eject": "react-scripts eject",
    "server": "node server.js" //  追加
  },
// ...

アプリを確認する

ここまで来たところで、現在のアプリケーションを確認してみましょう

以下のコマンドを実行して、Reactアプリケーションをビルドし、expressサーバーを立ち上げてブラウザで確認します。

$ npm run build // ← Reactアプリケーションのビルド
$ npm run server // ← Expressサーバー起動
> vonage-app@0.1.0 server
> node server.js

Server running on port 3000

スクリーンショット 2022-12-17 23.17.41.png

認証コードを取得する

画面UIを作成する(フロントエンド)

必要なページを作成していくためにReactRouter、UIを作成するためにMUIをインストールします。

$ npm install --save react-router-dom
$ npm install --save @mui/material @emotion/react @emotion/styled

各ページを作成する

以下それぞれのファイルのコードを記載しています。

App.jsx
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Home from './pages/Home';
import Authenticate from './pages/Authenticate';
import { Container } from '@mui/system';

function App() {
  return (
    <div className="App">
      <Container maxWidth="sm">
        <BrowserRouter>
          <Routes>
            <Route path={"/"} element={<Home />} />
            <Route path={"/authenticate"} element={<Authenticate />} />
          </Routes>
        </BrowserRouter>
      </Container>
    </div>
  );
}
export default App;
pages/Home.jsx
import { Button } from "@mui/material";
import { Link } from "react-router-dom";

const Home = () => {
  return (
    <>
      <h1>Account Management!</h1>
        <p>
          You have verified your identity using the phone number TODO
          and are now permitted to make changes to your account.
        </p>
        <Link to={"/authenticate"}>
          <Button variant="contained">
            Verify me
          </Button>
        </Link>
    </>
  );
};

export default Home;
pages/Authenticate.jsx
import { Button, FormControl, TextField } from "@mui/material";
import { useState } from "react";

const Authenticate = () => {
  const [mobileNumber, setMobileNumber] = useState("");

  const handleChangeMobileNumber = (e) => {
    setMobileNumber(e.target.value);
  }

  const requestVerificationCode = async () => {
    const formatMobileNumber = "81" + mobileNumber.slice(1);
    const response = await fetch("/verify", {
      method: "POST",
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ number: formatMobileNumber })
    })

    console.log(response.json());
  }

  return (
    <>
      <h1>Authenticate</h1>
      <FormControl fullWidth sx={{ m: 1 }}>
        <TextField
          id="outlined-name"
          label="Mobile number"
          inputProps={{ inputMode: "numeric", pattern: '[0-9]*' }}
          onChange={handleChangeMobileNumber}
        />
      </FormControl>
      <FormControl fullWidth sx={{ m: 1 }}>
        <Button 
          variant="contained"
          onClick={requestVerificationCode}
        >
            Get Verification Code
        </Button>
      </FormControl>
      <h2>{mobileNumber}</h2>
    </>
  );
};

export default Authenticate;

ここまで完成すると以下のような画面が作成できました。

ホーム画面 認証コードリクエスト画面
スクリーンショット 2022-12-18 0.52.58.png スクリーンショット 2022-12-18 0.53.06.png

認証コード発行をVonageにリクエストするエンドポイントを作成(バックエンド)

/verifyというPOSTリクエストのパスを作成し、リクエストで取得した携帯電話番号をvonageインスタンスにセットして認証コードをリクエストします。詰まってしまう箇所として、リクエストする時の電話番号は国際電話番号の形式にする必要があることに注意です。
09012345678 であれば、先頭のゼロを取り除き、日本を表す「81」を付けた 819012345678となります。

server.js
// Define your routes here
app.post('/verify', (req, res) => {
  verifyRequestNumber = req.body.number;
  vonage.verify.start({
    number: verifyRequestNumber,
    brand: VONAGE_BRAND_NAME
  }).then((resp) => {
    console.log(resp)
    verifyRequestId = resp.request_id;
    console.log(`request_id: ${verifyRequestId}`);
    res.status(201).send("success");
  }).catch(err => console.error(err));
})

以下のコードは画面側で /verifyへリクエストする際のイベント関数ですが、リクエストの前に電話番号を国際電話番号形式に変換するロジックを入れています。(※ 画面UIを作成する(フロントエンド)章の pages/Authenticate.jsxに含まれるコードの一部です。)

Authenticate.jsx
// ...
  const requestVerificationCode = async () => {
    const formatMobileNumber = "81" + mobileNumber.slice(1);
    const response = await fetch("/verify", {
      method: "POST",
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ number: formatMobileNumber })
    })

    console.log(response.json());
  }
// ...

認証情報が携帯電話に送信されるかを確認する

認証コードリクエスト画面で、実際に自身の携帯電話番号を入力して、GET VERIFICATION CODEボタンを押下してみましょう。

するとExpressサーバー側のログにはrequest_idが表示され、正常にリクエストできていることが確認できます。

{ request_id: 'cec9cb43b5434d12862b3b867cfa4da7', status: '0' }
request_id: cec9cb43b5434d12862b3b867cfa4da7

実際に携帯電話にもSMS通知で認証番号が届いていることがわかります。

認証コード
IMG_3565.PNG

認証コードを認証する

画面UIを作成する(フロントエンド)

新規で認証コードを入力するためのページを作成します。
ページに必要なUIとしては、携帯電話に送信された認証番号を入力するテキストフィールドとリクエストを送信するためのボタンなのでほとんどAuthenticateページと変わらないです。

以下がVerifyCodeページの全体のソースコードです。

pages/VerifyCode.jsx
import { Button, FormControl, TextField } from "@mui/material";
import { useState } from "react";

const VerifyCode = () => {
  const [code, setCode] = useState("");

  const handleChangeCode = (e) => {
    setCode(e.target.value);
  }

  const requestVerificationCode = async () => {
    const response = await fetch("/check-code", {
      method: "POST",
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ code })
    })

    console.log(response.json());
  }

  return (
    <>
      <h1>VerifyCode</h1>
      <FormControl fullWidth sx={{ m: 1 }}>
        <TextField
          id="outlined-name"
          label="Code"
          inputProps={{ inputMode: "numeric", pattern: '[0-9]*' }}
          onChange={handleChangeCode}
        />
      </FormControl>
      <FormControl fullWidth sx={{ m: 1 }}>
        <Button 
          variant="contained"
          onClick={requestVerificationCode}
        >
          Verify me!
        </Button>
      </FormControl>
    </>
  );
};

export default VerifyCode;
認証ページ
スクリーンショット 2022-12-18 1.52.07.png

新規ページに遷移できるようにするために、App.jsxを修正します。

pages/App.jsx
import './App.css';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import Home from './pages/Home';
import Authenticate from './pages/Authenticate';
import { Container } from '@mui/system';
import VerifyCode from './pages/VerifyCode';

function App() {
  return (
    <div className="App">
      <Container maxWidth="sm">
        <BrowserRouter>
          <Routes>
            <Route path={"/"} element={<Home />} />
            <Route path={"/authenticate"} element={<Authenticate />} />
            <Route path={"/verifycode"} element={<VerifyCode />} />
          </Routes>
        </BrowserRouter>
      </Container>
    </div>
  );
}

export default App;

また、Authenticateページで認証コード発行のリクエスト後にページ遷移させるために、ReactRouterのuseNavigate()を利用してVerifyCodeページに遷移するように修正します。

pages/Authenticate.jsx
import { Button, FormControl, TextField } from "@mui/material";
import { useState } from "react";
import { useNavigate } from "react-router-dom";

const Authenticate = () => {
  const navigate = useNavigate();
  const [mobileNumber, setMobileNumber] = useState("");

  const handleChangeMobileNumber = (e) => {
    setMobileNumber(e.target.value);
  }

  const requestVerificationCode = async () => {
    const formatMobileNumber = "81" + mobileNumber.slice(1);
    await fetch("/verify", {
      method: "POST",
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({ number: formatMobileNumber })
    });

    navigate("/verifycode")
  }

  return (
    <>
      <h1>Authenticate</h1>
      <FormControl fullWidth sx={{ m: 1 }}>
        <TextField
          id="outlined-name"
          label="Mobile number"
          inputProps={{ inputMode: "numeric", pattern: '[0-9]*' }}
          onChange={handleChangeMobileNumber}
        />
      </FormControl>
      <FormControl fullWidth sx={{ m: 1 }}>
        <Button 
          variant="contained"
          onClick={requestVerificationCode}
        >
            Get Verification Code
        </Button>
      </FormControl>
      <h2>{mobileNumber}</h2>
    </>
  );
};

export default Authenticate;

認証コード認証をVonageにリクエストするエンドポイント作成(バックエンド)

/verifyの下に新しく/check-codeのエンドポインを立て、認証をリクエストする処理を書いて行きます。発行のコードと似ていて、vonageインスタンスに発行の際に取得したverifyRequestIdと画面側からのリクエストで取得する携帯電話に送信された認証番号であるcodeを引数にいれて認証をリクエストします。

sever.js
app.post('/check-code', (req, res) => {
  vonage.verify.check({
    request_id: verifyRequestId,
    code: req.body.code
  }).then((resp) => {
    console.log(resp)
    if (resp.status === 0) {
      req.session.user = {
        number: verifyRequestNumber
      };
    }
    res.status(201).send("success");
  }).catch(err => console.error(err));
});

認証の確認

Expressサーバーで認証コードが正常に受理されると以下のようなレスポンスが返却され console.logによってターミナルに表示されます。
status: '0'が成功のステータスレスポンスです。

{ code: '3904' }
{
  request_id: 'c753cc99fb714c80ad3656ac364e1579',
  status: '0',
  event_id: '330000005912A562',
  price: '0.05000000',
  currency: 'EUR',
  estimated_price_messages_sent: '0.07000000'
}

認証状態を表示する

ホーム画面を修正する(フロントエンド)

Home.jsxに認証状態を取得する処理を追加していきます。画面レンダリングされたと同時に取得したいのでuseEffectでAPIコールを行います。
また、認証状態である場合には認証済みの電話番号を表示させたいので、表示させるUIデザインを少し可愛くしていきます。そのために@mui/icons-materialをインストールします。

$ npm install --save @mui/icons-material

以下がHome.jsxの最新の全体コードです。

pages/Home.jsx
import { Button, Chip } from "@mui/material";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import MdPhone from '@mui/icons-material/Phone';
import { Box } from "@mui/system";

const Home = () => {
  const [verifyNumber, setVerifyNumber] = useState(null);
  const [fromVerifyCode, setFromVerifyCode] = useState(null);
  const search = useLocation().search;
  const query = new URLSearchParams(search);
  if (query.verify) {
    setFromVerifyCode(query.verify);
  }

  useEffect(() => {
    const getVerify = async () => {
      const response = await fetch("/verify-number", {
        method: "GET",
        headers: {
          'Content-Type': 'application/json'
        },
      })

      const json = await response.json();
      setVerifyNumber(json.verifyNumber);
    }
    getVerify();
  }, [fromVerifyCode]);

  const phoneLabelMessage = verifyNumber ? `+${verifyNumber}` : "You are not verified";

  return (
    <>
      <h1>Account Management!</h1>
      <Box sx={{ m: 1 }}>
        <Chip icon={<MdPhone />} label={phoneLabelMessage}/>
      </Box>
      <Link to={"/authenticate"}>
        <Button variant="contained">
          Verify me
        </Button>
      </Link>
    </>
  );
};

export default Home;

認証状態を取得するエンドポイントを作成(バックエンド)

/check-codeのエンドポイントの下に新しく、認証状態を取得するGETリクエスト用のエンドポイント /verify-numberを作成します。ここでは、認証済みであれば verifyNumberに認証された電話番号が入り、未認証であれば、nullが入るようになっています。

server.js
app.get('/verify-number', (req, res) => {
  console.log(req.session.user)
  res.status(200).send({
    verifyNumber: req?.session?.user?.number,
  })
});

認証状態の確認

未認証 認証済み
スクリーンショット 2022-12-18 2.48.06.png スクリーンショット 2022-12-18 2.48.59.png

これでSPAに電話番号認証を導入することができます。VonageAPIを使用するとめちゃくちゃ簡単に作成できますね!

認証した電話番号にSMSを送信する機能

最後に、認証した電話番号を用いてSMSを送信する機能を作成していきます。

ホーム画面を修正する(フロントエンド)

Home.jsxに認証済みの電話番号があれば、テキストボックスとSMSの送信ボタンを表示させるようにJSX部分に追加していきます。
また、SMSで送信したい内容用のテキストボックスのchangeイベントに対応する handleChangeText関数とSMSを送信するためのhandleSubmitSms関数を定義して、それぞれのイベントに設定します。SMSの送信のためのエンドポイントのAPIは後述のバックエンド側の処理で書いていきますが、send-smsにPOSTでリクエストするようにします。

pages/Home.jsx
import { Button, Chip, FormControl, TextField } from "@mui/material";
import { useEffect, useState } from "react";
import { Link, useLocation } from "react-router-dom";
import MdPhone from '@mui/icons-material/Phone';
import { Box } from "@mui/system";

const Home = () => {
  const [verifyNumber, setVerifyNumber] = useState(null);
  const [fromVerifyCode, setFromVerifyCode] = useState(null);
  const [text, setText] = useState("");
  const search = useLocation().search;
  const query = new URLSearchParams(search);
  if (query.verify) {
    setFromVerifyCode(query.verify);
  }

  useEffect(() => {
    const getVerify = async () => {
      const response = await fetch("/verify-number", {
        method: "GET",
        headers: {
          'Content-Type': 'application/json'
        },
      })

      const json = await response.json();
      setVerifyNumber(json.verifyNumber);
    }
    getVerify();
  }, [fromVerifyCode]);

  const phoneLabelMessage = verifyNumber ? `+${verifyNumber}` : "You are not verified";

  const handleChangeText = (e) => {
    setText(e.target.value);
  }

  const handleSubmitSms = async () => {
    const data = { number: verifyNumber, text };
    await fetch("/send-sms", {
      method: "POST",
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify(data)
    });
  }

  return (
    <>
      <h1>Account Management!</h1>
      <Box sx={{ m: 1 }}>
        <Chip icon={<MdPhone />} label={phoneLabelMessage}/>
      </Box>
      <Link to={"/authenticate"}>
        <Button variant="contained">
          Verify me
        </Button>
      </Link>

      { verifyNumber && 
        (<Box sx={{ m: 2 }}>
          <FormControl fullWidth sx={{ m: 1 }}>
            <TextField
              id="outlined-name"
              label="sms text"
              onChange={handleChangeText}
            />
          </FormControl>
          <FormControl fullWidth sx={{ m: 1 }}>
            <Button 
              variant="contained"
              onClick={handleSubmitSms}
            >
              Send SMS
            </Button>
          </FormControl>
        </Box>)
      }
    </>
  );
};

export default Home;

SMSを送信するためのエンドポイントを作成(バックエンド)

/verify-number のエンドポイントの下に/send-smsの新規エンドポイントを作成します。ここでは、VonageSDKのSMS APIを使用していきますが、to、from、textさえ指定すれば簡単に任意のメッセージをSMSで送信することができます。
任意のメッセージは画面からリクエストされて送られてくるreq.body.textを設定します。

server.js
app.post('/send-sms', async (req, res) => {
  const to = req.body.number;
  const text = req.body.text;
  await vonage.sms.send({ to, from: VONAGE_BRAND_NAME, text })
    .then(resp => {
      console.log('Message sent successfully');
      console.log(resp);
    })
    .catch(err => console.error(err));
  res.status(201).send("success");
});

SMS送信の確認

以下のコマンドで再度フロントエンドをビルドして、サーバーを立ち上げます。

$ npm run build
$ npm run server

立ち上げたWebアプリケーションで電話番号認証をすると、以下のように新規で追加したテキストボックスとSEND SMSボタンが表示されます。ここに任意のメッセージを入れ、送信!
そうすると左のSMSメッセージ画像のようにアプリブランド名とメッセージがSMSで送信されました!

電話番号認証済みHOME画面 SMSメッセージ
スクリーンショット 2022-12-19 23.13.02.png IMG_3566.PNG

バックエンドのログも確認しておきましょう。
console.logに仕込んでおいた Message sent successfully が表示されて、VonageAPIからのレスポンスが表示されていますね。

Message sent successfully
{
  messageCount: 1,
  'message-count': '1',
  messages: [
    {
      to: 'XXXXXXXXXX', ← 電話番号なのでマスク
      'message-id': 'e9cfde87-6e96-476c-a37b-9c0cfbe90eb1',
      status: '0',
      'remaining-balance': '10.59800000',
      'message-price': '0.07000000',
      network: '44007',
      messageId: 'e9cfde87-6e96-476c-a37b-9c0cfbe90eb1',
      remainingBalance: '10.59800000',
      messagePrice: '0.07000000'
    }
  ]
}

SMS送信も驚くほど簡単に実装できました。

(おまけ) 電話番号認証を少し堅牢にする

VonageAPIにはNumber Insight APIという電話番号の有効性や到達性をチェックするためのAPIがあります。このAPIを用いて、電話番号認証のロジックを少し改良します。

Expressサーバーの /verify エンドポイントで電話番号に対して認証コードを送信する前に、その電話番号が有効化どうかをNumber Insight APIによってチェックします。
statusが0であれば、正常な電話番号として処理されているので、それ以外であればhttp statusコード400を返すようにします。

server.js
app.post('/verify', async (req, res) => {
  verifyRequestNumber = req.body.number;

+  const resp = await vonage.numberInsights.basicLookup(req.body.number);
+  console.log(resp);
+  if (resp.status !== 0) {
+    return res.status(400).send("invalid number");
+  }

  vonage.verify.start({
    number: verifyRequestNumber,
    brand: VONAGE_BRAND_NAME
  }).then((resp) => {
    console.log(resp)
    verifyRequestId = resp.request_id;
    console.log(`request_id: ${verifyRequestId}`);
    res.status(201).send("success");
  }).catch(err => console.error(err));
});

これで有効性チェックも導入できました。

ちなみに、Number InsightAPIのレスポンスは以下のような中身でした。国や国際電話番号プレフィックスもわかるので認証APIを国際化もできそうですね。

{
  status: 0,
  status_message: 'Success',
  request_id: 'e1dfd728-7ce0-44ba-ad40-e3f177892492',
  international_format_number: 'XXXXXXXX', ← マスク
  national_format_number: 'XXX-XXXX-XXXX', ← マスク
  country_code: 'JP',
  country_code_iso3: 'JPN',
  country_name: 'Japan',
  country_prefix: '81'
}

トラブルシューティング

もしAPIエラー等で悩まされる場合はAPIエラーがまとめられたドキュメントがあるので参照してみるとヒントが得られるかもしれないです。

筆者はデバッグしている際によく status: 10 が返却されて、セッション切れを待つことがありました笑。(他にもverifyすると解消されます。)

Code Text Definition
10 Concurrent verifications to the same number are not allowed Two simultaneous requests to the same number are not allowed. The customer can perform a verification request to the same number only after the previous request to that number has been marked as SUCCESSFUL, FAILED, or EXPIRED.

API Reference

また今回利用したAPIはOpenAPIのような形式でRESTful APIのインターフェースが定義されている仕様書まであるので、ぜひ参照して見てください。

VonageAPIでの開発に欲しかった機能

開発時点では、ダミーの電話番号や認証コードが1234などで通るようなsandbox機能があるといいなぁと思いました。しかし、Dashboardも見やすく各APIの実装例も豊富なので困らずに実装できました。

最後に

今回初めてVonageAPIを触ってみましたが、ドキュメントが充実しておりとても使いやすいサービスでした。SDKの更新に対応できていないドキュメントなどもありましたが、Dashboardから見れる説明はほぼ新しい情報だったので特に困らず実装をすることができました。
また、カレンダーに参加するにあたって無料のテストクレジットや追加のクレジットクーポンも提供してくださっていて大変助かりました。他のVoyageAPIも興味深いものが多いのでこの機会を活用して色々試して見ようと思いました。ありがとうございました。

参考

5
3
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
5
3