EC-CUBE4 SMS認証 ログイン

## はじめに

Symfonyベースになったことで、Symfony の拡張方法が分かれば、EC-CUBE4 の拡張ができるのは嬉しいですね。
今回は電話番号でログインする方法についての説明です。

ちなみに、PHPユーザーですが、普段は Symfony,EC-CUBE4 は使っていないので、間違いあれば教えてください :smiley:

## 注意事項

* 認証のカスタマイズに重点をおいた内容です。
* SMSを送信する処理は記載していません。
* 本記事では `Customer` エンティティの `note` プロパティがセキュリティーコードと仮定していますが、本運用される場合は、専用のエンティティを用意して、セキュリティー的に十分なコードを発行してください。※ コード中に「 `todo:` 」で記載しています

## ファイル

**新規ファイル**

* `app/Customize/Controller/PhoneAuthController.php`
* `app/Customize/Form/Extension/Front/EntryTypeExtension.php`
* `app/Customize/Security/Authenticator/SecurityCodeAuthenticator.php`
* `app/Customize/Security/Provider/CustomerProvider.php`
* `app/template/my-theme/phone_auth.twig`
* `app/template/my-theme/phone_auth_login.twig`

**変更ファイル**

* `app/template/my-theme/Entry/index.twig`
* `app/config/eccube/packages/security.yaml`

## 全体像

U...ユーザー
E...EC-CUBE

1. U:ログイン画面で電話番号を入力、サブミット
1. E:電話番号の有無チェック、セキュリティーコードを電話番号宛てにSMS送信
1. U:セキュリティーコード入力画面でコードを入力、サブミット
1. E:認証を行い、問題なければログイン

## 会員登録のカスタマイズ

この項目では以下の対応を行います。

* 会員登録時に電話番号の一意チェック
* 会員登録時のパスワードを必須項目から除外
* 会員登録時にダミーパスワードを設定

