Chapter 03

ユーザー認証周りの機能を一通り実装

たつきち
たつきち
2022.07.30に更新

この章に対応するコミット

デモアプリは日本語と英語に対応するためURLが /ja/ または /en/ で始まるようになっており、またすべての文字列リテラルを翻訳しているので、コミットの内容は本文の解説と若干異なります。

ユーザー認証周りの機能を一通り実装

多くの場合、基本的なユーザー認証機能は要件に入っているので、細かな機能を作っていく前にまずはユーザー認証周りの機能を一通り実装してしまいます。

1. Doctrine ORMを導入

まずはDoctrine ORMを導入します。

$ composer require orm

トランザクショナルDDL に対応しているデータベースでしか使えませんが、一応 all_or_nothing を有効にしておきます。(僕は普段MySQLを使っているので、有効にしても無意味です😅)

  # config/packages/doctrine_migrations.yaml

  doctrine_migrations:
      migrations_paths:
          # namespace is arbitrary but should be different from App\Migrations
          # as migrations classes should NOT be autoloaded
          'DoctrineMigrations': '%kernel.project_dir%/migrations'
+     all_or_nothing: true

2. SecurityBundleを導入し、ログイン処理の雛形を自動生成

ユーザー認証機能を提供してくれるSecurityBundleを導入します。

$ composer require security

続いて、MakerBundleの make:user コマンドと make:auth コマンドを使って User エンティティとコントローラ、セキュリティ設定の雛形を作成します。

細かな手順は以下の過去記事をご参照ください。

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

ここでは、コントローラのクラス名はサジェストされる SecurityController ではなく UserController にします。

$ bin/console make:auth

 What style of authentication do you want? [Empty authenticator]:
  [0] Empty authenticator
  [1] Login form authenticator
 > 1

 The class name of the authenticator to create (e.g. AppCustomAuthenticator):
 > LoginFormAuthenticator

 Choose a name for the controller class (e.g. SecurityController) [SecurityController]:
 > UserController

 Do you want to generate a '/logout' URL? (yes/no) [yes]:
 >

 created: src/Security/LoginFormAuthenticator.php
 updated: config/packages/security.yaml
 created: src/Controller/UserController.php
 created: templates/security/login.html.twig


  Success!


 Next:
 - Customize your new authenticator.
 - Finish the redirect "TODO" in the App\Security\LoginFormAuthenticator::onAuthenticationSuccess() method.
 - Review & adapt the login template: templates/security/login.html.twig.

自動生成されたセキュリティ設定を一部カスタマイズします。

  # config/packages.security.yaml

  security:
      encoders:
          App\Entity\User:
-             algorithm: auto
+             algorithm: bcrypt
  
      # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
      providers:
          :
          :
  
            logout:
-               path: app_logout
-               # where to redirect after logout
-               # target: app_any_route
+               path: user_logout
+               target: user_login
  
              # activate different ways to authenticate
              # https://symfony.com/doc/current/security.html#firewalls-authentication
  
              # https://symfony.com/doc/current/security/impersonating_user.html
-             # switch_user: true
+             switch_user: true
+ 
+             remember_me:
+                 secret: '%kernel.secret%'
+ 
+     role_hierarchy:
+         ROLE_USER:
+             - ROLE_ALLOWED_TO_VIEW
+         ROLE_ALLOWED_TO_ADMIN:
+             - ROLE_ALLOWED_TO_EDIT
+             - ROLE_ALLOWED_TO_EDIT_USER
+             - ROLE_ALLOWED_TO_SWITCH
+             - ROLE_CANARY
  
      # Easy way to control access for large sections of your site
      # Note: Only the *first* access control that matches will be used
      access_control:
-         # - { path: ^/admin, roles: ROLE_ADMIN }
-         # - { path: ^/profile, roles: ROLE_USER }
+         - { path: ^/user/login$, role: IS_AUTHENTICATED_ANONYMOUSLY }
+         - { path: ^/, role: ROLE_USER }

