Symfony5 + EasyAdmin3 で管理画面を作る

# はじめに
先日`EasyAdminBundle`の3系が公開されました。今回は、Symfony5で導入された新しい認証機構を用いてログインフォームを作成し、`EasyAdminBundle`の3系を利用して管理画面を作成するまでの手順をまとめました。

# 環境
|  | バージョン |
| --- | --- |
| PHP | 7.4.7 |
|symfony | 5.1.2 |
| easyadmin-bundle | 3.0.1 |




# ライブラリのインストール
`symfony/apache-pack`と`symfony/profiler-pack`はデバッグしやすくするために入れているので、なくても大丈夫です。

```bash
 $ composer require symfony/apache-pack easycorp/easyadmin-bundle security
 $ composer require --dev symfony/profiler-pack symfony/maker-bundle
```

# Adminクラスの作成
Userクラスの名前だけAdminにして、残りはデフォルトでOK。

```bash
$ bin/console make:user

 The name of the security user class (e.g. User) [User]:
 > Admin

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > 

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > 

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > 
...
```

# ログイン機構の作成
続いてログインを処理する認証機構を作成します。`make:auth`で雛形の作成ができるのですが、symfony5で導入された新しい認証機構には対応したインターフェイスでは無いので少し手直しする必要があります。

## AdminProviderの作成
まず、一意なID(今回はemail)から対応するAdminクラスのインスタンスを取得するためのAdminProviderを作成します。`EntityUserProvider`を継承し、コンストラクタでクラス名と一意なIDに対応するプロパティ名を指定することで、とても簡単にProviderを作成することができます。

```php:Serucity/AdminProvider.php
<?php

namespace App\Security;

use App\Entity\Admin;
use Doctrine\Persistence\ManagerRegistry;
use Symfony\Bridge\Doctrine\Security\User\EntityUserProvider;

class AdminProvider extends EntityUserProvider
{
    public function __construct(ManagerRegistry $registry, string $classOrAlias = Admin::class, string $property = 'email', string $managerName = null)
    {
        parent::__construct($registry, $classOrAlias, $property, $managerName);
    }
}
```

## Authenticatorの作成
以下のコマンドで、`SecurityController`と`AdminLoginFormAuthenticator`が作成されます。一つ目の質問は**1**を選択してください。脳死でエンター連打するとミスります。

```bash
 bin/console make:auth

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > AdminLoginForm

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > Admin\Security

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 > 
```
続いて、作成された`AdminLoginFormAuthenticator`を以下のように修正します。
主にやることはこの三つです。

* 継承する親クラスを`AbstractFormLoginAuthenticator`から`AbstractLoginFormAuthenticator`へ変更(FormとLoginが逆になっている)
* ↑によって整合性の取れなくなった関数の戻り値などの型を修正
* **authenticate()メソッドを実装**

一番重要なのが最後のauthenticate()メソッドの実装で、ここがsymfony5から導入された認証機構になると思われます。認証に成功した場合、`PassportInterface`と言う物を返すようにします。`PassportInterface`には`Badge`と呼ばれる物を持たせられるようで、これにより`Remember me`などを実装できるらしいです。

```php:Security/AdminLoginFormAuthenticator.php
<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
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\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
use Symfony\Component\Security\Http\Authenticator\AbstractLoginFormAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\PassportInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;
use Symfony\Component\Security\Http\Util\TargetPathTrait;

class AdminLoginFormAuthenticator extends AbstractLoginFormAuthenticator
{
    use TargetPathTrait;

    public const LOGIN_ROUTE = 'admin_login';

    private $adminProvider;
    private $urlGenerator;
    private $csrfTokenManager;
    private $passwordEncoder;

    public function __construct(AdminProvider $adminProvider, UrlGeneratorInterface $urlGenerator, CsrfTokenManagerInterface $csrfTokenManager, UserPasswordEncoderInterface $passwordEncoder)
    {
        $this->adminProvider = $adminProvider;
        $this->urlGenerator = $urlGenerator;
        $this->csrfTokenManager = $csrfTokenManager;
        $this->passwordEncoder = $passwordEncoder;
    }

    public function supports(Request $request): bool
    {
        return self::LOGIN_ROUTE === $request->attributes->get('_route') && $request->isMethod('POST');
    }

    public function authenticate(Request $request): PassportInterface
    {
        $credentials = $this->getCredentials($request);
        $user = $this->getUser($credentials);

        if (!$user) {
            throw new CustomUserMessageAuthenticationException('Email could not be found.');
        }

        if (!$this->isValidCredentials($credentials, $user)) {
            throw new CustomUserMessageAuthenticationException('Email or Password is invalid.');
        }

        return new SelfValidatingPassport($user);
    }

    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey): Response
    {
        if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
            return new RedirectResponse($targetPath);
        }

        $url = $this->urlGenerator->generate('admin_dashboard');
        return new RedirectResponse($url);
    }

    public function getLoginUrl(Request $request): string
    {
        return $this->urlGenerator->generate(self::LOGIN_ROUTE);
    }

    public function getCredentials(Request $request)
    {
        $credentials = [
            'email' => $request->request->get('email'),
            'password' => $request->request->get('password'),
            'csrf_token' => $request->request->get('_csrf_token'),
        ];
        $request->getSession()->set(Security::LAST_USERNAME, $credentials['email']);

        return $credentials;
    }

    public function isValidCredentials($credentials, UserInterface $user)
    {
        return $this->passwordEncoder->isPasswordValid($user, $credentials['password']);
    }

    public function getUser($credentials)
    {
        $token = new CsrfToken('authenticate', $credentials['csrf_token']);
        if (!$this->csrfTokenManager->isTokenValid($token)) {
            throw new InvalidCsrfTokenException();
        }
        return $this->adminProvider->loadUserByUsername($credentials['email']);
    }
}

```

