SymfonyでGuardとEntityを使った認証

Symfonyフレームワークでの認証について、やっと動いたのでメモ代わりとして書いてみます。認証にはGuardクラスとDoctrineのエンティティを利用したログイン方法です。

参考にしたのは[この記事](https://www.sitepoint.com/easier-authentication-with-guard-in-symfony-3/)。ただしベーシック認証のケースになってます。

> ちなみに、Symfonyの認証方法としては、[ドキュメントのSecurity](http://symfony.com/doc/current/components/security/authentication.html)、[FOSバンドルを利用する](http://symfony.com/doc/current/bundles/FOSUserBundle/index.html)、などもあります。見た感じだとGuardが直感的にわかりやすそうで、かつ細かな調整が行いやすそうだったので試してみました。

PHPクラスの用意
----

### Userエンティティ

まずはログインユーザー用のエンティティクラスを作ります。Doctrineを利用して、データベースのユーザーテーブルからログイン情報を取得します。

```php
<?php
namespace AppBundle\Entity\User;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\UserInterface;

/**
 * Class User
 *
 * @ORM\Table(name="task_user")
 * @ORM\Entity(repositoryClass="AppBundle\Entity\User\User\UserRepository")
 */
class User implements UserInterface
{
    /**
     * @var int
     * 
     * @ORM\Column(name="id", type="integer")
     * @ORM\Id
     * @ORM\GeneratedValue(strategy="AUTO")
     */
    protected $id;

    /**
     * @var string
     * @ORM\Column(name="user_name", type="string", length=64)
     */
    protected $username;

    /**
     * Encrypted password. Must be persisted.
     *
     * @var string
     * @ORM\Column(name="password", type="string", length=256)
     */
    protected $password;

    /**
     * Returns the roles granted to the user.
     *
     * @return (Role|string)[] The user roles
     */
    public function getRoles()
    {
        return ['ROLE_ADMIN'];
    }

    /**
     * Returns the password used to authenticate the user.
     *
     * @return string The password
     */
    public function getPassword()
    {
        return $this->password;
    }

    /**
     * Returns the salt that was originally used to encode the password.
     *
     * @return string|null The salt
     */
    public function getSalt()
    {
        return null;
    }

    /**
     * Returns the username used to authenticate the user.
     *
     * @return string The username
     */
    public function getUsername()
    {
        return $this->username;
    }

    /**
     * Removes sensitive data from the user.
     */
    public function eraseCredentials()
    {
    }
}
```

ポイントは`UserInterface`を実装すること。`getUsername`、`getRoles`などのメソッドを適宜実装します。テーブル名などは実際のデータベースに合わせます。

### Guardクラス

次は、`Guard`クラスを実装します。ここに認証するコードを書き込むことになります。

```php
<?php
namespace AppBundle\AuthService;

use AppBundle\Entity\User\User;
use Doctrine\ORM\EntityManager;
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\Exception\AuthenticationException;
use Symfony\Component\Security\Core\Exception\CustomUserMessageAuthenticationException;
use Symfony\Component\Security\Core\Security;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Security\Core\User\UserProviderInterface;
use Symfony\Component\Security\Guard\AbstractGuardAuthenticator;

class SettingAuth extends AbstractGuardAuthenticator
{
    /**
     * @var RouterInterface
     */
    private $router;

    /**
     * @var EntityManager
     */
    private $em;

    /**
     * @var string
     */
    private $message = 'please check inputs. ';

    /**
     * SettingAuth constructor.
     *
     * @param $em
     * @param $router
     */
    public function __construct($em, $router)
    {
        $this->em = $em;
        $this->router = $router;
    }

    /**
     * Returns a response that directs the user to authenticate.
     *
     * @param Request                 $request       The request that resulted in an AuthenticationException
     * @param AuthenticationException $authException The exception that started the authentication process
     * @return Response
     */
    public function start(Request $request, AuthenticationException $authException = null)
    {
        $url = $this->router->generate('settings-login');
        return new RedirectResponse($url);
    }

    /**
     * Get the authentication credentials from the request and return them
     * as any type (e.g. an associate array). If you return null, authentication
     * will be skipped.
     *
     * @param Request $request
     * @return mixed|null
     */
    public function getCredentials(Request $request)
    {
        $url = $this->router->generate('settings-login');
        if ($request->getPathInfo() != $url || $request->getMethod() !== 'POST') {
            return null;
        }
        $username = $request->request->get('_username');
        $request->getSession()->set(Security::LAST_USERNAME, $username);
        return array(
            'username' => $username,
            'password' => $request->request->get('_password'),
        );
    }

    /**
     * Return a UserInterface object based on the credentials.
     *
     * @param mixed                 $credentials
     * @param UserProviderInterface $userProvider
     * @throws AuthenticationException
     * @return UserInterface|null
     */
    public function getUser($credentials, UserProviderInterface $userProvider)
    {
        if (!isset($credentials['username'])) {
            return null;
        }
        $username = $credentials['username'];
        $user = $this->em->getRepository(User::class)
            ->findOneBy(['username' => $username]);

        if ($user) {
            return $user;
        }
        throw new CustomUserMessageAuthenticationException($this->message);
    }

    /**
     * Returns true if the credentials are valid.
     *
     * @param mixed         $credentials
     * @param UserInterface $user
     * @return bool
     * @throws AuthenticationException
     */
    public function checkCredentials($credentials, UserInterface $user)
    {
        $password = $credentials['password'];
        if (password_verify($password, $user->getPassword())) {
            return true;
        }
        throw new CustomUserMessageAuthenticationException($this->message);
    }

    /**
     * Called when authentication executed, but failed (e.g. wrong username password).
     *
     * @param Request                 $request
     * @param AuthenticationException $exception
     * @return Response|null
     */
    public function onAuthenticationFailure(Request $request, AuthenticationException $exception)
    {
        $request->getSession()->set(Security::AUTHENTICATION_ERROR, $exception);
        $url = $this->router->generate('settings-login');
        return new RedirectResponse($url);
    }

    /**
     * Called when authentication executed and was successful!
     *
     * @param Request        $request
     * @param TokenInterface $token
     * @param string         $providerKey The provider (i.e. firewall) key
     * @return Response|null
     */
    public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
    {
        $url = $this->router->generate('initialize');
        return new RedirectResponse($url);
    }

    /**
     * Does this method support remember me cookies?
     *
     * @return bool
     */
    public function supportsRememberMe()
    {
        return false;
    }
}
```

`AbstractGuardAuthenticator`を継承して作ります。

* `start`：ログインフォームを表示するためのredirectレスポンスを返します。
* `getCredential`：ファイヤウォールでガードされてるURLにアクセスすると毎回呼ばれるようです。したがって最初に**認証処理が必要かどうかを判定**します。認証処理が必要ない場合は、`NULL`を返すことになります。ログイン画面かつPOSTメソッドの場合、ログインフォームから`$credential`を作成して返します。
* `getUser`：認証処理の最初は、ユーザーデータの取得です。今回はDoctrineを使って、DBからエンティティを読み込みます。
* `checkCredential`：ユーザーが取得できたらパスワード認証です。好きなコードを書けますのでpassword_verify関数を利用してます。`if ($password === $user->getPassword())`と書くこともできます。
* `onAuthenticationFailure`：認証に失敗した場合の処理です。再びログイン画面を表示するためredirectResponseを返してます。
* `onAuthenticationSuccess`：認証に成功した場合の処理です。redirectResponseを返すか、あるいはNULLを返すことでそのまま表示を行います。



設定の変更
----

PHPクラスができたので、次は設定です。

### services.yml

まずは`Guard`クラスをサービスとして登録しましょう。

```yml
services:
    app.settings_authenticator:
        class: AppBundle\AuthService\SettingAuth
        arguments: ["@doctrine.orm.default_entity_manager", "@router"]
```

サービス名は`app.settings_authenticator`としました。コンストラクタにはDoctrineのEntityManagerとルーターを渡してます。


### security.yml

次はセキュリティ設定で、Guardサービスで認証を行います。

```
security:
    providers:
        users:
            entity: { class: AppBundle\Entity\User\User, property: username }

    firewalls:
        settings:
            pattern: ^/settings/
            anonymous: ~
            logout: ~            
            guard:
                authenticators:
                    - app.settings_authenticator

    access_control:
        - { path: ^/settings/login, role: IS_AUTHENTICATED_ANONYMOUSLY }
        - { path: ^/settings/, roles: ROLE_ADMIN}
```

ポイントとして、

* プロバイダーとして`User`エンティティクラスを指定します。
* ファイヤウォールでは、guardとしてサービス`app.settings_authenticator`を指定します。`pattern`で認証するURLを指定します。
* アクセスコントロールとして、一番最初にログイン画面（のURL`^/settings/login`）の設定をします。`IS_AUTHENTICATED_ANONYMOUSLY`を指定して、ログインフォームを認証なしで表示できるようにします。

> どうやら、firewallsだけだと未認証でも入れるっぽいです…
> 多分、アノニマスという認証が通ってる、のでしょう。
> ちゃんとロールも指定する必要があるようです。

Controllerとログインフォーム
----

あと、もう少し…

### Controller

いよいよログイン画面を処理するControllerを作成します。Routeは、security.ymlで指定したログインURLと同じにしてください。Guardクラス内ではルート名を使っているので、整合しやすいです。

```php
    /**
     * @Config\Route("/settings/login", name="settings-login")
     * @Config\Method({"GET", "POST"})
     * @return Response
     */
    public function login()
    {
        $user = $this->getUser();
        if ($user instanceof UserInterface) {
            return $this->redirectToRoute('initialize');
        }

        /** @var AuthenticationException $exception */
        $utils = $this->get('security.authentication_utils');
        $exception = $utils->getLastAuthenticationError();
        $lastName  = $utils->getLastUsername();

        $data = [
            'lastName' => $lastName,
        ];
        if ($exception) {
            $this->addFlash('notice', $exception->getMessage());
        } else {
            $this->addFlash('message', 'please login');
        }
        return $this->render('task/system/login.html.twig', $data);
    }
```

Guardで認証できなかった場合のため、'security.authentication_utils'サービスを使って例外のメッセージと入力ユーザー名を取得して表示してます。


### ログインフォーム

最後にログインフォームです。

```html
{% block body %}

    <form action="" method="post">

        <label for="username">Username:</label>
        <input type="text" id="username" name="_username" class="form-control" value="{{ lastName }}"/>

        <label for="password">Password:</label>
        <input type="password" id="password" name="_password" class="form-control"/>

        <button type="submit" class="btn btn-primary">login</button>
    </form>

{% endblock %}
```

`Guard::getCredential`では、入力から$credentialを作成してます。それと同じの名前（`_username`と`_password`）を使います。

最後に
----

多分、これで動くはずですが…　動いてしまうと、何が問題だったのか分かりませんが、足掛けで一週間かかってしまいました。

おそらくFOSのときのコードや設定が入り込んだりして、YMLやプロバイダーとかで混乱してたのでしょう。もうひとつは`Guard::getCredential`でNULLを返す理由を理解したことで動いた気もします。