EC-CUBE4でメールアドレス以外でログインする


今回は、EC-CUBEのフロント側 (ユーザがECサイトで買い物をする部分)のログインロジックを変更します。

#想定
EC-CUBE4(Symfony3.4)を使用します。
ある程度EC-CUBE4やSymfonyに触れたことがある方向けとして、SymfonyとEC CUBE4の基礎は省略します。

EC CUBE標準のフロントログインロジックは、登録されたメールアドレスを用いて行います。
ここでは、カラム`logid_id (ログインID)`を追加して、ログインIDでログインできるようにすることが目的です。

# DBの変更

##### Entityクラスの拡張
まずは、DBテーブルdtb_customerに`login_id`というカラムを追加しましょう。

SymfonyのDBロジックのDoctrineでサポートされている、Entityクラスに対するTraitクラスを使用してカラム追加を行います。
`Eccube\Entity\Customer`に対するCustomerTraitクラスを新規で作成しましょう。

```PHP:app/Customize/Entity/CustomerTrait.php
<?php

namespace Customize\Entity;

use Doctrine\ORM\Mapping as ORM;
use Eccube\Annotation as Eccube;
use Symfony\Component\Validator\Constraints as Assert;

/**
 * @Eccube\EntityExtension("Eccube\Entity\Customer")
 */
trait CustomerTrait
{
    /**
     * @ORM\Column(name="login_id", type="string", length=255, nullable=true, unique=true)
     */
    private $login_id;

    /**
     * Set login_id.
     *
     * @param string $login_id
     *
     * @return Customer
     */
    public function setLoginId($login_id)
    {
        $this->login_id = $login_id;

        return $this;
    }

    /**
     * Get login_id.
     *
     * @return string
     */
    public function getLoginId()
    {
        return $this->login_id;
    }
}
```

##### Entityクラスの記述をDBに反映する

作成後に、EC CUBEディレクトリの最も浅いところで下記のコマンドをそれぞれ実行しましょう。
Windows環境の方は、頭に`php `を付け足して実行してください。

```
$ bin/console eccube:generate:proxies
$ bin/console doctrine:schema:update --dump-sql --force
```
1つ目のコマンドは、Entityのproxyファイルを生成するコマンドです。
`app/proxy/entity`内に、対応するEntityのプロキシファイルが作成されれば成功です。
今回は、`app\proxy\entity\src\Eccube\Entity\Customer.php`が作成されます。
ファイルの中身は、`Eccube\Entity\Customer`の記述の中で、`Customize\Entitiy\CustomerTrait`が呼び出されるものとなります。

2つ目のコマンドでは、CustomerTraitにアノテーションで記載したSQLを実行する形となります。
下記の出力結果が表示されれば成功で、この場合dtb_customerにカラム追加が実行されます。
`nullable=true`とすれば`DEFAULT NULL`としてカラム追加できます。
`nullable=false`とすれば`NOT NULL`としてカラム追加できますが、`login_id`追加時に値はまだNULLとなっているので、この場合では実行時にエラーとなります。

下記が表示されれば成功です。

```
 The following SQL statements will be executed:

     ALTER TABLE dtb_customer ADD login_id VARCHAR(255) DEFAULT NULL;
     CREATE UNIQUE INDEX UNIQ_8298BBE35CB2E05D ON dtb_customer (login_id);

 Updating database schema...

     2 queries were executed


 [OK] Database schema updated successfully!
```

# ログインロジックの書き換え

##### security.yamlの変更

カラム追加ができたので次に移りましょう。

`app\config\eccube\packages\security.yaml`を書き換えて、ログインロジックに用いるクラスを変更します。

providersでは、ECサイトの管理画面側とフロント画面側のログインロジックで使用しているクラスが記述されています。
ここでは、フロント側で後ほど使用するクラスを呼び出すよう追記しましょう。

```YAML:app/config/eccube/packages/security.yaml　L1~L17に追加
    providers:
        # https://symfony.com/doc/current/security.html#b-configuring-how-users-are-loaded
        # In this example, users are stored via Doctrine in the database
        # To see the users at src/App/DataFixtures/ORM/LoadFixtures.php
        # To load users from somewhere else: https://symfony.com/doc/current/security/custom_provider.html
        member_provider:
            id: Eccube\Security\Core\User\MemberProvider
        customer_provider:
            # id: Eccube\Security\Core\User\CustomerProvider # 本来書いてあったロジック
            id: Customize\Security\Core\User\CustomerProviderCustomized # 新たに呼び出すクラスを記述
```

次に、ログイン時に`login_id`を使用することを明示化します。
動作上は`login_email`でも支障ないのですが、書き換えましょう。

```YAML:app/config/eccube/packages/security.yaml　L49~L59に追加
            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' login_emailは使わずに、login_idを使用する
                username_parameter: 'login_id'
                password_parameter: 'login_pass'
                use_forward: false
                success_handler: eccube.security.success_handler
                failure_handler: eccube.security.failure_handler
```


##### ログインロジッククラスの拡張

次に、フロント側で使用するログインロジッククラスを作成します。

