BeginnerEngineerBlog
中の人
中の人

ECCUBE4.1(symfony4系)で2段階認証を実装する

公開: 2021-11-07 21:56
更新: 2023-04-06 14:57
2666
ECCUBE4.1 symfony4.x php 2段階認証
ECCUBE4.1系でCustomerのログインで2段階認証を実装する紹介になります。

こんにちは!

中の人です。

前回 ECCUBE4.1(symfony4系)でパスワードがあっているかだけ判定したい でpasswordのみあっているか判定する記事を書きましたが、今回はこれを利用してCustomerのログイン処理を2段階認証にするやり方を紹介したいと思います!

symfony: 4.4.26
ECCUBE: 4.1.0


仕様


2段階認証といってもいくつか方法があると思いますが、今回の実装例では一番シンプルな以下の方法で認証をしたいと思います。

  1. メールアドレス、パスワードを入力
  2. 入力された内容が正しければ、ランダム文字列が記載されたメールを送信
  3. メールに記載されたランダム文字列を入力して、正しければログイン

という仕様にしたいと思います。

また、ECCUBEでのカスタマイズの仕方はEC-CUBE 4.0 開発者向けドキュメント を参考にしてください。

ということでやっていきましょー!

(20220419追記
有効時間を設定したい場合はこちら のやりとりの中のNo.10010あたりから参考にしてください。
※ 一発で実装できてないので、過程を含めて参考にしてください。
)

CustomerLoginTypeを拡張する


ということで、まず最初にFormTypeを拡張します。

今回はヘッダーに表示されているログインでのログイン処理を2段階認証にしたいと思います。
(eccubeではカートから先に遷移する際に、もう一つ別のログイン処理が実行されます。今回こっちは何もしないので、こっちも2段階認証にしたい場合はこの記事を参考に同じように実装してみてください。)

では、以下のようにCutomerLoginTypeを拡張してください。

📁 ec-cube/app/Customize/Form/Extension/Front/CustomerLoginTypeExtension.php
<?php

namespace Customize\Form\Extension\Front;

use Eccube\Form\Type\Front\CustomerLoginType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\Extension\Core\Type\TextType;

class CustomerLoginTypeExtension extends AbstractTypeExtension
{
    /**
     * {@inheritdoc}
     */
    public static function getExtendedTypes()
    {
        return [CustomerLoginType::class];
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add(
            'two_factor_token',
            TextType::class,
            [
                'mapped' => false,
            ]
        );
    }
}

CustomerLoginTypeに「two_factor_token」フォームを追加します。
これはトークン入力用のフォームになります。

Controllerを作成する


そしたら、コントローラを新規に作成して、今回の記事のコアとなるアクションを定義していきます。
私はLoginController.phpというファイルを作成しました。

で、前提として、このコントローラはajax通信で実行します。
(20211116追記
ajax通信で実行するアクションはcheckCustomerPasswordアクションだけでした。
)

📁 ec-cube/app/Customize/Controller/Mypage/LoginController.php

<?php

namespace Customize\Controller\Mypage;

use Eccube\Controller\AbstractController;
use Eccube\Entity\Customer;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Eccube\Repository\CustomerRepository;
use Eccube\Repository\Master\CustomerStatusRepository;
use Eccube\Entity\Master\CustomerStatus;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;
use Eccube\Repository\BaseInfoRepository;
use Symfony\Component\Validator\Validator\ValidatorInterface;
use Symfony\Component\Validator\Constraints as Assert;


class LoginController extends AbstractController
{
    private $customerRepository;

    private $customerStatusRepository;

    private $encoderFactory;

    private $mailer;

    private $baseInfoRepository;

    private $validator;

    public function __construct(
        CustomerRepository $customerRepository,
        CustomerStatusRepository $customerStatusRepository,
        EncoderFactoryInterface $encoderFactory,
        \Swift_Mailer $mailer,
        BaseInfoRepository $baseInfoRepository,
        ValidatorInterface $validator
    )
    {
        $this->customerRepository = $customerRepository;
        $this->customerStatusRepository = $customerStatusRepository;
        $this->encoderFactory = $encoderFactory;
        $this->mailer = $mailer;
        $this->baseInfoRepository = $baseInfoRepository;
        $this->validator = $validator;
    }

