Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                

コネヒト開発者ブログ

コネヒト開発者ブログ

CakePHP の Authentication プラグインを使って、複数の Authenticator を有効にする

こんにちは!久々の登場となります @takoba_ です!!

このエントリは、 コネヒト Advent Calendar 2021 の 8日目 のエントリです。 7日目は @shnagai による 異なるデータソース間の同期がしたくてAWS DMSについて調べたのでまとめてみた でした。

tech.connehito.com

CakePHP の Authentication プラグインとは?

CakePHP は CakePHP4 がリリースされるタイミングで、認可をする機能を cakephp/authorization プラグインに、認証をする機能を cakephp/authentication プラグインに分離しました*1。これらのプラグインは、実は PSR-7 に準拠した Middleware を提供する Composer パッケージになっていたりします。つまりは、 PHP 界隈にとっては非常にナウい実装になるわけです!

(加えて、 Middleware 部などに限って言えば、理論上は他のフレームワークでも流用可能!というわけですね〜〜)

???「○○えもん〜〜ログイン画面でメールアドレスと screen_name のどちらかを ID として許容するようにしたいよ〜〜」

さあ、そんな便利でナウい実装を持つプラグインを使いこなしていきたいわけなんですが、たとえばログイン画面で「メールアドレスと screen_name のどちらかを ID として許容するようにしたいよ〜〜😢」ってこと、ありませんか?

(一旦下を向いてコントに入るフェイントをしつつ)ありますよね〜〜〜?*2

この場合に登場するのが cakephp/authentication プラグインになります。 CakePHP Cookbook では、 CMSを実装することをケーススタディにして認証機能を追加するチュートリアル を用意しており、"メールアドレスとパスワード" といった単一の認証要素パターンでの認証機能の実装を説明していますが、先ほど例示した "メールアドレスとパスワード" または "screen_nameとパスワード" というような複数の認証要素パターンを待ち受ける場合に対して、どのように対処するかは説明されてません でした。

本エントリでは、上記のような問題を解決する実装を説明したいと思います!🙋‍♀️

前提: 今回の登場人物

唐突だが、まずは cakephp/authentication プラグインに登場するクラスたちの紹介だ!🎸

cf. CakePHP4 の認証処理 cakephp/authentication のメモ - Qiita

Authenticator

cf. 認証機能 - CakePHP Authentication 2.x Cookbook

Authenticatorsは、リクエストデータを認証に変換する処理を行います。 それらは、 Identifiers を利用して、既知の Identity Objects を見つけます。

書いてある通りに、リクエストデータから認証要素パターンのデータを抽出します。具体的には POSTパラメータ として入力された情報をもとに認証要素パターンだけが含まれるデータオブジェクト(といっても Hash だけど)に変換する、といった部分を担うかんじです。

Identifier

cf. Identifiers - CakePHP Authentication 2.x Cookbook

Identifiersは認証者がリクエストから抽出した情報に基づいてユーザやサービスを識別します。 Identifiersは loadIdentifier メソッドでオプション指定することができます。

認証者 is (上記で出てきた)Authenticator ですねわかります。その他は書いてあるとおりなんですが、具体的には Authenticator が抽出した認証要素パターンをもとにデータベースなどを照合してユーザーやサービスを識別し導出する、といったかんじです。

AuthenticationService

authentication/AuthenticationService.php at 2.x · cakephp/authentication

認証処理に関する Facade の役割を果たします。推奨されている実装では、CakePHP アプリケーションを束ねる class App\ApplicationgetAuthenticationService() を定義した上で Component や Middleware から呼ばれる実装になっています。

AuthenticationMiddleware

cf. Middleware - CakePHP Authentication 2.x Cookbook

AuthenticationMiddleware は認証プラグインの中で成形しています。 これは、アプリケーションへの各リクエストを捕らえ、いずれかの認証証明証でユーザーの認証を試みます。 各認証機能は、ユーザが認証されるまで、あるいはユーザが見つからないまで順番に試行されます。 認証された場合の ID と認証結果オブジェクトを含むリクエストには authenticationidentityauthenticationResult 属性が設定され、認証子によって提供された追加のエラーを含むことができます。

AuthenticationService を使って、認証処理を実行します。実態は AuthenticationService::authenticate() を実行していて、各種 Authenticator を順繰り実行して認証の試行を行うかんじです。

$service->loadAuthenticator() は同じ Authenticator を複数回読み込めない?

さて、本題に戻ります。

とりあえず、前述した「ログイン画面において、メールアドレスと screen_name のどちらかを ID として許容するようにしたい」という要件を満たそうと、以下のような実装をしたとします。

<?php
declare(strict_types=1);

namespace App;

class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
    // いろいろと略

    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $service = new AuthenticationService();

        // Load the authenticators. Session should be first.
        $service->loadAuthenticator('Authentication.Session');
        $service->loadAuthenticator('Authentication.Form', [
            'fields' => [
                IdentifierInterface::CREDENTIAL_USERNAME => 'email',
                IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
            ],
        ]);
        $service->loadAuthenticator('Authentication.Form', [
            'fields' => [
                IdentifierInterface::CREDENTIAL_USERNAME => 'screen_name',
                IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
            ],
        ]);
        // 以下略
    }

とすると、以下のようなエラーが出てしまい、アプリケーションが起動しなくなりました。

2021-12-0x xx:xx:xx Error: [RuntimeException] The "Form" alias has already been loaded. The `fields` key has a value of `{"username":"screen_name","password":"password"}` but previously had a value of `{"username":"email","password":"password"}` in path/to/app/vendor/cakephp/cakephp/src/Core/ObjectRegistry.php on line 161

