2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

EC-CUBE4 SMS認証 ログイン

Last updated at Posted at 2021-09-16

はじめに

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

会員登録のカスタマイズ

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

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

上記全て FormExtensionを使った拡張 で行います。

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()));
        });
    }
}

画面の準備

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

  • 会員登録画面のパスワード項目を削除
  • 電話番号入力画面の準備
  • セキュリティーコード入力画面の準備
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>
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];
    }
}
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 %}
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 の変更

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

  • 専用のユーザープロバイダーの用意
  • 専用の認証クラスの用意
  • ユーザー画面の認証設定変更
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);
    }
}
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);
    }
}
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
2
2
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
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?