Help us understand the problem. What is going on with this article?

SymfonyでGuardとEntityを使った認証

More than 3 years have passed since last update.

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

参考にしたのはこの記事。ただしベーシック認証のケースになってます。

ちなみに、Symfonyの認証方法としては、ドキュメントのSecurityFOSバンドルを利用する、などもあります。見た感じだとGuardが直感的にわかりやすそうで、かつ細かな調整が行いやすそうだったので試してみました。

PHPクラスの用意

Userエンティティ

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

<?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を実装すること。getUsernamegetRolesなどのメソッドを適宜実装します。テーブル名などは実際のデータベースに合わせます。

Guardクラス

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

<?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クラスをサービスとして登録しましょう。

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クラス内ではルート名を使っているので、整合しやすいです。

    /**
     * @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'サービスを使って例外のメッセージと入力ユーザー名を取得して表示してます。

ログインフォーム

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

{% 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を返す理由を理解したことで動いた気もします。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした