Edited at

SymfonyでGuardとEntityを使った認証

More than 1 year has 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を返す理由を理解したことで動いた気もします。