4
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

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

Last updated at Posted at 2020-05-03

CakePHP4 で Auth0 を使う。まずは Auth0 に慣れる

CakePHP4 の認証処理 cakephp/authentication のメモ
で、 Auth0 の使い方と cakephp/authentication でどのように認証を行うかがわかったので、 Auth0 と cakephp/authentication を使った認証を実装します。

この記事でわかること

バージョン情報

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

事前準備

docker-compose up -d

ライブラリの導入

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

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 設定でセットした内容です。

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 へも同様の記述を残していきます。
秘密的な情報はあまり記載したくないので、自分は以下の内容にしています。

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 を追加します。

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.phpAuthenticationService を生成するメソッドを追加します。
./src/Application.phpAuthenticationServiceProviderInterface を実装する必要があります。

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

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

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

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

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.phpAuthentication コンポーネント を適用して、すべての Controller の処理が認証処理を通るようにします。

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() の3つです。
identity(), user(), cache() は、それぞれ、Auth0でどのような情報がセット・取得できるかを見たくて作ったメソッドなので、実際は不要です。

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 にセットした内容に合わせる必要があります。

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 のみをセットしています。

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']));
    }
}
4
9
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?