CakePHP4 で Auth0 を使う。まずは Auth0 に慣れる
と
CakePHP4 の認証処理 cakephp/authentication のメモ
で、 Auth0 の使い方と cakephp/authentication でどのように認証を行うかがわかったので、 Auth0 と cakephp/authentication を使った認証を実装します。
この記事でわかること
- CakePHP4 での認証処理 cakephp/authentication でAuth0 を使った認証方法。
- この記事内のソースは以下で公開しています。
バージョン情報
バージョン | |
---|---|
CakePHP4 | 4.0.6 |
auth0/auth0-php | 7.2.0 |
cakephp/authentication | 2.1.0 |
事前準備
- CakePHP4 を docker-compose で動くようにする の記事内容のソースから発展させてます。
- docker-compose を実行し、コンテナを立ち上げます。
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 を記載する。
- Allowed Callback URLs:「 http://localhost/users/callback 」を入力
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 設定でセットした内容です。
# 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 へも同様の記述を残していきます。
秘密的な情報はあまり記載したくないので、自分は以下の内容にしています。
# 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)。
<?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 を追加します。
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つのメソッドを用意しています。
2点目)
AuthenticationService を生成するときのオプション identityClass
を指定しています。
Identity クラスを作るとき、通常は ユーザー情報の id をユーザーを特定する値のキーとして使いますが、Auth0 のユーザー情報(JWT)に合わせるために sub を id として扱うように指定しています。
3点目)
AuthenticationService#loadIdentifier() を今回は利用しません。
Auth0からもらった情報をそのままユーザー情報として利用することにしているためです。
これはアプリケーションの作りによっては loadIdentifier() で独自の Identifier を実装し利用するケースもあると思います。
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 の処理が認証処理を通るようにします。
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でどのような情報がセット・取得できるかを見たくて作ったメソッドなので、実際は不要です。
<?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 にセットした内容に合わせる必要があります。
// ... 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
のみをセットしています。
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']));
}
}