🎻

[Symfony] UserエンティティにplainPasswordプロパティを設けて扱いやすくする

2020/04/24に公開

以前、以下の記事でSymfonyアプリにユーザーログイン機能を追加る基本的な手順を説明しました。

[Symfony] FOSUserBundleを使わなくても15分でユーザーログインは実装できる

今回はこれの少しだけ発展編です。

bin/console make:user コマンドで作ったUserエンティティは password というエンコード済みのパスワードを保持するプロパティしか持たないため、ユーザーのパスワードを変更できるような操作がアプリ内の複数箇所にある場合、 入力されたパスワードをエンコードして password プロパティにセット、というのをすべての箇所でやらなければならず、面倒です。 😓

また、以下の記事で解説していますが、

Symfonyでログイン後の画面を機能テストする方法【簡単です】

フィクスチャを使ってログイン後の画面を機能テストしたい場合に、Userエンティティが password プロパティしか持たないと、記事内で解説しているとおり

  1. bin/console security:encode-password {パスワード} パスワードをエンコードする
  2. エンコードされたパスワードハッシュをフィクスチャに書く

というちょっと面倒なことをしなければなりません🙄

というわけで、前置きが長くなりましたが、このような問題を解決する定石を説明します✋

Userエンティティに plainPassword プロパティを追加

まず、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 を破棄するように適切に実装しています。

Entity Listenerを使って plainPassword をエンコードしたものを password に自動で反映させる

次に、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 に保存されるようになります👍

落とし穴: prePersistpreUpdate だと plainPassword しか変更されていないときに動かない

ちなみに、今回フックするLifecycle Eventsとして preFlush を選択しましたが、より直感的な prePersistpreUpdate を代わりに使ってしまうと、若干意図しない挙動になってしまうので要注意です。

新規ユーザー作成時は prePersist でちゃんとフックできるのですが、既存ユーザー更新時に preUpdate だとフックできないケースがあるのです。

具体的には、 どのプロパティも変更せず plainPassword にだけ値をセットしたとき です。

plainPassword プロパティはDoctrineの管理外のため、 plainPassword だけを変更したエンティティは、Doctrineから見ると「何も変更されていないエンティティ」に見えるのです。

Doctrineのコードを見てみると、 preUpdate イベントが発火されるのは このメソッドが呼ばれたとき ですが、エンティティが何も変更されていない場合は、そのちょっと前の このif文 で弾かれて、何もせずに終了してしまいます。

preFlush の発火タイミングは このif文よりもちょっと手前 にあるので、 preFlush のタイミングでEntity Listenerから password プロパティを変更してあげれば、その直後のif文で無事に「変更あり」という判定になり、変更がDBに保存されます。

ちなみに:FOSUserBundleも prePersistpreUpdate を見ているので同じ問題が再現する

Symfonyのユーザー管理のデファクトスタンダードである(最近はそうでもないかも…)FOSUserBundle も、今回紹介したのと同様の仕組みで plainPassword プロパティを用意してくれているのですが、コードを見ると prePersistpreUpdate をフックしていて、上述したのと同じ問題が再現します。

頭の片隅に入れておくと、いざというとき助かるかもしれません。

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を適切に設定してあげるとよい
GitHubで編集を提案

Discussion