    /**
     * @param Request $request
     * @Route("/check/customer/password", name="check_customer_password", methods={"POST"})
     */
    public function checkCustomerPassword(Request $request)
    {
        if (!$request->isXmlHttpRequest()) {
            return $this->json(['status' => 'NG'], 400);
        }
        $this->isTokenValid();

        $login_email = $request->get('login_email');
        $login_pass = $request->get('login_pass');

        if (!isset($login_email, $login_pass)) {
            return $this->json(['status' => 'NG'], 500);
        }

        $customer = $this->customerRepository->findOneBy(
            [
                'email' => $login_email,
                'Status' => $this->customerStatusRepository->find(CustomerStatus::REGULAR),
            ]
        );

        if (!$customer instanceof Customer) {
            return $this->json(['status' => 'NG'], 500);
        }

        $encoder = $this->encoderFactory->getEncoder($customer);

        if ($encoder->isPasswordValid($customer->getPassword(), $login_pass, $customer->getSalt())) {

            $token = mt_rand(100000,999999);

            $base_info = $this->baseInfoRepository->get();

            $message = (new \Swift_Message())
                ->setSubject('トークンの送信')
                ->setFrom([$base_info->getEmail01() => $base_info->getShopName()])
                ->setTo([$customer->getEmail()])
                ->setBody($token);

            $this->session->set('login_token', $token);

            $count = $this->mailer->send($message, $failures);

            return $this->json(['status' => 'OK']);
        }

        return $this->json(['status' => 'NG'], 500);
    }

    /**
     * @param Request $request
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     * @Route("/check/customer/token", name="check_customer_token", methods={"POST"})
     */
    public function login(Request $request)
    {
        $token = $request->get('two_factor_token');
        $errors = $this->validator->validate(
            $token,
            [
                new Assert\NotBlank(),
                new Assert\Type(['type' => 'numeric']),
            ]
        );

        if ($errors->count() === 0 && $token === (string)$this->session->get('login_token')) {
            $this->session->remove('login_token');
            return $this->redirectToRoute(
                'mypage_login',
                [
                    'request' => $request
                ],
                307
            );
        }
        $this->session->remove('login_token');
        $this->session->getFlashBag()->set('bad_flash', 'ばーかちゃんとしろばか');
        return $this->redirectToRoute(
            'mypage_login',
            [
                'request' => $request,
            ]
        );
    }
}

ということで、各アクションについて説明します。

まず、checkCustomerPasswordアクションで、emailとpasswordの組み合わせがあっているか確認します。
どんな処理をしているかはコメントを確認してください。
で、今回はセッションにトークンを保存する形にしていますが、customerテーブルにトークンフィールドを追加してデータベースにトークンを保存する形にしてもいいと思います。(というか、こちらの方がより確実です。)

    /**
     * @param Request $request
     * @Route("/check/customer/password", name="check_customer_password", methods={"POST"})
     */
    public function checkCustomerPassword(Request $request)
    {
        // ajaxの通信かバリデート
        if (!$request->isXmlHttpRequest()) {
            return $this->json(['status' => 'NG'], 400);
        }
        // csrf_tokenのバリデート
        $this->isTokenValid();

        // requestから入力されたemailとpasswordを取得
        $login_email = $request->get('login_email');
        $login_pass = $request->get('login_pass');

        // emailとpasswordどちらもparameterがあるかのばりデート
        if (!isset($login_email, $login_pass)) {
            return $this->json(['status' => 'NG'], 500);
        }

        // emailから本会員登録されているcustomerがいるか検索
        $customer = $this->customerRepository->findOneBy(
            [
                'email' => $login_email,
                'Status' => $this->customerStatusRepository->find(CustomerStatus::REGULAR),
            ]
        );

        // customerがいるかのバリデート
        if (!$customer instanceof Customer) {
            return $this->json(['status' => 'NG'], 500);
        }

        // encoderなるものを取得
        $encoder = $this->encoderFactory->getEncoder($customer);

        // customerのpasswordがあっているか判定
        if ($encoder->isPasswordValid($customer->getPassword(), $login_pass, $customer->getSalt())) {

            // tokenを生成
            $token = mt_rand(100000,999999);

            // eccubeの設定を取得
            $base_info = $this->baseInfoRepository->get();

            // mailerを設定
            $message = (new \Swift_Message())
                ->setSubject('トークンの送信')
                ->setFrom([$base_info->getEmail01() => $base_info->getShopName()])
                ->setTo([$customer->getEmail()])

                // mailの本文に生成したtokenをセット
                ->setBody($token);

            // sessionにtokenをセット
            $this->session->set('login_token', $token);

            // mail送信
            $count = $this->mailer->send($message, $failures);

            // 成功ステータスを返す
            return $this->json(['status' => 'OK']);
        }

        return $this->json(['status' => 'NG'], 500);
    }