これは、 CakePHP で Factory Method パターン を実装する場合に用いられる ObjectRegistry というクラスの仕様なんですが、 ObjectRegistry に登録された object は key-value で管理されているので、 同じ key の object は当然複数持てない、ということになります。困った!

じゃあ、どうする?

でもね、そんなときにもフレームワークの仕様を知ってるとサクッと解決できたりするものです。ここからは上記問題の解決方法を説明していきます。

1. Authenticator 群の設定

まずは Authenticator 群をどのように設定するか考えます。ここでは2つのパターンがあったので、双方説明していきます。

1-1. FormAuthenticator を load する際に alias をつけて回避する

まず、 ObjectRegistry は基本的に key の文字列から Cake\Core\App::className() を用いてクラス名を導出する手筈を採ります。ですが、ObjectRegistry で load する際に className という option を設定すると、keyは自由な文字列を設定することができるようになります。

上記を利用して、実装したのが以下のコードです。

<?php
declare(strict_types=1);

namespace App;

use Authentication\Authenticator\FormAuthenticator;

class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
    // いろいろと略

    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $service = new AuthenticationService();

        // Load the authenticators. Session should be first.
        $service->loadAuthenticator('Authentication.Session');
        $service->loadAuthenticator('Authentication.FormWithEmail', [
            'fields' => [
                IdentifierInterface::CREDENTIAL_USERNAME => 'email',
                IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
            ],
            'className' => FormAuthenticator::class,
       ]);
        $service->loadAuthenticator('Authentication.FormWithScreenName', [
            'fields' => [
                IdentifierInterface::CREDENTIAL_USERNAME => 'screen_name',
                IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
            ],
            'className' => FormAuthenticator::class,
        ]);
        // 以下略
    }

1-2. src/Authenticator/ にカスタムした Authenticator を実装する

もうひとつの方法としては、class Authentication\Authenticator\AbstractAuthenticator を継承したカスタム Authenticator を定義して、それを利用する方法があります。

今回の場合は、まず class Authentication\Authenticator\FormAuthenticator を継承した class App\Authenticator\FormWithEmailAuthenticator を以下のように定義します。

<?php
declare(strict_types=1);

namespace App\Authenticator;

use Authentication\Authenticator\FormAuthenticator;
use Authentication\Identifier\IdentifierInterface;

/**
 * Form Authenticator with email
 */
class FormWithEmailAuthenticator extends FormAuthenticator
{
    /**
     * @inheritDoc
     */
    public function __construct(IdentifierInterface $identifier, array $config = [])
    {
        parent::__construct($identifier, $config);

        $this->setConfig('fields', [
            IdentifierInterface::CREDENTIAL_USERNAME => 'email',
            IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
        ]);
    }
}

上記に倣って、 class App\Authenticator\FormWithScreenNameAuthenticator も定義しておきます。

そのあとに、 Application::getAuthenticationService() で、以下のように設定します。

<?php
declare(strict_types=1);

namespace App;

class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
    // いろいろと略

    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $service = new AuthenticationService();

        // Load the authenticators. Session should be first.
        $service->loadAuthenticator('Authentication.Session');
        $service->loadAuthenticator('FormWithEmail');
        $service->loadAuthenticator('FormWithScreenName');
        // 以下略
    }

どうです?こっちの方がシュッとしてるかんじもありますね。

2. PasswordIdentifier は割と柔軟なので、ちょっとconfigを変えればいけちゃう

1.で Authenticator の設定は完了しましたが、続いては Identifier の設定も変更する必要があります。

パスワード認証に用いる class Authentication\Identifier\PasswordIdentifier は割と柔軟に認証要素の拡張ができるので、以下のように ['email', 'screen_name'] として email も screen_name も許容するように設定してあげます。

<?php
declare(strict_types=1);

namespace App;

class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
    // いろいろと略

    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $service = new AuthenticationService();

        // Load the authenticators. Session should be first.
        $service->loadAuthenticator('Authentication.Session');
        $service->loadAuthenticator('FormWithEmail');
        $service->loadAuthenticator('FormWithScreenName');

        // Load identifiers
        $service->loadIdentifier('Authentication.Password', [
            'fields' => [
                IdentifierInterface::CREDENTIAL_USERNAME => ['email', 'screen_name'],
                IdentifierInterface::CREDENTIAL_PASSWORD => 'password',
            ],
        ]);

        return $service;
    }

これで、求められていた「ログイン画面において、メールアドレスと screen_name のどちらかを ID として許容するようにしたい」という要件が達成できました!助かったよ○○えもん!🐱

おわりに

どうですか?フレームワークやプラグインの仕様を知ってると、ちょっと設定を変えるだけでこんなに柔軟に対応できるようになりました!とてもハッピ〜〜👏

みんなも CakePHP や cakephp/authentication プラグインと仲良くなってハッピーライフを過ごしてください!🙌

コネヒト社では、ぼくらと一緒に CakePHP に詳しくなりながらバリバリ開発を推進していってくれるエンジニアたちを大募集しております!以下よりぜひ応募してくれよな〜〜🤝

明日は @otukutun によるエントリです!おたのしみに〜〜👋

参考

*1:旧来利用されていた AuthComponent は、 CakePHP 4.0.0 がリリースされたタイミングで非推奨となりました

*2:怪奇!YesどんぐりRPGの漫才「財布を拾った」より