[Symfony] UserエンティティにplainPasswordプロパティを設けて扱いやすくする
以前、以下の記事でSymfonyアプリにユーザーログイン機能を追加る基本的な手順を説明しました。
今回はこれの少しだけ発展編です。
bin/console make:user
コマンドで作ったUserエンティティは password
というエンコード済みのパスワードを保持するプロパティしか持たないため、ユーザーのパスワードを変更できるような操作がアプリ内の複数箇所にある場合、 入力されたパスワードをエンコードして password
プロパティにセット、というのをすべての箇所でやらなければならず、面倒です。 😓
また、以下の記事で解説していますが、
フィクスチャを使ってログイン後の画面を機能テストしたい場合に、Userエンティティが password
プロパティしか持たないと、記事内で解説しているとおり、
-
bin/console security:encode-password {パスワード}
パスワードをエンコードする - エンコードされたパスワードハッシュをフィクスチャに書く
というちょっと面倒なことをしなければなりません🙄
というわけで、前置きが長くなりましたが、このような問題を解決する定石を説明します✋
plainPassword
プロパティを追加
Userエンティティに まず、Userエンティティに plainPassword
といったプロパティを追加し、パスワードの平文を持てるようにします。(もちろんDBには保存しません)
// ※ @ORM\Column(...) アノテーションをしていないことに注意
private $plainPassword;
// ...
public function getPlainPassword(): ?string
{
return $this->plainPassword ? (string) $this->plainPassword : null;
}
public function setPlainPassword(?string $plainPassword): self
{
$this->plainPassword = $plainPassword;
return $this;
}
// ...
public function eraseCredentials()
{
$this->plainPassword = null;
}
プロパティの追加に加えて、eraseCredentials()
メソッド を、 plainPassword
を破棄するように適切に実装しています。
plainPassword
をエンコードしたものを password
に自動で反映させる
Entity Listenerを使って 次に、Doctrineの Entity Listener という機能を使って preFlush
Lifecycle Eventsをフックし、平文パスワードをエンコードしたものが自動で password
プロパティにセットされるようにします。
Entity Listenerの詳しい使い方については こちらの記事 をご参照ください。
以下のような内容で実装します。
<?php
namespace App\EntityListener;
use App\Entity\User;
use Doctrine\ORM\Event\LifecycleEventArgs;
use Doctrine\ORM\Event\PreFlushEventArgs;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Symfony\Component\Security\Core\Encoder\UserPasswordEncoderInterface;
class UserListener
{
/**
* @var UserPasswordEncoderInterface
*/
private $encoder;
public function __construct(UserPasswordEncoderInterface $encoder)
{
$this->encoder = $encoder;
}
public function preFlush(User $user, PreFlushEventArgs $event)
{
if ($plainPassword = $user->getPlainPassword()) {
$user->setPassword($this->encoder->encodePassword($user, $plainPassword));
$user->eraseCredentials();
}
}
}
/**
* @ORM\Entity(repositoryClass="App\Repository\UserRepository")
* @ORM\EntityListeners({"App\EntityListener\UserListener"})
*/
class User implements UserInterface
{
// ...
}
# services.yaml
services:
# ...
App\EntityListener\:
resource: '../src/EntityListener'
tags: ['doctrine.orm.entity_listener']
UserListner::preFlush()
の内容はごくシンプルですね。
plainPassword
プロパティに値が入っていたら、エンコードして password
プロパティにセット、としているだけです。
用が済んだら $user->eraseCredentials();
を呼び出して平文パスワードの情報をちゃんと破棄していることにも注目してください。
これで、アプリ側ではパスワードのエンコード処理のことは特に気にしなくても、 plainPassword
に平文パスワードを入れてflushすれば、常に自動でエンコードしたパスワードハッシュが password
に保存されるようになります👍
prePersist
と preUpdate
だと plainPassword
しか変更されていないときに動かない
落とし穴: ちなみに、今回フックするLifecycle Eventsとして preFlush
を選択しましたが、より直感的な prePersist
と preUpdate
を代わりに使ってしまうと、若干意図しない挙動になってしまうので要注意です。
新規ユーザー作成時は prePersist
でちゃんとフックできるのですが、既存ユーザー更新時に preUpdate
だとフックできないケースがあるのです。
具体的には、 どのプロパティも変更せず plainPassword
にだけ値をセットしたとき です。
plainPassword
プロパティはDoctrineの管理外のため、 plainPassword
だけを変更したエンティティは、Doctrineから見ると「何も変更されていないエンティティ」に見えるのです。
Doctrineのコードを見てみると、 preUpdate
イベントが発火されるのは このメソッドが呼ばれたとき ですが、エンティティが何も変更されていない場合は、そのちょっと前の このif文 で弾かれて、何もせずに終了してしまいます。
preFlush
の発火タイミングは このif文よりもちょっと手前 にあるので、 preFlush
のタイミングでEntity Listenerから password
プロパティを変更してあげれば、その直後のif文で無事に「変更あり」という判定になり、変更がDBに保存されます。
prePersist
と preUpdate
を見ているので同じ問題が再現する
ちなみに:FOSUserBundleも Symfonyのユーザー管理のデファクトスタンダードである(最近はそうでもないかも…)FOSUserBundle も、今回紹介したのと同様の仕組みで plainPassword
プロパティを用意してくれているのですが、コードを見ると prePersist
と preUpdate
をフックしていて、上述したのと同じ問題が再現します。
頭の片隅に入れておくと、いざというとき助かるかもしれません。
NotBlank
制約をセットしたい場合は、新規ユーザー作成時のみを対象にしないといけない
ところで、今回新設した plainPassword
プロパティですが、少し厄介なことに
- 新規ユーザー作成時は、空がセットされることは拒否したい
- 既存ユーザー更新時は、空がセットされていたらパスワードは変更しない、としたい
という要件が自動的に発生してしまいます。
なので、Validation Groups を使って「新規ユーザー作成時にのみ NotBlank
制約を適用」という設定をしておく必要があります。
具体的には、まず以下のようにUserエンティティの plainPassword
プロパティにValidation Groups付きで NotBlank
制約をセットします。
/**
* @Assert\NotBlank(groups={"registration"})
*/
private $plainPassword;
そして、FormTypeが例えば以下のような一般的な内容だとすると、
class UserType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class, [
'label' => 'メールアドレス',
])
->add('plainPassword', PasswordType::class, [
'label' => 'パスワード',
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
]);
}
}
コントローラから新規ユーザー作成フォームを作るときに、以下のように validation_groups
オプションでグループを指定してあげれば、そのフォームでは plainPassword
プロパティに対する NotBlank
制約が有効になります。
$form = $this->createForm(UserType::class, $user = $this->getUser(), [
'validation_groups' => ['registration'],
]);
$form->handleRequest($request);
既存ユーザー更新フォームでは validation_groups
を指定しないようにすれば、 NotBlank
制約の対象とならず、 plainPassword
にnullが入っていたら無視されて、パスワードは変更されません👍
別解
コントローラから validation_groups
を渡すのが面倒、関心を分離したいという場合は、新規ユーザー作成用と既存ユーザー更新用でFormTypeを分けてしまってもよいでしょう。
新規ユーザー作成用
class UserRegisterType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('email', EmailType::class, [
'label' => 'メールアドレス',
])
->add('plainPassword', PasswordType::class, [
'label' => 'パスワード',
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => User::class,
'validation_groups' => ['registration'],
]);
}
}
既存ユーザー更新用
class UserEditType extends UserRegisterType
{
public function configureOptions(OptionsResolver $resolver)
{
parent::configureOptions($resolver);
$resolver->setDefaults([
'validation_groups' => [],
]);
}
}
こうしておけば、コントローラ側では以下のようにFormTypeを使い分けるだけで済みます。
// 新規ユーザー作成用
$form = $this->createForm(UserRegisterType::class, $user = $this->getUser());
// 既存ユーザー更新用
$form = $this->createForm(UserEditType::class, $user = $this->getUser());
まとめ
- Userエンティティに
plainPassword
プロパティを追加して、password
プロパティはEtity listenerでフックして自動で更新させるようにすると、色々と楽になる -
password
の更新をprePersistでやろうとすると、plainPassword
プロパティしか変更せずにpersistしたときに無視されてしまうので、preFlushを使うとよい -
plainPassword
プロパティに対するNotBlank
制約は新規ユーザー作成時にのみ有効にしたいので、Validation Groupsを適切に設定してあげるとよい
Discussion