次に、トークンがあっているか確認して、あっていればログイン処理をします。

    /**
     * @param Request $request
     * @return \Symfony\Component\HttpFoundation\RedirectResponse
     * @Route("/check/customer/token", name="check_customer_token", methods={"POST"})
     */
    public function login(Request $request)
    {
        // トークンを取得
        $token = $request->get('two_factor_token');
        // 必須,数字バリデーション
        $errors = $this->validator->validate(
            $token,
            [
                new Assert\NotBlank(),
                new Assert\Type(['type' => 'numeric']),
            ]
        );

        // バリデーションを突破かつ送信されたトークンとセッションのトークンが同じなら
        if ($errors->count() === 0 && $token === (string)$this->session->get('login_token')) {
            // もうセッションのトークンは不要なので削除する
            $this->session->remove('login_token');
            // デフォルトのログインアクションにpostとしてリダイレクト
            return $this->redirectToRoute(
                'mypage_login',
                [
                    'request' => $request
                ],
                307 // <- こうするとpostでリダイレクトできる
            );
        }
        // ダメだったら、セッションのトークンを削除
        $this->session->remove('login_token');
        // 適当にダメでしたフラッシュをセット
        $this->session->getFlashBag()->set('bad_flash', 'ばーかちゃんとしろばか');
        // デフォルトのログインアクションにリダイレクト
        return $this->redirectToRoute(
            'mypage_login',
            [
                'request' => $request,
            ]
        );
    }

ここではトークンのチェックのみしています。
リダイレクトで、デフォルトで実装されているemailとpasswordのログイン処理に飛ばしているので、emailとpasswordはそっちに任せる感じです。

ということでコントローラは以上です!


Templateを拡張する


そしたら、login.twigを拡張します。

📁 ec-cube/app/template/default/Mypage/login.twig