変更した内容は以下のとおりです。

  • パスワードのハッシュアルゴリズムは bcrypt を使用
  • ログイン/ログアウト画面のルートを変更(あとでこれに合わせてルーティングを実装)
  • switch_user 機能 を有効に
  • remember_me 機能 を有効に
  • あとあと使いそうな基本的なROLEを定義
  • ログイン画面以外はすべて要ログインに

3. 雛形を修正してログイン処理を実装

雛形として作成されているコントローラとAuthenticatorを修正して、実際にユーザーログインが動作するところまで実装します。

コントローラ

  • security.yaml の設定に合わせてルート名を user_login user_logout に変更
  • ログイン済みの場合はログイン画面を見せずにトップページへ飛ばすように
+ /**
+  * @Route("/user", name="user_")
+  */
  class UserController extends AbstractController
  {
      /**
-      * @Route("/login", name="app_login")
+      * @Route("/login", name="login")
       */
-     public function login(AuthenticationUtils $authenticationUtils): Response
+     public function login(AuthenticationUtils $authenticationUtils)
      {
-         // if ($this->getUser()) {
-         //     return $this->redirectToRoute('target_path');
-         // }
+         if ($this->getUser()) {
+             return $this->redirectToRoute('home_index');
+         }
  
          // get the login error if there is one
          $error = $authenticationUtils->getLastAuthenticationError();
          // last username entered by the user
          $lastUsername = $authenticationUtils->getLastUsername();
  
-         return $this->render('security/login.html.twig', ['last_username' => $lastUsername, 'error' => $error]);
+         return $this->render('user/login.html.twig', [
+             'last_username' => $lastUsername,
+             'error' => $error,
+         ];
      }
  
      /**
-      * @Route("/logout", name="app_logout")
+      * @Route("/logout", name="logout")
       */
      public function logout()
      {
          // ...
      }
  }

Authenticator

  • security.yaml の設定に合わせてログイン画面のルート名を user_login に変更
  • ログイン成功時のリダイレクト処理を追加(必須)
  class LoginFormAuthenticator extends AbstractFormLoginAuthenticator implements PasswordAuthenticatedInterface
  {
      use TargetPathTrait;
  
-     public const LOGIN_ROUTE = 'app_login';
+     public const LOGIN_ROUTE = 'user_login';
  
      // ...
  
      public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
      {
          if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
              return new RedirectResponse($targetPath);
          }
  
          // For example : return new RedirectResponse($this->urlGenerator->generate('some_route'));
-         throw new \Exception('TODO: provide a valid redirect inside '.__FILE__);
+         return new RedirectResponse($this->urlGenerator->generate('home_index'));
      }
  
      protected function getLoginUrl()
      {
          return $this->urlGenerator->generate(self::LOGIN_ROUTE);
      }
  }

エンティティ

これは思いっきり個人的な好みですが、エンティティのプロパティに型宣言をつけて public にし、 getter/setter を排除します。

  /**
   * @ORM\Entity(repositoryClass=UserRepository::class)
   */
  class User implements UserInterface
  {
      /**
       * @ORM\Id
       * @ORM\GeneratedValue
       * @ORM\Column(type="integer")
       */
      private $id;
  
      /**
       * @ORM\Column(type="string", length=180, unique=true)
       */
-     private $email;
+     public ?string $email = null;
  
      /**
       * @ORM\Column(type="json")
       */
      private $roles = [];
  
      /**
       * @var string The hashed password
       * @ORM\Column(type="string")
       */
-     private $password;
+     public ?string $password = null;
  
      public function getId(): ?int
      {
          return $this->id;
      }
-  
-     public function getEmail(): ?string
-     {
-         return $this->email;
-     }
- 
-     public function setEmail(string $email): self
-     {
-         $this->email = $email;
- 
-         return $this;
-     }
  
      // ...
  }