`SecurityController`はパスのprefixに`/admin`を追加し、Routeのappをadminへ直しておきます。

```php:SecurityController.php
<?php

namespace App\Controller\Admin;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;

/**
 * Class SecurityController
 * @package App\Controller\Admin
 * @Route("/admin")
 */
class SecurityController extends AbstractController
{
    /**
     * @Route("/login", name="admin_login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        $lastUsername = $authenticationUtils->getLastUsername();
        return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
    }

    /**
     * @Route("/logout", name="admin_logout")
     */
    public function logout()
    {
        throw new \LogicException('This method can be blank - it will be intercepted by the logout key on your firewall.');
    }
}
```

## security.yamlの修正
最後にfirewallなどの設定を修正します。 上記の流れで実行すると、デフォルトでは`main`firewallの中に認証の設定などが自動で入りますが、管理者向けにfirewallを新たに設定します。

`enable_authenticator_manager`の項目にtrueを設定することで、symfony5で導入された認証機構を有効化することができます。`admin`firewallの中の`custom_authenticators`に先ほど作成した`AdminLoginFormAuthenticator`を指定することで、^/admin/にマッチするURLへリクエストがきた時に認証処理が走ります。認証が通らなかった場合は`entry_point`で指定したサービスを用いてレスポンスを返します。ここで指定するサービスは`AuthenticationEntryPointInterface`の実装である必要があるのですが、`AdminLoginFormAuthenticator`が継承している`AbstractLoginFormAuthenticator`は既にこの実装が組み込まれているので自動的にログインフォームへリダイレクトしてくれます。

また、従来の認証から変わった点として、「匿名ユーザー」と言う概念がなくなり、代わりに「未認証」として扱われるようになったようです(あまり違いが分かっていない)。そのため、今まで匿名ユーザーのアクセスを許可する場合`access_control`の`role`に`IS_AUTHENTICATED_ANONYMOUSLY`と指定していましたが、新たな認証では`PUBLIC_ACCESS`を指定することになったようです。

また、少しハマった点として、firewallの設定に`lazy: true`としないと、`access_control`で`PUBLIC_ACCESS`にしても認証処理が走ってしまうようで、ログインフォームへのリダイレクトがループしてしまいます。

```yaml:packages/security.yaml
security:
    enable_authenticator_manager: true
    encoders:
        App\Entity\Admin:
            algorithm: auto
    providers:
        admin_provider:
            id: App\Security\AdminProvider
    firewalls:
        dev:
            pattern: ^/(_(profiler|wdt)|css|images|js)/
            security: false
        admin:
            pattern: ^/admin
            lazy: true
            provider: admin_provider
            custom_authenticators:
                - App\Security\AdminLoginFormAuthenticator
            entry_point: App\Security\AdminLoginFormAuthenticator
            logout:
                path: admin_logout
        main:
            security: false


    access_control:
        - { path: ^/admin/login, roles: PUBLIC_ACCESS }
        - { path: ^/admin, roles: ROLE_ADMIN }

```

ここまでで、`admin/login`へアクセスするとログインフォームが表示されるかと思います！


## ダミーデータの投入
ダミーデータを投入して実際にログインできるか試してみます。

```
$ composer require --dev orm-fixtures
```