{#
This file is part of EC-CUBE

Copyright(c) EC-CUBE CO.,LTD. All Rights Reserved.

http://www.ec-cube.co.jp/

For the full copyright and license information, please view the LICENSE
file that was distributed with this source code.
#}
{% extends 'default_frame.twig' %}

{% set body_class = 'mypage' %}

{% block main %}
    {# jsを追加 #}
    <script>
        $(function () {
            {# トークン送信ボタンを押したら #}
            $('#send_token').on('click', function () {
                {# emailとpasswordのフォームの値を取得 #}
                let login_email_value = $('#login_email').val();
                let login_pass_value = $('#login_pass').val();
                {# 値があったら #}
                if (login_email_value.length && login_pass_value.length) {
                    {# ajaxでpassword判定アクションにリクエストを送信 #}
                    $.ajax({
                        url: '{{ url('check_customer_password') }}',
                        type: 'POST',
                        data: {
                            'login_email': login_email_value,
                            'login_pass': login_pass_value,
                        },
                        dataType: 'json',
                    }).done(function (data) {
                        {# 成功したらトークンの入力を促す #}
                        $('.ec-icon').after(`<span style="color: green">トークンプリーズ</span>`);
                    }).fail(function (data) {
                        {# ダメだったら煽る #}
                        $('.ec-icon').after(`<span style="color: red">ばーかばーか帰ればか</span>`);
                    });
                }
            });
        });
    </script>
    <div class="ec-role">
        {# ログイン失敗時のフラッシュがあれば表示 #}
        {% if app.session.flashBag.has('bad_flash') %}
            <div class="alert alert-danger" role="alert">
                {% for message in app.session.flashBag.get('bad_flash') %}
                    {{ message }}
                {% endfor %}
            </div>
        {% endif %}
        <div class="ec-pageHeader">
            <h1>{{ 'ログイン'|trans }}</h1>
        </div>
        <div class="ec-off2Grid">
            <div class="ec-off2Grid__cell">
                {# 送信先を変更 #}
                <form name="login_mypage" id="login_mypage" method="post" action="{{ url('check_customer_token') }}">
                    {% if app.session.flashBag.has('eccube.login.target.path') %}
                        {% for targetPath in app.session.flashBag.peek('eccube.login.target.path') %}
                            <input type="hidden" name="_target_path" value="{{ targetPath }}" />
                        {% endfor %}
                    {% endif %}
                    <div class="ec-login">
                        <div class="ec-login__icon">
                            <div class="ec-icon"><img src="{{ asset('assets/icon/user.svg') }}" alt=""></div>
                        </div>
                        <div class="ec-login__input">
                            <div class="ec-input">
                                {{ form_widget(form.login_email, {'attr': {'style' : 'ime-mode: disabled;', 'placeholder' : 'メールアドレス', 'autofocus': true}}) }}
                                {{ form_widget(form.login_pass,  {'attr': {'placeholder' : 'パスワード' }}) }}
                                {# トークンフォーム追加 #}
                                {{ form_widget(form.two_factor_token,  {'attr': {'placeholder' : 'トークン' }}) }}
                            </div>
                            {% if BaseInfo.option_remember_me %}
                                <div class="ec-checkbox">
                                    <label>
                                        {% if is_granted('IS_AUTHENTICATED_REMEMBERED') %}
                                            <input type="hidden" name="login_memory" value="1">
                                        {% else %}
                                            {{ form_widget(form.login_memory, { 'label': '次回から自動的にログインする'|trans }) }}
                                        {% endif %}
                                    </label>
                                </div>
                            {% endif %}
                            {% for reset_complete in app.session.flashbag.get('password_reset_complete') %}
                                <p>{{ reset_complete|trans }}</p>
                            {% endfor %}
                            {% if error %}
                                <p class="ec-errorMessage">{{ error.messageKey|trans(error.messageData, 'validators')|nl2br }}</p>
                            {% endif %}
                        </div>
                        <div class="ec-grid2">
                            <div class="ec-grid2__cell">
                                <div class="ec-login__actions">
                                    <button type="submit"
                                            class="ec-blockBtn--cancel">{{ 'ログイン'|trans }}</button>
                                    {# トークン送信ボタン追加 #}
                                    <button type="button" class="ec-blockBtn--cancel" id="send_token">トークン送信</button>
                                </div>
                            </div>
                            <div class="ec-grid2__cell">
                                <div class="ec-login__link"><a class="ec-link"
                                                               href="{{ url('forgot') }}">{{ 'ログイン情報をお忘れですか?'|trans }}</a>
                                </div>
                                <div class="ec-login__link"><a class="ec-link"
                                                               href="{{ url('entry') }}">{{ '新規会員登録'|trans }}</a>
                                </div>
                            </div>
                        </div>
                    </div>
                    <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
                </form>
            </div>
        </div>
    </div>
{% endblock %}


デフォルトから変更している部分はコメント入れているので、確認してください。

あと、jsの動きは最小限になっているので、ご自身が思う挙動に合わせて編集してください。

ということで、ファイルの作成と拡張は以上となりますので、実際の挙動を見ていきましょう!


実際の挙動



ログイン画面に遷移


拡張したトークン入力フォームと、ログインボタンは本来、トークン送信ボタンを押してオッケーだった場合に表示するのがベストですが、今回はめんどくさかったのでそのまま表示しています。
この辺はご自身で調整してみてください!

で、あらかじめ会員登録したcustomerのemailとpasswordを入力してトークン送信ボタンをクリック

オッケーだったらメールを確認



メール送信されたトークンをトークンフォームに入力してログインボタンをクリック




ログイン状態になりましたね!

ということで無事に2段階認証の実装できました。よかったよかった。

終わりに


デフォルトの実装で、adminルートで2段階認証が実装されていますが、customerルートは

  • ログインしていなくてもアクセスできるルート
  • ログインしていないとアクセスできないルート

の2種類があるため、デフォルトで実装されている2段階認証ではハンドリングが面倒なのでこのような実装をしました。

今回紹介した方法であれば、ログイン処理だけ2段階認証にすることができるので、個人的にはシンプルでいいんじゃないかなーとは思っています。

この辺は実装する内容に合わせて選択するのがいいでしょう。

ということで今回の記事は終わります!

お疲れ様でした!


0
0
0
0
通信エラーが発生しました。
似たような記事