上記全て [FormExtensionを使った拡張](https://doc4.ec-cube.net/customize_formtype) で行います。

```php:app/Customize/Form/Extension/Front/EntryTypeExtension.php
<?php

declare(strict_types=1);

namespace Customize\Form\Extension\Front;

use Eccube\Entity\Customer;
use Eccube\Form\Type\Front\EntryType;
use Eccube\Repository\CustomerRepository;
use Eccube\Util\StringUtil;
use Symfony\Component\Form\AbstractTypeExtension;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormError;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
use Symfony\Component\Security\Core\Encoder\EncoderFactoryInterface;

class EntryTypeExtension extends AbstractTypeExtension
{
    /** @var CustomerRepository */
    private $customerRepository;

    /** @var EncoderFactoryInterface */
    private $encoderFactory;

    public function __construct(CustomerRepository $customerRepository, EncoderFactoryInterface $encoderFactory)
    {
        $this->customerRepository = $customerRepository;
        $this->encoderFactory = $encoderFactory;
    }

    public function getExtendedType(): string
    {
        return EntryType::class;
    }

    public function getExtendedTypes(): iterable
    {
        return [EntryType::class];
    }

    public function buildForm(FormBuilderInterface $builder, array $options): void
    {
        $builder->remove('password');

        $customerRepository = $this->customerRepository;
        $builder->addEventListener(FormEvents::POST_SUBMIT, function (FormEvent $event) use ($customerRepository) {
            $form = $event->getForm();
            $customer = $form->getData();
            assert($customer instanceof Customer);
            $anotherCustomer = $customerRepository->findOneBy(['phone_number' => $customer->getPhoneNumber()]);
            if ($anotherCustomer !== null) {
                $form['phone_number']->addError(new FormError(trans('error'))); // trans_id ご自由に
            }

            $password = StringUtil::random();
            $encoder = $this->encoderFactory->getEncoder($customer);
            $customer->setPassword($encoder->encodePassword($password, $customer->getSalt()));
        });
    }
}
```

## 画面の準備

この項目では以下の対応を行います。

* 会員登録画面のパスワード項目を削除
* 電話番号入力画面の準備
* セキュリティーコード入力画面の準備

```diff_twig:app/template/my-theme/Entry/index.twig
-                         <dl>
-                             <dt>
-                                 {{ form_label(form.password, 'common.password', { 'label_attr': {'class': 'ec-label' }}) }}
-                             </dt>
-                             <dd>
-                                 <div class="ec-input{{ has_errors(form.password.first) ? ' error' }}">
-                                     {{ form_widget(form.password.first, {
-                                         'attr': { 'placeholder': 'common.password_sample'|trans({ '%min%': eccube_config.eccube_password_min_len, '%max%': eccube_config.eccube_password_max_len }) },
-                                         'type': 'password'
-                                     }) }}
-                                     {{ form_errors(form.password.first) }}
-                                 </div>
-                                 <div class="ec-input{{ has_errors(form.password.second) ? ' error' }}">
-                                     {{ form_widget(form.password.second, {
-                                         'attr': { 'placeholder': 'common.repeated_confirm'|trans },
-                                         'type': 'password'
-                                     }) }}
-                                     {{ form_errors(form.password.second) }}
-                                 </div>
-                             </dd>
-                         </dl>
```

```php:app/Customize/Controller/PhoneAuthController.php
<?php

declare(strict_types=1);

namespace Customize\Controller;

use Customize\Security\Provider\CustomerProvider;
use Eccube\Controller\AbstractController;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Template;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;

class PhoneAuthController extends AbstractController
{
    /** @var CustomerProvider */
    private $customerProvider;

    public function __construct(CustomerProvider $customerProvider)
    {
        $this->customerProvider = $customerProvider;
    }

    /**
     * @Route("/phone-auth", name="phone-auth", methods={"GET","POST"})
     * @Template("phone_auth.twig")
     *
     * @return RedirectResponse|array{}
     */
    public function index(Request $request)
    {
        if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
            return $this->redirectToRoute('mypage');
        }

        if ($request->isMethod('POST')) {
            $session = $request->getSession();
            if ($session === null) {
                return $this->redirectToRoute('phone-auth');
            }

            $phoneNumber = $request->get('phoneNumber');
            if ($phoneNumber === null || $phoneNumber === '') {
                return $this->redirectToRoute('phone-auth');
            }

            $customer = null;
            try {
                $customer = $this->customerProvider->loadUserByUsername($phoneNumber);
            } catch (UsernameNotFoundException $exception) {
            }

            if ($customer === null) {
                return $this->redirectToRoute('phone-auth');
            }

            // todo: 1.セキュリティーコードの発行&保存
            // todo: 2.セキュリティーコードのSMS送信

            $session->set('phoneNumber', $phoneNumber);
            return $this->redirectToRoute('phone-auth-login');
        }

        return [];
    }

    /**
     * @Route("/phone-auth/login", name="phone-auth-login", methods={"GET","POST"})
     * @Template("phone_auth_login.twig")
     *
     * @return RedirectResponse|array{}
     */
    public function login(Request $request)
    {
        if ($this->isGranted('IS_AUTHENTICATED_FULLY')) {
            return $this->redirectToRoute('mypage');
        }

        $session = $request->getSession();
        if ($session === null) {
            return $this->redirectToRoute('phone-auth');
        }

        $phoneNumber = $session->get('phoneNumber');
        if ($phoneNumber === null || $phoneNumber === '') {
            return $this->redirectToRoute('phone-auth');
        }

        return ['phoneNumber' => $phoneNumber];
    }
}
```

```twig:app/template/my-theme/phone_auth.twig
{% extends 'default_frame.twig' %}

{% block main %}
    <form method="POST">
        <label for="phoneNumber">PhoneNumber:</label>
        <input type="text" id="phoneNumber" name="phoneNumber" value=""/>

        <button type="submit">send</button>
    </form>
{% endblock %}
```

```twig:app/template/my-theme/phone_auth_login.twig
{% extends 'default_frame.twig' %}

{% block main %}
    <form method="POST">
        <label for="securityCode">SecurityCode: </label>
        <input type="password" id="securityCode" name="securityCode" value="" />

        <input type="hidden" name="phoneNumber" value="{{ phoneNumber }}" />
        <input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">

        <button type="submit">login</button>
    </form>
{% endblock %}
```

## Symfony Security Component の変更

この項目では以下の対応を行います。

* 専用のユーザープロバイダーの用意
* 専用の認証クラスの用意
* ユーザー画面の認証設定変更

```php:app/Customize/Security/Provider/CustomerProvider.php
<?php

declare(strict_types=1);

namespace Customize\Security\Provider;

use Eccube\Entity\Customer;
use Eccube\Entity\Master\CustomerStatus;
use Eccube\Repository\CustomerRepository;
use Symfony\Component\Security\Core\Exception\UnsupportedUserException;
use Symfony\Component\Security\Core\Exception\UsernameNotFoundException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;

class CustomerProvider implements UserProviderInterface
{
    /** @var CustomerRepository */
    protected $customerRepository;

    public function __construct(CustomerRepository $customerRepository)
    {
        $this->customerRepository = $customerRepository;
    }

    public function loadUserByUsername($username)
    {
        $customer = $this->customerRepository->findOneBy([
            'phone_number' => $username,
            'Status' => CustomerStatus::REGULAR,
        ]);

        if (null === $customer) {
            throw new UsernameNotFoundException(sprintf('phone_number "%s" does not exist.', $username));
        }

        return $customer;
    }

    public function refreshUser(UserInterface $user)
    {
        if (!$user instanceof Customer) {
            throw new UnsupportedUserException(sprintf('Instances of "%s" are not supported.', get_class($user)));
        }

        return $this->loadUserByUsername($user->getPhoneNumber());
    }

    public function supportsClass($class)
    {
        return Customer::class === $class || is_subclass_of($class, Customer::class);
    }
}
```

```php:app/Customize/Security/Authenticator/SecurityCodeAuthenticator.php
<?php

declare(strict_types=1);

namespace Customize\Security\Authenticator;

use Eccube\Entity\Customer;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\RouterInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authentication\Token\UsernamePasswordToken;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

/**
 * @see https://symfony.com/doc/3.4/security/guard_authentication.html
 */
class SecurityCodeAuthenticator extends AbstractGuardAuthenticator
{
    /** @var RouterInterface */
    private $router;

    /** @var CsrfTokenManagerInterface */
    private $csrfTokenManager;

    private $message = 'please check inputs.'; // ご自由に

    public function __construct(RouterInterface $router, CsrfTokenManagerInterface $csrfTokenManager)
    {
        $this->router = $router;
        $this->csrfTokenManager = $csrfTokenManager;
    }

    public function supports(Request $request)
    {
        return 'phone-auth-login' === $request->attributes->get('_route') &&
               $request->isMethod('POST');
    }

    public function start(Request $request, AuthenticationException $authException = null)
    {
        return new RedirectResponse($this->router->generate('phone-auth'), Response::HTTP_TEMPORARY_REDIRECT);
    }

    /**
     * @return array{phoneNumber: string, securityCode: string}|null
     */
    public function getCredentials(Request $request): ?array
    {
        $csrfToken = $request->request->get('_csrf_token');
        if (false === $this->csrfTokenManager->isTokenValid(new CsrfToken('authenticate', $csrfToken))) {
            throw new InvalidCsrfTokenException('Invalid CSRF token.');
        }

        return [
            'phoneNumber' => $request->request->get('phoneNumber'),
            'securityCode' => $request->request->get('securityCode'),
        ];
    }

    public function getUser($credentials, UserProviderInterface $userProvider): ?UserInterface
    {
        if (! isset($credentials['phoneNumber'])) {
            return null;
        }

        $phoneNumber = $credentials['phoneNumber'];
        $user = $userProvider->loadUserByUsername($phoneNumber);
        if ($user !== null) {
            return $user;
        }

        throw new CustomUserMessageAuthenticationException($this->message);
    }

    public function checkCredentials($credentials, UserInterface $user): bool
    {
        assert($user instanceof Customer);
        $securityCode = $credentials['securityCode'];
        if ($securityCode === $user->getNote()) { // todo: 3.セキュリティーコードのチェック
            return true;
        }

        throw new CustomUserMessageAuthenticationException($this->message);
    }

    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        $session = $request->getSession();
        if ($session !== null) {
            $session->set(Security::AUTHENTICATION_ERROR, $exception);
        }

        return new RedirectResponse($this->router->generate('phone-auth'));
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        return new RedirectResponse($this->router->generate('homepage')); // ご自由に
    }

    public function supportsRememberMe()
    {
        return false; // ご自由に
    }

    public function createAuthenticatedToken(UserInterface $user, $providerKey)
    {
        if ($user instanceof Customer && $providerKey === 'customer') {
            return new UsernamePasswordToken($user, null, $providerKey, $user->getRoles());
        }

        return parent::createAuthenticatedToken($user, $providerKey);
    }
}
```

```diff_yaml:app/config/eccube/packages/security.yaml
+ #            form_login:
+ #                check_path: mypage_login
+ #                login_path: mypage_login
+ #                csrf_token_generator: security.csrf.token_manager
+ #                default_target_path: homepage
+ #                username_parameter: 'login_email'
+ #                password_parameter: 'login_pass'
+ #                use_forward: false
+ #                success_handler: eccube.security.success_handler
+ #                failure_handler: eccube.security.failure_handler
+            guard:
+                authenticators:
+                    - Customize\Security\Authenticator\SecurityCodeAuthenticator
```