はじめに
Symfonyベースになったことで、Symfony の拡張方法が分かれば、EC-CUBE4 の拡張ができるのは嬉しいですね。
今回は電話番号でログインする方法についての説明です。
ちなみに、PHPユーザーですが、普段は Symfony,EC-CUBE4 は使っていないので、間違いあれば教えてください
注意事項
- 認証のカスタマイズに重点をおいた内容です。
- 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
- U:ログイン画面で電話番号を入力、サブミット
- E:電話番号の有無チェック、セキュリティーコードを電話番号宛てにSMS送信
- U:セキュリティーコード入力画面でコードを入力、サブミット
- 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