CakePHP4 の認証処理 cakephp/authentication で Auth0 を使った認証を行う

[CakePHP4 で Auth0 を使う。まずは Auth0 に慣れる](https://qiita.com/katsuhiko/items/88522f2285939f7c8847)
と
[CakePHP4 の認証処理 cakephp/authentication のメモ](https://qiita.com/katsuhiko/items/716079aef687a967af99)
で、 Auth0 の使い方と cakephp/authentication でどのように認証を行うかがわかったので、 Auth0 と cakephp/authentication を使った認証を実装します。


# この記事でわかること

- CakePHP4 での認証処理 cakephp/authentication でAuth0 を使った認証方法。
- この記事内のソースは以下で公開しています。
    - https://github.com/katsuhiko/cakephp-vue-study


# バージョン情報

|  | バージョン |
|:-----------------|:------------------|
| CakePHP4         | 4.0.6 |
| auth0/auth0-php  | 7.2.0 |
| cakephp/authentication  | 2.1.0 |

# 事前準備

- [CakePHP4 を docker-compose で動くようにする](https://qiita.com/katsuhiko/items/f18c488457af33abdf66) の記事内容のソースから発展させてます。
- docker-compose を実行し、コンテナを立ち上げます。

```sh
docker-compose up -d
```


# ライブラリの導入

Composer で Auth0, cakephp/authentication を導入します。
Dockerコンテナ経由で導入しているので、以下のコマンドです。

```sh
docker exec -it app php composer.phar require auth0/auth0-php
docker exec -it app php composer.phar require cakephp/authentication:^2.0
```


# Auth0 の設定


Auth0 のアカウント作成方法は、他サイトを参考にしてください。
アカウント作成後からの手順をメモがわりに残します。


## Application の作成

- 左メニュー「Applications」をクリック
- 画面右「＋ CREATE APPLICATION」ボタンをクリック
- 以下を入力＆選んで「CREATE」ボタンをクリック
    - Name：自身で決めたアプリケーション名を入力
    - Choose an application type：「Regular Web Applications」を選択

これで Application が作成されます。


## Application の設定

- 左メニュー「Applications」をクリック
- 一覧から該当するアプリケーション名リンクをクリック
- タブ「Settings」をクリック
- 以下を入力して「SAVE CHANGES」ボタンをクリック
    - Allowed Callback URLs：「 http://localhost/users/callback 」を入力
        - Authorization Code Flow での処理で Code を受け取る URL を記載する。
    - Allowed Logout URLs：「 http://localhost/home 」を入力
        - ログアウト後にリダイレクトする（リダイレクト先として指定する） URL を記載する。


## envファイル

Auth0 に関する設定情報を ./config/.env ファイルへ追加します。
追加する設定情報は以下です。

AUTH0_DOMAIN、AUTH0_CLIENT_ID、AUTH0_CLIENT_SECRET は、 Auth0 管理画面（ Auth0 左メニュー「Applications」 > 該当アプリケーション名リンク > タブ「Settings」 ）の Domain、Client ID、Client Secret を記載します。

AUTH0_CALLBACK_URL、AUTH0_LOGOUT_URL は、Auth0 Application 設定でセットした内容です。

```sh:config/.env
# Auth0
export AUTH0_DOMAIN="xxxx.auth0.com"
export AUTH0_CLIENT_ID="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export AUTH0_CLIENT_SECRET="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
export AUTH0_CALLBACK_URL="http://localhost/users/callback"
export AUTH0_LOGOUT_URL="http://localhost/home"
```

他の人にも展開することを考えて、 ./config/.env.example へも同様の記述を残していきます。
秘密的な情報はあまり記載したくないので、自分は以下の内容にしています。

```sh:config/.env.example
# Auth0
export AUTH0_DOMAIN="example.auth0.com"
export AUTH0_CLIENT_ID=""
export AUTH0_CLIENT_SECRET=""
export AUTH0_CALLBACK_URL="http://localhost/users/callback"
export AUTH0_LOGOUT_URL="http://localhost/home"
```


# Authenticator の実装

`./src/Authenticator` ディレクトリを作成して、その下に `Auth0Authenticator.php` を作成します。

※ `./src/Authenticator` に作る必要がある点に興味を持った場合、 AuthenticatorCollection#_resolveClassName() ( https://github.com/cakephp/authentication/blob/master/src/Authenticator/AuthenticatorCollection.php#L81 ) を見ると詳しくわかります。個人的には昨今の Framework には DI 必須だと思ってるんですが、PHPのようなスクリプト言語だと、このような Service Locator による解決もありかなっと思いました。

AccessToken を利用してユーザー情報を取得するサンプルとしているため、 `persist_access_token` を true としています。
認証のみであれば、AccessToken は不要のため、`persist_access_token` を指定する必要はありません（デフォルト false）。

```src/Authenticator/Auth0Authenticator.php
<?php
declare(strict_types=1);

namespace App\Authenticator;

use Auth0\SDK\Auth0;
use Authentication\Authenticator\AbstractAuthenticator;
use Authentication\Authenticator\Result;
use Authentication\Authenticator\ResultInterface;
use Psr\Http\Message\ServerRequestInterface;

/**
 * Auth0 Authenticator
 */
class Auth0Authenticator extends AbstractAuthenticator
{
    /**
     * @var array
     */
    protected $_defaultConfig = [];

    /**
     * @return \Auth0\SDK\Auth0
     */
    private function auth0(): Auth0
    {
        return new Auth0([
            'domain' => env('AUTH0_DOMAIN', ''),
            'client_id' => env('AUTH0_CLIENT_ID', ''),
            'client_secret' => env('AUTH0_CLIENT_SECRET', ''),
            'redirect_uri' => env('AUTH0_CALLBACK_URL', ''),
            'scope' => 'openid profile email',
            'persist_access_token' => true,
        ]);
    }

    /**
     * @param \Psr\Http\Message\ServerRequestInterface $request request
     * @return \Authentication\Authenticator\ResultInterface
     */
    public function authenticate(ServerRequestInterface $request): ResultInterface
    {
        $user = $this->auth0()->getUser();

        if (empty($user)) {
            return new Result(null, Result::FAILURE_IDENTITY_NOT_FOUND);
        }

        // $user は array なので ArrayObject へ変換しなくても OK。
        // if (!($user instanceof ArrayAccess)) {
        //     $user = new ArrayObject($user);
        // }

        return new Result($user, Result::SUCCESS);
    }
}
```


# Middlewareの適用

cakephp/authentication を利用するために `./src/Application.php` へ Middleware を追加します。

RoutingMiddleware よりあとに AuthenticationMiddleware を追加します。

```php:src/Application.php
    public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
    {
        $middlewareQueue
            ->add(new ErrorHandlerMiddleware(Configure::read('Error')))

            ->add(new AssetMiddleware([
                'cacheTime' => Configure::read('Asset.cacheTime'),
            ]))

            ->add(new RoutingMiddleware($this))

            // add Authentication after RoutingMiddleware
            ->add(new AuthenticationMiddleware($this));

        return $middlewareQueue;
    }
```

`./src/Application.php` へ `AuthenticationService` を生成するメソッドを追加します。
`./src/Application.php` は `AuthenticationServiceProviderInterface` を実装する必要があります。

3点ポイントがあります。

1点目）
API処理で未認証の場合、ログイン画面へのリダイレクトではなく、 `401 Unauthorized` を返却したいため、AuthenticationService を生成するときのオプション `unauthenticatedRedirect` に `null` をセットしています。
そのため APIアクセス用とページアクセス用の２つのメソッドを用意しています。

2点目）
AuthenticationService を生成するときのオプション `identityClass` を指定しています。
Identity クラスを作るとき、通常は ユーザー情報の id をユーザーを特定する値のキーとして使いますが、Auth0 のユーザー情報（JWT）に合わせるために sub を id として扱うように指定しています。

3点目）
AuthenticationService#loadIdentifier() を今回は利用しません。
Auth0からもらった情報をそのままユーザー情報として利用することにしているためです。
これはアプリケーションの作りによっては loadIdentifier() で独自の Identifier を実装し利用するケースもあると思います。

```php:src/Application.php
class Application extends BaseApplication implements AuthenticationServiceProviderInterface
{
    // ... snip
    /**
     * @param \Psr\Http\Message\ServerRequestInterface $request request
     * @return \Authentication\AuthenticationServiceInterface
     */
    public function getAuthenticationService(ServerRequestInterface $request): AuthenticationServiceInterface
    {
        $path = $request->getUri()->getPath();
        $isApi = (strpos($path, '/api/') === 0);

        if ($isApi) {
            return $this->getAuthenticationServiceForApi();
        } else {
            return $this->getAuthenticationServiceForPage();
        }
    }

    /**
     * @return \Authentication\AuthenticationServiceInterface
     */
    private function getAuthenticationServiceForApi(): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => null,
            'queryParam' => 'redirect',
            'identityClass' => function ($identityData) {
                return new Identity($identityData, [
                    'fieldMap' => [
                        'id' => 'sub',
                    ],
                ]);
            },
        ]);

        // Auth0 のユーザー情報から、別途 アプリのユーザー情報を取得するのであれば、 Identifier を実装して、
        // Authenticator から呼び出す必要がある。
        // $authenticationService->loadIdentifier('Authentication.Password');

        $authenticationService->loadAuthenticator('Auth0');

        return $authenticationService;
    }

    /**
     * @return \Authentication\AuthenticationServiceInterface
     */
    private function getAuthenticationServiceForPage(): AuthenticationServiceInterface
    {
        $authenticationService = new AuthenticationService([
            'unauthenticatedRedirect' => '/users/login',
            'queryParam' => 'redirect',
            'identityClass' => function ($identityData) {
                return new Identity($identityData, [
                    'fieldMap' => [
                        'id' => 'sub',
                    ],
                ]);
            },
        ]);

        $authenticationService->loadAuthenticator('Auth0');

        return $authenticationService;
    }
    // ... snip
}
```


# Component の適用

`./src/Contrller/AppController.php` へ `Authentication` コンポーネント を適用して、すべての Controller の処理が認証処理を通るようにします。

```php:src/Controller/AppController.php
class AppController extends Controller
{
    public function initialize(): void
    {
        parent::initialize();

        $this->loadComponent('RequestHandler');
        $this->loadComponent('Flash');

        $this->loadComponent('Authentication.Authentication');
    }
}
```

# ログイン用の Controller の実装

ログイン用として、 `./src/Controller/UsersController.php` を実装します。
実装するポイントは、コメントで記載しました。

必須なメソッドは login(), logout(), callback() の３つです。
identity(), user(), cache() は、それぞれ、Auth0でどのような情報がセット・取得できるかを見たくて作ったメソッドなので、実際は不要です。

```php:src/Controller/UsersController.php
<?php
declare(strict_types=1);

namespace App\Controller;

use Auth0\SDK\API\Authentication;
use Auth0\SDK\Auth0;
use Cake\Event\EventInterface;
use Cake\Http\Response;

/**
 * Users Controller
 */
class UsersController extends AppController
{
    /**
     * @param \Cake\Event\EventInterface $event event
     * @return void
     */
    public function beforeFilter(EventInterface $event): void
    {
        parent::beforeFilter($event);
        $this->Authentication->addUnauthenticatedActions(['login', 'logout', 'callback']);
    }

    /**
     * @return \Auth0\SDK\Auth0
     */
    private function auth0(): Auth0
    {
        return new Auth0([
            'domain' => env('AUTH0_DOMAIN', ''),
            'client_id' => env('AUTH0_CLIENT_ID', ''),
            'client_secret' => env('AUTH0_CLIENT_SECRET', ''),
            'redirect_uri' => env('AUTH0_CALLBACK_URL', ''),
            'scope' => 'openid profile email',
            'persist_access_token' => true,
        ]);
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function login(): ?Response
    {
        // Session を破棄して、Auth0のユーザー情報を空にする。
        // そうしないと、Auth0->exchange() で
        // Can't initialize a new session while there is one active session already
        // が発生する。
        $this->getRequest()->getSession()->destroy();

        // ログイン後、認証前にアクセスしたページへ移動できるようにセッションに保持する。
        $redirect = $this->getRequest()->getQuery('redirect', '/');
        $this->getRequest()->getSession()->write('Login.redirect', $redirect);

        $auth0 = $this->auth0();
        $loginUrl = $auth0->getLoginUrl();

        return $this->redirect($loginUrl);
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function logout(): ?Response
    {
        $this->Authentication->logout();

        $auth0 = $this->auth0();
        $auth0->logout();

        /** @var string $domain */
        $domain = env('AUTH0_DOMAIN', '');
        /** @var string $clientId */
        $clientId = env('AUTH0_CLIENT_ID', '');
        $authApi = new Authentication($domain, $clientId);

        /** @var string $returnTo */
        $returnTo = env('AUTH0_LOGOUT_URL', '');
        $logoutUrl = $authApi->get_logout_link($returnTo, $clientId);

        return $this->redirect($logoutUrl);
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function callback(): ?Response
    {
        // Auth0->exchange() は、 Auth0Authenticator->authenticate() メソッドの Auth0->getUser() で実施しているので、
        // ここで Auth0->exchange() は行わない。
        // ここで Auth0->exchange() を行うと「 Invalid state 」が発生する。
        // $auth0 = $this->auth0();
        // $auth0->exchange();

        // 認証前にアクセスしたページへ移動する。
        /** @var string $redirect */
        $redirect = $this->getRequest()->getSession()->read('Login.redirect');

        return $this->redirect($redirect);
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function identity(): ?Response
    {
        $identity = $this->Authentication->getIdentity();

        if ($identity) {
            debug($identity->getIdentifier());
            debug($identity->getOriginalData());
        }

        return $this->render();
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function user(): ?Response
    {
        $auth0 = $this->auth0();

        /** @var string $domain */
        $domain = env('AUTH0_DOMAIN', '');
        /** @var string $clientId */
        $clientId = env('AUTH0_CLIENT_ID', '');
        $authApi = new Authentication($domain, $clientId);

        /** @var string $accessToken */
        $accessToken = $auth0->getAccessToken();
        $user = $authApi->userinfo($accessToken);

        debug($user);

        return $this->render();
    }

    /**
     * @return \Cake\Http\Response|null
     */
    public function cache(): ?Response
    {
        $auth0 = $this->auth0();

        debug($auth0->getUser());
        debug($auth0->getAccessToken());
        debug($auth0->getIdToken());
        debug($auth0->getRefreshToken());

        return $this->render();
    }
}
```


# routes の定義

UsersContoller のルートを定義します。
Code を受け取る callback URL とログアウト後に表示するURL（今回は /home ）は、Auth0 Application にセットした内容に合わせる必要があります。

```php:src/Config/routes.php
// ... snip
$routes->scope('/', function (RouteBuilder $builder) {
    // Register scoped middleware for in scopes.
    $builder->registerMiddleware('csrf', new CsrfProtectionMiddleware([
        'httpOnly' => true,
    ]));

    $builder->applyMiddleware('csrf');

    $builder->connect('/home', ['controller' => 'Home', 'action' => 'index']);

    $builder->connect('/users/login', ['controller' => 'Users', 'action' => 'login']);
    $builder->connect('/users/logout', ['controller' => 'Users', 'action' => 'logout']);
    $builder->connect('/users/callback', ['controller' => 'Users', 'action' => 'callback']);
    $builder->connect('/users/user', ['controller' => 'Users', 'action' => 'user']);
    $builder->connect('/users/cache', ['controller' => 'Users', 'action' => 'cache']);
    $builder->connect('/users/identity', ['controller' => 'Users', 'action' => 'identity']);
    // ... snip
});
// ... snip
```

# 確認

これで認証が必要なページ（Controller）へアクセスすると、Auth0のログイン画面が表示され、ログイン後、認証前にアクセスしたページが表示される（リダレクトされる）動きを確認することが出来ます。


# 補足） テストケースの修正

認証処理を行うとテストでエラーが発生します。
今回は、エラーを回避するために事前にセッション情報にユーザー情報をセットする方法を利用します。

Auth0 は セッション情報のキー `auth0__user` にユーザー情報を格納しています。この値に最低限のユーザー情報 `sub` のみをセットしています。

```php:tests/TestCase/Controller/Api/Task/SearchTaskControllerTest.php
class SearchTaskControllerTest extends TestCase
{
    use IntegrationTestTrait;

    /**
     * Fixtures
     *
     * @var array
     */
    protected $fixtures = [
        'app.Tasks',
    ];

    /**
     * setUp method
     *
     * @return void
     */
    public function setUp(): void
    {
        parent::setUp();
        $this->configRequest([
            'headers' => [
                'Accept' => 'application/json',
            ],
        ]);

        // 認証を通す。
        $this->session([
            'auth0__user' => [
                'sub' => 'auth0|xxxxxxxxxxxxxxxxxxxxxxxx',
            ],
        ]);
    }

    /**
     * @return void
     */
    public function test_検索できること(): void
    {
        // Arrange
        Fabricate::create('Tasks', 3);

        // Act
        $this->get('/api/ca-task/search.json');

        // Assert
        $this->assertResponseOk();
        $actual = json_decode(strval($this->_response->getBody()), true);
        $this->assertSame(3, count($actual['data']));
    }

    /**
     * @return void
     */
    public function test_タスク内容で絞って検索できること(): void
    {
        // Arrange
        Fabricate::create('Tasks', 2, ['description' => '検索キーワードあり']);
        Fabricate::create('Tasks', 1, ['description' => '何もなし']);

        // Act
        $this->get('/api/ca-task/search.json?description_like=キーワード');

        // Assert
        $this->assertResponseOk();
        $actual = json_decode(strval($this->_response->getBody()), true);
        $this->assertSame(2, count($actual['data']));
    }

    /**
     * @return void
     */
    public function test_タスク内容が空の場合入力チェックエラーとなること(): void
    {
        // Arrange
        Fabricate::create('Tasks', 3);

        // Act
        $this->get('/api/ca-task/search.json?description_like=');

        // Assert
        $this->assertResponseCode(403);
        $actual = json_decode(strval($this->_response->getBody()), true);
        $this->assertSame(1, count($actual['errors']));
    }
}
```