着信認証ASPでEC-CUBEの会員登録を2要素化

# はじめに
着信認証は株式会社オスティアリーズが提供する電話番号を利用した認証APIの仕組みです。
2要素認証やパスワードレス認証等の用途で各所に導入が進んでいます。
また、2021年より簡易な着信認証を可能にした着信認証ASP版のサービス提供も開始しました。
今回、あるところからの相談でECサイト構築のデファクトであるEC-CUBEに「少ないリソースで簡易に2要素認証を導入出来ないか？」との要望から会員登録時に着信認証ASP版を組み込んでみました。
※EC-CUBEダウンロード版（4系の4.1.2）を使用しています
**参考：**
- [着信認証ASPで2要素認証を実装する](https://qiita.com/ost_oga/items/47bc0f3410b067280eee)

# やりたいこと
今回は新規会員のフローの
・「情報入力」→「確認」→「完了」
を
・「情報入力」→「確認」→「着信認証」→「完了」
に変更したいと思います。

# 環境構築
###### EC-CUBEのインストール
公式の手順に従いサーバにインストール（[詳細](https://www.ec-cube.net/installation/)）。
簡単にサンプルの店舗ページと管理ツールが動きます。

###### 着信認証ASPの登録
[こちら](https://asp.ostiaries.jp/signup)から着信認証ASPにサインアップを完了させて使用します。
詳細は[別記事](https://qiita.com/ost_oga/items/47bc0f3410b067280eee)をご参照お願いします。
以下、EC-CUBEの管理ツールと着信認証ASPの管理ツールで作業を行うため便宜上
**・EC-CUBEの管理ツール=「EC管」**
**・着信認証ASP管理ツール=「ASP管」**
とします。

# 着信認証をEC-CUBEへ組み込み
###### step1：EC-CUBEのショップサイトに着信認証のスクリプトを設定
着信認証ASPは、着信認証を行いたいページに
・JSタグの設定
・SUBMIT時のイベントに着信認証実行関数コール
を設定します。
上の設定でイベント発生時に着信認証ASP側へロケーションを遷移させ、認証後JSタグのパラメータに設定されたURLへ戻ってくる仕組みになっています。
まず、デベロッパー情報に従いJSのパラメータを準備します。
EC-CUBEの会員登録コントローラが「entry」という名前でルーティングされ、
後述のコントローラカスタマイズで着信認証の結果判定のため今回は「mode」というパラメータを追加したURLを定義します。
```
（例）
開発環境のホスト名は「sample.com」
ok_url：https%3A%2F%2Fsample.com%2Fentry%3Fmode%3Dcomplete
　　　　※https://sample.com/entry?mode=completeをURLエンコードしたもの
back_url：back_url=https%3A%2F%2Fsample.com%2Fentry%3Fmode%3Dretry
　　　　※https://sample.com/entry?mode=retryをURLエンコードしたもの
```
上のURLをASP管のデベロッパー情報「★Step1」からJSをコピーして、
{ok_url}{back_url}をそれぞれ書き換えます。
次にEC管の「コンテンツ管理」「ページ管理」より「会員登録(確認ページ)」を選択して、
適当な箇所に
```
{% block javascript %}
上で作ったJSタグをコピペ
{% endblock javascript %}
```
を設定し登録。

###### step2：続いて着信認証実行関数を設定
「会員登録(確認ページ)」に続けて編集を行います。
```
<button class="ec-blockBtn--action" type="submit" name="mode" value="complete">{{ '会員登録をする'|trans }}</button>
↓
<button class="ec-blockBtn--action" type="button" name="mode" value="complete" onclick="call_auth_run('{{ form.phone_number.vars.data }}')">{{ '会員登録をする(着信認証実施)'|trans }}</button>
```
以上でサイト側の修正は完了。

###### step3：EC-CUBEの会員管理コントローラをカスタマイズ
着信認証を行う際、一時的にロケーションがショップサイトから着信認証ASPに離れます。
認証後にショップサイトへ戻った際に会員登録画面で入力された情報を復元するため、
EC-CUBEの会員登録コントローラ（EntryController.php）に手を入れます。
※EC-CUBEには、様々なカスタマイズに対応するように/app/Customize/ディレクトリが用意されておいます。
★カスタマイズ環境準備
　/src/Eccube/Controller/EntryController.phpを/app/Customize/Controllerにコピー
　　↓
　コピーしたEntryController.phpをCustomEntryController.phpにリネーム
　　↓
　CustomEntryController.phpのNameSpace等の編集
```CustomEntryController.php
// namespaceをカスタマイズ環境に合わせる
namespace Eccube\Controller;
↓
namespace Customize\Controller;

// AbstractControllerをuseに追加
use Eccube\Controller\AbstractController;

// クラス名の変更
class EntryController extends AbstractController
↓
class CustomEntryController extends AbstractController
```
★コンストラクタにPrefRepositoryを追加
```CustomEntryController.php
/**
 * @var PrefRepository
 */
private $prefRepository;    // 着信認証ASPのため追加
/**
 * EntryController constructor.
 * @param PrefRepository $prefRepository
 */
public function __construct(
        PrefRepository $prefRepository          // 着信認証ASPのため追加
) {
    ・
    略
    ・
    $this->prefRepository = $prefRepository;            // 着信認証ASPのため追加
}
```
★index関数を着信認証対応に変更
①実処理を行うかの判定if文を着信認証からの戻り対応に変更
```index関数 - CustomEntryController.php
	if ($form->isSubmitted() && $form->isValid()) {
	　↓
	if (($form->isSubmitted() && $form->isValid()) || ($request->get('secret'))) {	// 着信認証ASPからのPOST（パラメータsecret）を条件に追加
```
②switch内のmodeパラメータがconfirm（確認画面要求）の処理にて、
着信認証後に入力情報を復元するためセッションに入力情報を格納する処理を追加
```index関数 - CustomEntryController.php
	case 'confirm':
		log_info('会員登録確認開始');
		log_info('会員登録確認完了');

		//****************************
		// 着信認証前（会員登録確認画面表示時）に会員登録の入力情報をセッションに格納
		// この情報を着信認証終了後に復元して処理を続行する
		$CustomerDataArray = $Customer->toArray();
		$CustomerDataArray['CustomerFavoriteProducts'] = $Customer->getCustomerFavoriteProducts()->toArray();
		$CustomerDataArray['CustomerAddresses'] = $Customer->getCustomerAddresses()->toArray();
		$CustomerDataArray['Orders'] = $Customer->getOrders()->toArray();
		$CustomerDataArray['Status'] = $Customer->getStatus()->toArray();
		$CustomerDataArray['Pref'] = $Customer->getPref()->toArray();
		//$json = json_encode($CustomerDataArray);
		$this->session->set('sCustomer', $CustomerDataArray);
		$this->session->set('call_auth_proc', 1);
		//****************************

		return $this->render(
			'Entry/confirm.twig',
			[
				'form' => $form->createView(),
				'Page' => $this->pageRepository->getPageByRoute('entry_confirm'),
			]
		);
```

③switch内のmodeパラメータがcomplete（登録完了処理要求）の処理にて、
セッションに一時格納された入力情報を復元して処理を行う処理を追加
```index関数 - CustomEntryController.php
	case 'complete':
		log_info('会員登録開始');


		//**********************************************
		// 着信認証からの遷移
		if ($request->get('secret')) {
			// secret検証
			$secret = $request->get('secret');
			if ($secret != 'XXXXXXXXXXXXXX') {  // 着信認証ASPのサービスごとに振られたsecret（取り急ぎハードコーディング）
				// エラー遷移
				return $this->redirectToRoute('entry');
			}
			$call_auth_proc = $this->session->get('call_auth_proc');
			if (!$call_auth_proc) {
				// エラー遷移
				return $this->redirectToRoute('entry');
			}
			// 着信認証前（会員登録確認画面表示時）にセッションに保存した入力情報を復元
			$CustomerDataArray = $this->session->get('sCustomer');					  // セッションの入力情報を取得
			$Customer = $this->customerRepository->newCustomer();					  // 空のcustomerRepositoryを生成
			$Customer->setPropertiesFromArray($CustomerDataArray);					  // customerRepositoryにセッションから復元した入力情報を投入
			$CustomerStatus = $this->customerStatusRepository->find(1);
			$Customer->setStatus($CustomerStatus);									  // customerStatusRepositoryを設定
			$Pref = $this->prefRepository->find($CustomerDataArray['Pref']['id']);
			$Customer->setPref($Pref);												  // prefRepositoryを設定
		}
		//**********************************************
```
④ ①のif文にelse条件を追加（着信認証にて戻る操作が行われた際の処理）
```index関数 - CustomEntryController.php
	//**********************************************
	// 着信認証からの遷移（戻る/タイムアウト時用）処理
	} else if ($request->get('mode') == 'retry') {
		// 着信認証前（会員登録確認画面表示時）にセッションに保存した入力情報を復元し
		// 確認画面を表示
		log_info('会員登録確認（着信認証のリトライ）開始');
		$call_auth_proc = $this->session->get('call_auth_proc');
		$CustomerDataArray = $this->session->get('sCustomer');					  // セッションの入力情報を取得
		if ((!$CustomerDataArray) || (!$call_auth_proc)) {
			// エラー処理（最初の入力ページへリダイレクト）
			$this->session->set('call_auth_proc', 0);
			return $this->redirectToRoute('entry');
		}
		$Customer = $this->customerRepository->newCustomer();					  // 空のcustomerRepositoryを生成
		$Customer->setPropertiesFromArray($CustomerDataArray);					  // customerRepositoryにセッションから復元した入力情報を投入
		$CustomerStatus = $this->customerStatusRepository->find(1);
		$Customer->setStatus($CustomerStatus);									  // customerStatusRepositoryを設定
		$Pref = $this->prefRepository->find($CustomerDataArray['Pref']['id']);
		$Customer->setPref($Pref);												  // prefRepositoryを設定

		/* @var $builder \Symfony\Component\Form\FormBuilderInterface */
		$builder = $this->formFactory->createBuilder(EntryType::class, $Customer);

		$event = new EventArgs(
			[
				'builder' => $builder,
				'Customer' => $Customer,
			],
			$request
			);
		$this->eventDispatcher->dispatch(EccubeEvents::FRONT_ENTRY_INDEX_INITIALIZE, $event);

		/* @var $form \Symfony\Component\Form\FormInterface */
		$form = $builder->getForm();

		$form->handleRequest($request);

		return $this->render(
			'Entry/confirm.twig',
			[
				'form' => $form->createView(),
				'Page' => $this->pageRepository->getPageByRoute('entry_confirm'),
			]
		);
	//**********************************************
	}
```
編集後のindex関数は下のようになります。
```CustomEntryController.php
    /**
     * 会員登録画面.
     *
     * @Route("/entry", name="entry", methods={"GET", "POST"})
     * @Route("/entry", name="entry_confirm", methods={"GET", "POST"})
     * @Template("Entry/index.twig")
     */
    public function index(Request $request)
    {
        if ($this->isGranted('ROLE_USER')) {
            log_info('認証済のためログイン処理をスキップ');

            return $this->redirectToRoute('mypage');
        }

        /** @var $Customer \Eccube\Entity\Customer */
        $Customer = $this->customerRepository->newCustomer();

        /* @var $builder \Symfony\Component\Form\FormBuilderInterface */
        $builder = $this->formFactory->createBuilder(EntryType::class, $Customer);

        $event = new EventArgs(
            [
                'builder' => $builder,
                'Customer' => $Customer,
            ],
            $request
            );
        $this->eventDispatcher->dispatch(EccubeEvents::FRONT_ENTRY_INDEX_INITIALIZE, $event);

        /* @var $form \Symfony\Component\Form\FormInterface */
        $form = $builder->getForm();

        $form->handleRequest($request);

        //if ($form->isSubmitted() && $form->isValid()) {
        if (($form->isSubmitted() && $form->isValid()) || ($request->get('secret'))) {	// 着信認証ASPからのPOST（パラメータsecret）を条件に追加

            switch ($request->get('mode')) {
                case 'confirm':
                    log_info('会員登録確認開始');
                    log_info('会員登録確認完了');

                    //****************************
                    // 着信認証前（会員登録確認画面表示時）に会員登録の入力情報をセッションに格納
                    // この情報を着信認証終了後に復元して処理を続行する
                    $CustomerDataArray = $Customer->toArray();
                    $CustomerDataArray['CustomerFavoriteProducts'] = $Customer->getCustomerFavoriteProducts()->toArray();
                    $CustomerDataArray['CustomerAddresses'] = $Customer->getCustomerAddresses()->toArray();
                    $CustomerDataArray['Orders'] = $Customer->getOrders()->toArray();
                    $CustomerDataArray['Status'] = $Customer->getStatus()->toArray();
                    $CustomerDataArray['Pref'] = $Customer->getPref()->toArray();
                    //print_r($CustomerDataArray);
                    $this->session->set('sCustomer', $CustomerDataArray);
                    $this->session->set('call_auth_proc', 1);
                    //****************************

                    return $this->render(
                        'Entry/confirm.twig',
                        [
                            'form' => $form->createView(),
                            'Page' => $this->pageRepository->getPageByRoute('entry_confirm'),
                        ]
                        );

                case 'complete':
                    log_info('会員登録開始');


                    //**********************************************
                    // 着信認証からの遷移
                    if ($request->get('secret')) {
                        // secret検証
                        $secret = $request->get('secret');
                        if ($secret != 'xxxxxxxxyyyyyyzzzzz123456767') {  // 着信認証ASPのサービスごとに振られたsecret
                            // エラー遷移
                            return $this->redirectToRoute('entry');
                        }
                        $call_auth_proc = $this->session->get('call_auth_proc');
                        if (!$call_auth_proc) {
                            // エラー遷移
                            return $this->redirectToRoute('entry');
                        }
                        // 着信認証前（会員登録確認画面表示時）にセッションに保存した入力情報を復元
                        $CustomerDataArray = $this->session->get('sCustomer');					  // セッションの入力情報を取得
                        $Customer = $this->customerRepository->newCustomer();					  // 空のcustomerRepositoryを生成
                        $Customer->setPropertiesFromArray($CustomerDataArray);					  // customerRepositoryにセッションから復元した入力情報を投入
                        $CustomerStatus = $this->customerStatusRepository->find(1);
                        $Customer->setStatus($CustomerStatus);									  // customerStatusRepositoryを設定
                        $Pref = $this->prefRepository->find($CustomerDataArray['Pref']['id']);
                        $Customer->setPref($Pref);												  // prefRepositoryを設定
                        // セッションの後始末
                        $this->session->remove('sCustomer');
                        $this->session->remove('call_auth_proc');
                    }
                    //**********************************************

                    $encoder = $this->encoderFactory->getEncoder($Customer);
                    $salt = $encoder->createSalt();
                    $password = $encoder->encodePassword($Customer->getPassword(), $salt);
                    $secretKey = $this->customerRepository->getUniqueSecretKey();

                    $Customer
                    ->setSalt($salt)
                    ->setPassword($password)
                    ->setSecretKey($secretKey)
                    ->setPoint(0);

                    $this->entityManager->persist($Customer);
                    $this->entityManager->flush();

                    log_info('会員登録完了');

                    $event = new EventArgs(
                        [
                            'form' => $form,
                            'Customer' => $Customer,
                        ],
                        $request
                        );
                    $this->eventDispatcher->dispatch(EccubeEvents::FRONT_ENTRY_INDEX_COMPLETE, $event);

                    $activateFlg = $this->BaseInfo->isOptionCustomerActivate();

                    // 仮会員設定が有効な場合は、確認メールを送信し完了画面表示.
                    if ($activateFlg) {
                        $activateUrl = $this->generateUrl('entry_activate', ['secret_key' => $Customer->getSecretKey()], UrlGeneratorInterface::ABSOLUTE_URL);

                        // メール送信
                        $this->mailService->sendCustomerConfirmMail($Customer, $activateUrl);

                        if ($event->hasResponse()) {
                            return $event->getResponse();
                        }

                        log_info('仮会員登録完了画面へリダイレクト');

                        return $this->redirectToRoute('entry_complete');
                    } else {
                        // 仮会員設定が無効な場合は、会員登録を完了させる.
                        $qtyInCart = $this->entryActivate($request, $Customer->getSecretKey());

                        // URLを変更するため完了画面にリダイレクト
                        return $this->redirectToRoute('entry_activate', [
                            'secret_key' => $Customer->getSecretKey(),
                            'qtyInCart' => $qtyInCart,
                        ]);
                    }
            }
        } else if ($request->get('mode') == 'retry') {
            //**********************************************
            // 着信認証からの遷移（戻る/タイムアウト時用）処理
            // 着信認証前（会員登録確認画面表示時）にセッションに保存した入力情報を復元し
            // 確認画面を表示
            log_info('会員登録確認（着信認証のリトライ）開始');
            $call_auth_proc = $this->session->get('call_auth_proc');
            $CustomerDataArray = $this->session->get('sCustomer');					  // セッションの入力情報を取得
            if ((!$CustomerDataArray) || (!$call_auth_proc)) {
                // エラー処理（最初の入力ページへリダイレクト）
                $this->session->set('call_auth_proc', 0);
                return $this->redirectToRoute('entry');
            }
            $Customer = $this->customerRepository->newCustomer();					  // 空のcustomerRepositoryを生成
            $Customer->setPropertiesFromArray($CustomerDataArray);					  // customerRepositoryにセッションから復元した入力情報を投入
            $CustomerStatus = $this->customerStatusRepository->find(1);
            $Customer->setStatus($CustomerStatus);									  // customerStatusRepositoryを設定
            $Pref = $this->prefRepository->find($CustomerDataArray['Pref']['id']);
            $Customer->setPref($Pref);												  // prefRepositoryを設定

            /* @var $builder \Symfony\Component\Form\FormBuilderInterface */
            $builder = $this->formFactory->createBuilder(EntryType::class, $Customer);

            $event = new EventArgs(
                [
                    'builder' => $builder,
                    'Customer' => $Customer,
                ],
                $request
                );
            $this->eventDispatcher->dispatch(EccubeEvents::FRONT_ENTRY_INDEX_INITIALIZE, $event);

            /* @var $form \Symfony\Component\Form\FormInterface */
            $form = $builder->getForm();

            $form->handleRequest($request);
            // セッションの後始末
            $this->session->remove('sCustomer');
            $this->session->remove('call_auth_proc');

            return $this->render(
                'Entry/confirm.twig',
                [
                    'form' => $form->createView(),
                    'Page' => $this->pageRepository->getPageByRoute('entry_confirm'),
                ]
                );
            //**********************************************
        }

        return [
            'form' => $form->createView(),
        ];
    }

```

# 動作確認
長くなりましたが、上の実装で会員登録の確認画面でのボタン押下で着信認証画面へ遷移しました。
<img width="700" alt="動作確認1" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745216/43ff4171-7e51-6e27-1a00-37b4f091c272.png">

表示された番号へ発信すると、会員登録完了へ遷移します。
<img width="600" alt="動作確認2" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745216/ac715a6c-28e3-3873-a6d9-ae093bd8d759.png">
以上で会員登録の際に着信認証を行い、入力された電話番号が正しい事が認証出来ました。

# 効果
今回は新規会員登録に実装を行いましたが、2要素認証が一般的になりつつある中、
・ログイン
・一定額以上の購入手続き
・会員情報変更
などにも使用することで不正な利用を制限しつつ、利用者にとってもストレスのない本人確認が実現します。

# 最後に
###### ★EC-CUBEとは
株式会社イーシーキューブが開発したオープンソース型ECサイト構築パッケージです。
<img width="300" alt="イーシーキューブ" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745216/7d338ecc-15ef-104b-e87c-303ab6fd2d83.png">

###### ★着信認証とは
[株式会社オスティアリーズ](https://ostiaries.co.jp)が提供する電話番号を利用した認証サービスです。
<img width="300" alt="着信認証" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1745216/c0402d4a-a511-1326-07c0-8f9c43d775a2.png">

お問い合わせ先は
https://ostiaries.co.jp/contact.php
までお願いします。