Symfonyフレームワークでの認証について、やっと動いたのでメモ代わりとして書いてみます。認証にはGuardクラスとDoctrineのエンティティを利用したログイン方法です。
参考にしたのはこの記事。ただしベーシック認証のケースになってます。
ちなみに、Symfonyの認証方法としては、ドキュメントのSecurity、FOSバンドルを利用する、などもあります。見た感じだと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
を実装すること。getUsername
、getRoles
などのメソッドを適宜実装します。テーブル名などは実際のデータベースに合わせます。
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を返す理由を理解したことで動いた気もします。