$roles だけは、フレームワークが内部的に getRoles() メソッドを呼ぶことがあるのであえてそのままにしています。

getter/setter でファイルが埋め尽くされると可読性が著しく下がってとても苦手なので、PHP 7.4以上が使える環境ではこの書き方をしています。

<blockquote class="twitter-tweet" data-conversation="none"><p lang="ja" dir="ltr">プロパティをprivateにしてgetter/setter書くのって本質的にpublicと同じなので、getter/setter書いてる理由は入出力の型を指定するためだけという認識でした。7.4ならプロパティ型指定が使えるのでpublicにしてしまえると考えてます。</p>— たつきち👨‍💻CTO→フリーランス (@ttskch) <a href="https://twitter.com/ttskch/status/1259866072141815810?ref_src=twsrc^tfw">May 11, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

その他

  • ログイン画面のテンプレートを、雛形として作成された security/login.html.twig から user/login.html.twig へ移動して、中身を実装
  • ログイン画面ではグローバルナビの内容を表示しないように修正
  • ログインエラーのメッセージを自動で翻訳させたいので symfony/translation をインストール( composer require translation
  • ログインエラーの翻訳内容を一部上書き

マイグレーション

以上の修正をしたら、マイグレーションスクリプトを自動生成して、マイグレーションを実行します。

$ bin/console doctrine:migrations:diff
$ bin/console doctrine:migrations:migrate

doctrine:migrations:migrate で自動生成したマイグレーションスクリプトは、差分が複雑だと結構な確率で意図どおりの内容になっていなかったりするので、ちゃんと目視で内容を確認するようにしましょう✋

<blockquote class="twitter-tweet"><p lang="ja" dir="ltr">doctrine:migrations:diff で作ったマイグレーションスクリプト、信用せずにちゃんと内容確認しようって5万回ぐらい反省してきたけどまたノールックでコミットして失敗した</p>— たつきち👨‍💻CTO→フリーランス (@ttskch) <a href="https://twitter.com/ttskch/status/1283671406488608768?ref_src=twsrc^tfw">July 16, 2020</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

動作確認

これで、下図のようなログイン画面を表示できるようになりました🙌

4. User に表示名・最終ログイン日時・平文パスワードプロパティを追加

ここでもう一手間、User エンティティにいくつかプロパティを追加してもう少し便利に使えるようにします。

まず、$lastLoggedInAt $displayName $plainPassword という3つのプロパティを追加します。用途はそれぞれ以下のとおりです。

プロパティ 用途
$lastLoggedInAt 最終ログイン日時
$displayName ユーザーの表示名
$plainPassword 平文パスワード(オンメモリでのみ使用)

$lastLoggedInAt

$lastLoggedInAt はログイン処理が行われる度に自動で更新されてほしいので、Authenticatorに更新処理を追加します。

  use App\Entity\User;
+ use Cake\Chronos\Chronos;
  use Doctrine\ORM\EntityManagerInterface;
  use Symfony\Component\HttpFoundation\RedirectResponse;
  use Symfony\Component\HttpFoundation\Request;
  
  // ...
  
  public function onAuthenticationSuccess(Request $request, TokenInterface $token, $providerKey)
  {
+     /** @var User $user */
+     $user = $token->getUser();
+     $user->lastLoggedInAt = Chronos::now();
+     $this->em->persist($user);
+     $this->em->flush();
+ 
      if ($targetPath = $this->getTargetPath($request->getSession(), $providerKey)) {
          return new RedirectResponse($targetPath);
      }
  
      return new RedirectResponse($this->urlGenerator->generate('home_index'));
  }

その際、「現在日時」の情報を使うので、テスト時に簡単にモックできるように cakephp/chronos を使っておきます。

$displayName

$displayName は設定されていない場合は代わりに $email を返してほしいところですが、僕の場合は前述のとおり getter を作らない主義なので、

public function getDisplayName(): string
{
    return $this->displayName ?? $this->email;
}

みたいなことはできません😓

なので、 EntityListener を使って postLoad のタイミングで $displayName の値を自動で初期化するようにします。(後述)

$plainPassword

$plainPassword はオンメモリでのみユーザーが入力した平文パスワードを保持するプロパティです。

これも、EntityListenerで preFlush のタイミングでエンコードして $password プロパティに格納し直すことで、コントローラなどのクライアント側でエンコード処理をしなくて済むようにします。

具体的には、まず以下のような UserListener クラスを作ります。

// src/EntityListener/UserListener.php

class UserListener
{
    private UserPasswordEncoderInterface $encoder;

    public function __construct(UserPasswordEncoderInterface $encoder)
    {
        $this->encoder = $encoder;
    }

    public function postLoad(User $user, LifecycleEventArgs $event)
    {
        $user->displayName = $user->displayName ?? $user->email;
    }

    public function preFlush(User $user, PreFlushEventArgs $event)
    {
        if ($user->plainPassword) {
            $user->password = $this->encoder->encodePassword($user, $user->plainPassword);
        }
    }
}

そして、 src/EntityListener 配下をEntityListenerとしてフレームワークに登録するため、 services.yaml に以下のような記述を加えます。

App\EntityListener\:
    resource: '../src/EntityListener'
    tags: ['doctrine.orm.entity_listener']

あとは、 User エンティティクラスに以下のようにアノテーションを書いて UserListener をEntityListenerとして使うように設定します。

  /**
   * @ORM\Entity(repositoryClass=UserRepository::class)
+  * @ORM\EntityListeners({UserListener::class})
   */
  class User implements UserInterface

以下の過去記事でも詳細に解説しているのであわせてご参照ください。

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

5. エンティティをTimestampableに

さらに User エンティティにもう一手間加えます。

エンティティに $createdAt $updatedAt というプロパティを持たせて、作成・編集する度に自動で日時を記録してくれるように(いわゆる Timestampable に)するために、gedmo/doctrine-extensions を導入します。(自力でやるのは面倒なので)

$ composer require gedmo/doctrine-extensions

でインストールしたら、 config/packages/dodctrine_extensions.yaml を以下の内容で作成します。

services:
  gedmo.listener.timestampable:
    class: Gedmo\Timestampable\TimestampableListener
    tags:
      - { name: doctrine.event_subscriber, connection: default }
    calls:
      - [ setAnnotationReader, [ '@annotation_reader' ] ]

最後に、以下のようにエンティティで use TimestampableEntity; すれば完了です。

  use App\EntityListener\UserListener;
  use App\Repository\UserRepository;
  use Doctrine\ORM\Mapping as ORM;
+ use Gedmo\Timestampable\Traits\TimestampableEntity;
  use Symfony\Component\Security\Core\User\UserInterface;
  
  class User implements UserInterface
  {
+     use TimestampableEntity;

これで、作成日時・更新日時を自動で記録してくれます。楽チンですね😇

6. ユーザー作成コマンドを実装

さて、これでユーザーログイン周りはほぼ完成ですが、今はまだユーザーが1つもないので実際にログインすることができません。

今後のためにも、CLIからユーザーを作成できるようコマンドを作っておきましょう。

まずは、 make:command コマンドで雛形を作成します。

$ bin/console make:command

 Choose a command name (e.g. app:agreeable-puppy):
 > app:user:create

 created: src/Command/UserCreateCommand.php


  Success!


 Next: open your new command class and customize it!
 Find the documentation at https://symfony.com/doc/current/console.html

実装内容は こんな感じ です。

コマンドを実装したら、以下のようにして実行します。

$ bin/console app:user:create test@test.com test -r ROLE_ALLOWED_TO_ADMIN


 [OK] User is created


これで、メールアドレス test@test.com 、パスワード test の管理者ユーザーが作成できました👍(もちろん、本番ではもっとちゃんとしたパスワードで作成してください)