```php:DataFixtures/AdminFixtures.php
<?php

namespace App\DataFixtures;

use App\Entity\Admin;
use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Persistence\ObjectManager;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;

class AdminFixtures extends Fixture
{
    private $encoder;

    public function __construct(UserPasswordEncoderInterface $encoder)
    {
        $this->encoder = $encoder;
    }

    public function load(ObjectManager $manager)
    {
        $user = new Admin();

        $password = $this->encoder->encodePassword($user, 'password');
        
        $user->setEmail('admin@example.com');
        $user->setPassword($password);
        $user->setRoles(['ROLE_ADMIN']);
        
        $manager->persist($user);
        $manager->flush();
    }
}
```

データベースの設定は環境に合わせて変えてください。

```env:.env
...
DATABASE_URL=mysql://user:pass@127.0.0.1:3306/sym5admin?serverVersion=5.7
...
```

```bash
$ bin/console d:d:c
Created database `sym5admin` for connection named default

$  bin/console d:s:u -f

$  bin/console doctrine:fixtures:load

 Careful, database "sym5admin" will be purged. Do you want to continue? (yes/no) [no]:
 > yes
```

以上で、下記のユーザー情報でログインできるかと思います！(/admin のRouteがまだ無いのでエラーが表示されます。)また、異なるパスワードやメールアドレスの場合はフォームへエラーが表示されることも確認できます。
email: admin@example.com
pass: password

# easyAdminの設定

easyAdminはほとんど使ったことが無いのですが、2系と3系でかなり設定項目が変わったようです。
まず、easyAdminをインストールすると自動で作成される`/config/routes/easy_admin.yaml`を削除します。2系だと必要だった見たいですが、3系では必要無い見たいです。

# ログインフォームの修正
先ほど修正した`SecurityController`ですが、また修正します(笑)。`login`メソッドの返り値を以下のようにすることで、easyAdminのテンプレートを用いたかっこいいフォームになります。

```php:SecurityController.php
    /**
     * @Route("/login", name="admin_login")
     */
    public function login(AuthenticationUtils $authenticationUtils): Response
    {
        $error = $authenticationUtils->getLastAuthenticationError();
        $lastUsername = $authenticationUtils->getLastUsername();

        return $this->render('@EasyAdmin/page/login.html.twig', [
            'error' => $error,
            'last_username' => $lastUsername,
            'translation_domain' => 'admin',
            'page_title' => 'ADMIN LOGIN',
            'csrf_token_intention' => 'authenticate',
            'username_label' => 'email',
            'password_label' => 'password',
            'sign_in_label' => 'Log in',
            'username_parameter' => 'email',
            'password_parameter' => 'password',
        ]);
    }
```

## ダッシュボードの作成
easyAdmim3系では、まずダッシュボードを作成することが必要です。以下のコマンドでダッシュボード用のコントローラーが作成されます。この段階で`/admin`のRouteが作成されるので、ログインするとeasyAdminのダッ種ボードが見れるようになります(まだ何も無いですが)。

```bash
bin/console make:admin:dashboard

[OK] Your dashboard class has been successfully generated.
```


## ダッシュボードのメニューの作成

まずはリソースとなるエンティティのCRUDコントローラーを作成します。今回はまだ`Admin`クラスしか無いので、`Admin`クラスを選択します。（この選択のさせ方はentityの数が増えた時ちょっと大変だなと思った）

```bash
 bin/console make:admin:crud

 Which Doctrine entity are you going to manage with this CRUD controller?:
  [0] App\Entity\Admin
 > 0
```

次に`DashBoardController`の`configureMenuItems()`メソッドを次のようにします。

```php:DashBoardController.php

    public function configureMenuItems(): iterable
    {
        return [
            MenuItem::linktoDashboard('Dashboard', 'fa fa-home'),

            MenuItem::Section('Admin'),
            MenuItem::linkToCrud('Admin', 'fa fa-user', Admin::class),
        ];
    }
```

お疲れ様です！以上で基本的な作業は終わりです！
だいぶ管理画面ぽい見た目になってきたかと思います！


# おわりに
今回は、最新のSymfony5 + EasyAdmin3の構成で管理画面を作成する方法をまとめました。Symfonyの管理画面作成用ライブラリではsonataAdminBundleなどが有名ですが、残念ながらSymfony5にはまだ対応していないみたいなので、今回このような記事を作成しました。
また、ログイン処理も今までfosUserbundleなどに頼り切っていたため、素直な実装を改めてしてみたことでかなり勉強になりました。EasyAdmin3はまだリリースされたばかりであまり情報が無いですが、これからじっくり触ってみようと思います。