```PHP:app\Customize\Security\Core\User\CustomerProviderCustomized.php
<?php

namespace Customize\Security\Core\User;

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 CustomerProviderCustomized implements UserProviderInterface
{
    /**
     * @var CustomerRepository
     */
    protected $customerRepository;

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

    /**
     * Loads the user for the given username.
     *
     * This method must throw UsernameNotFoundException if the user is not
     * found.
     *
     * @param string $username The username
     *
     * @return UserInterface
     *
     * @throws UsernameNotFoundException if the user is not found
     */
    public function loadUserByUsername($username)
    {
        $Customer = $this->customerRepository->findOneBy([
            'login_id' => $username,
            'Status' => CustomerStatus::REGULAR,
        ]);

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

        return $Customer;
    }

    /**
     * Refreshes the user.
     *
     * It is up to the implementation to decide if the user data should be
     * totally reloaded (e.g. from the database), or if the UserInterface
     * object can just be merged into some internal array of users / identity
     * map.
     *
     * @return UserInterface
     *
     * @throws UnsupportedUserException if the user is not supported
     */
    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->getLoginId());
    }

    /**
     * Whether this provider supports the given user class.
     *
     * @param string $class
     *
     * @return bool
     */
    public function supportsClass($class)
    {
        return Customer::class === $class;
    }
}

```

ここでは、`Eccube\Security\Core\User\CustomerProvider`を踏襲しつつ、メソッド2つ(`loadUserByUsername`と`refreshUser`)
を変更しています。
`CustomerProvider`を踏襲・オーバーライドして記述を少なくすることも可能ですが、今回は前述のように`UserProviderInterface`をimplementsで拡張する方式となっております。

メソッド`loadUserByUsername`は、主にログイン時に使用するメソッドになります。
引数で受け取る`$username`に対し、`dtb_customer.login_id`を検索するように書き換えました。

メソッド`refreshUser`は、画面ロード時等に最新のユーザ情報をDBから取得します。
EC CUBEのデフォルトですと`$this->loadUserByUsername($user->getUsername())`でメールアドレスを引数とするので、こちらもログインIDを使用するように書き換えました。


# ログインテンプレートの書き換え


##### FormTypeの拡張
EC CUBEのMYページのログイン画面のFormTypeを書き換えます。

`security.yaml`を変更したのと同様に、`login_email`を削除して`login_id`を追加します。

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

/*
 * 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.
 */

namespace Customize\Form\Extension\Front;

use Eccube\Common\EccubeConfig;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Form\AbstractTypeExtension;
use Eccube\Form\Type\Front\CustomerLoginType;

class CustomerLoginTypeExtension extends AbstractTypeExtension
{
    /**
     * @var EccubeConfig
     */
    protected $eccubeConfig;

    /**
     * @var AuthenticationUtils
     */
    protected $authenticationUtils;

    public function __construct(AuthenticationUtils $authenticationUtils, EccubeConfig $eccubeConfig)
    {
        $this->authenticationUtils = $authenticationUtils;
        $this->eccubeConfig = $eccubeConfig;
    }

    /**
     * {@inheritdoc}
     */
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->remove('login_email')
            ->add('login_id', TextType::class, [
                'attr' => [
                    'max_length' => $this->eccubeConfig['eccube_stext_len'],
                ],
                'constraints' => [
                    new Assert\NotBlank(),
                ],
                'data' => $this->authenticationUtils->getLastUsername(),
            ]);
    }

    /**
     * {@inheritdoc}
     */
    public function getExtendedType()
    {
        return CustomerLoginType::class;
    }
}
```

最大文字数と必須入力を指定し、`'data' => $this->authenticationUtils->getLastUsername()`でログインエラー時に入力されたログインIDを返すようにしています。

##### Twigテンプレートの拡張

次にMYページのログイン画面のTwigテンプレートを拡張しましょう。
`src/Eccube/Resource/template/default/Mypage/login.twig` を`app/template/default/Mypage/`以下にコピーすることで、`app`以下のものが反映されるようになります。

```php:app/template/default/Mypage/login.twig
{% block main %}
    <div class="ec-role">
        <div class="ec-pageHeader">
            <h1>{{ 'common.login'|trans }}</h1>
        </div>
        <div class="ec-off2Grid">
            <div class="ec-off2Grid__cell">
                <form name="login_mypage" id="login_mypage" method="post" action="{{ url('mypage_login') }}">
                    {% if app.session.flashBag.has('eccube.login.target.path') %}
                        {% for targetPath in app.session.flashBag.get('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_id, {'attr': {'style' : 'ime-mode: disabled;', 'placeholder' : 'ログインID', 'autofocus': true}}) }}
                                {# login_emailをコメントアウトする #}
                                {# {{ form_widget(form.login_email, {'attr': {'style' : 'ime-mode: disabled;', 'placeholder' : 'common.mail_address', 'autofocus': true}}) }} #}
                                {{ form_widget(form.login_pass,  {'attr': {'placeholder' : 'common.password' }}) }}
                            </div>
```



## 最後に
ログインIDの発行については、要件等に応じて別途下記のような実装が必要になるかと思います。

* EC会員登録時に、会員自らがユニークなログインIDの入力を可能とする。
* EC会員登録時に、ユニークなログインIDを生成する
* etc..

何か間違った記述がある場合はコメント等にてご指摘等お願いします。

## 参考文献
[Symfony公式 ver3.4]How to Build a Traditional Login Form
https://symfony.com/doc/3.4/security/form_login_setup.html

SymfonyでGuardとEntityを使った認証 (@asaokamei様) (Quita)
https://qiita.com/asaokamei/items/5122e398e4cc17c84429

[EC CUBE公式]Entityのカスタマイズ
https://doc4.ec-cube.net/customize_entity