1. はじめに
こんにちは。
近年、Webアプリケーションに対する脆弱性の検出・報告のペースは上がり続けています。従来型のCMSは管理画面と公開画面が同一のアプリケーション上に同居するため、公開側に管理機能のエンドポイントが露出します。これは攻撃を受ける面がそのぶん広いということでもあります。
そうした中で注目されているのが「ヘッドレス方式」による管理画面と公開画面の分離です。管理機能を公開側から切り離すことで、外部に露出する範囲を必要最小限に絞れます。もちろん管理画面自体がなくなるわけではないので、そちら側の保護は引き続き必要です。
baserCMS 5 には、この仕組みが初期バージョンから備わっています。
ヘッドレス方式自体の情報はそれなりに出回っているので、今回は「フロントユーザーの認証」についてピックアップしたいと思います。
検証環境は baserCMS 5.4.x(開発版)です。
2. 独自のユーザーテーブルを使いたい
今回の記事ですが、フロント側にログイン機能を持つ既存システムを baserCMS 5 に移行する際に
既存のユーザーテーブルをそのまま引き継ぎたい、という経緯が出発点です。
baserCMS 5 にもフロントユーザーの仕組みはあります。ユーザーグループの「認証プレフィックス」にフロントページを指定することで、フロント側のログインが利用できます。Web API についても、管理APIに対して JWT でトークンを発行する仕組みが標準で用意されています。設定手順は公式ガイドにまとまっているので、そちらをご覧ください。
ただし、この標準機能が認証に使うのはコアの users テーブルです。今回の要件に照らすと、いくつか噛み合わない点がありました。
- 移行元のテーブル構造をそのまま載せられない
- 会員特有のカラムを追加しようにも、コアが同じテーブルを使っているため、カラム名の衝突やアップデート時のデグレのリスクを抱えることになります。
- フロントユーザーと管理ユーザーが同居する
- 会員数が増えると、管理画面のユーザー一覧に会員が混ざることになります。
- 権限制御が今回の用途には過剰
- ユーザーグループによる権限判定は管理者向けに設計された仕組みで、今回必要だったのは「ログインしているかどうか」程度の単純な判定でした。
独自のユーザーテーブルをそのまま使いつつ、baserCMS 5 の認証の仕組みには載せる。これができれば理想的です。
そこで、どこまで拡張ポイントが用意されているのかを調べることにしました。
3. 独自テーブルを使ったフロントユーザー認証
3-1. baserCMS 5 の認証の仕組み
baserCMS 5 の認証は、大きく2つの要素で成り立っています。
① 設定:BcPrefixAuth
baserCMS 5 は URL のプレフィックス(Admin / Api/Admin / Front …)ごとに認証設定を持っています。リクエストが来るとそのプレフィックスに対応する設定を引き、認証の仕組み(セッション認証かJWT認証か、どのカラムをユーザー名とパスワードとして扱うか)を組み立てます。
その設定項目のひとつが userModel、つまりどのテーブルを見るかです。ここが差し替えポイントになります。
さらに、baserCMS 5 はこの設定配列を読んでルーティングまで自動生成します。プレフィックスを1つ宣言すれば、URLも認証も付いてくるわけです。
② インタフェース:UserInterface
baserCMS が「ユーザーエンティティ」に求めるものは、UserInterface を実装し、4つのメソッドを定義することだけです。
| メソッド名 | 役割 | 会員の場合 |
|---|---|---|
| isSuper() | スーパーユーザーかどうかの判定 | 常にfalse |
| isAdmin() | 管理者ユーザーかどうかの判定 | 常にfalse |
|
getDisplayName() |
表示名称の返却 | ニックネーム等 |
|
getAuthPrefixes() |
所属する認証プレフィックスの返却 | 自分のプレフィックス |
3-2. 認証設定
今回は MemberAuth という会員認証プラグインを作り、その config/setting.php に独自のプレフィックスを1つ宣言します。
<?php
return [
'BcPrefixAuth' => [
'Api/Member' => [
'name' => '会員 Web API',
'type' => 'Jwt',
'alias' => 'api/member',
'username' => 'email',
'password' => 'password',
// 独自テーブルを指定する
'userModel' => 'MemberAuth.Members',
'isRestApi' => true,
],
],
];
差し替えの要になるのが userModel です。ここに独自テーブルを指定するだけで、以降の認証はこのテーブルを見るようになります。username と password には、認証に使うカラム名を指定します。
alias は実際のURLに現れる部分で、この例では /api/member/... でアクセスすることになります。
なお、この設定だけではまだ動きません。実際に叩くと想定外のエラーが返ってきたのですが、それは4章で扱います。
3-3. エンティティ
UserInterface を実装します。
<?php
declare(strict_types=1);
namespace MemberAuth\Model\Entity;
use Authentication\PasswordHasher\DefaultPasswordHasher;
use BaserCore\Model\Entity\UserInterface;
use Cake\ORM\Entity;
/**
* Member Entity
*
* baserCMS 側と接続するための唯一の契約が UserInterface。
* この4メソッドを実装していれば、コアの認証まわりはそのまま動く。
*
* @property int $id
* @property string $email
* @property string $password
* @property string $nickname
* @property bool $status
*/
class Member extends Entity implements UserInterface
{
protected array $_accessible = [
'*' => true,
'id' => false,
];
protected array $_hidden = [
'password',
];
/**
* パスワードをハッシュ化する
*/
protected function _setPassword($value)
{
if (!$value) {
return false;
}
return (new DefaultPasswordHasher())->hash($value);
}
/**
* スーパーユーザーかどうか
* 会員は管理権限を持たないため常に false
*/
public function isSuper(): bool
{
return false;
}
/**
* 管理者ユーザーかどうか
* 会員は管理権限を持たないため常に false
*/
public function isAdmin(): bool
{
return false;
}
/**
* 表示名称を取得
*/
public function getDisplayName(): string
{
return (string)$this->nickname;
}
/**
* 認証領域のプレフィックスを取得
*/
public function getAuthPrefixes(): array
{
// setting.php の BcPrefixAuth で宣言したキーと一致させる
return ['Api/Member'];
}
}
isSuper() から getAuthPrefixes() までの4つが UserInterface の実装です。会員は管理権限を持たないので、前の2つは常に false を返します。
_setPassword() はこれとは別で、CakePHP のミューテータによるパスワードのハッシュ化です。認証の契約とは関係ありませんが、これがないと平文で保存されてしまいます。ハッシュ方式はコアの User エンティティと同じ DefaultPasswordHasher を使っており、ここが揃っていないと認証が通りません。
なお、CakePHP のミューテータは戻り値をそのままフィールドに代入します。値が空のときに false を返しても「更新しない」という意味にはならず、保存するとパスワードが空文字で上書きされてしまいます。パスワードを伴わないプロフィール更新を実装する場合は、Table の beforeMarshal() で、入力が空なら password キー自体を外す必要があります。コアの UsersTable もこの方式です。
補足
_accessible は簡略化のため全許可にしています。会員登録やプロフィール更新を実装する際は、更新を許可するカラムを明示的に列挙することをおすすめします。
3-4. テーブル
<?php
declare(strict_types=1);
namespace MemberAuth\Model\Table;
use ArrayObject;
use Cake\Event\EventInterface;
use Cake\ORM\Query\SelectQuery;
use Cake\ORM\Table;
/**
* Members Table
*
* 他システムから移行されてきた members テーブルを扱う。
*/
class MembersTable extends Table
{
public function initialize(array $config): void
{
parent::initialize($config);
// 移行元のテーブル名が members でない場合はここで変更する
$this->setTable('members');
}
/**
* パスワードが空の場合はキー自体を外す
*
* ミューテータの戻り値は無条件に代入されるため、
* ここで外しておかないと既存のハッシュが上書きされる。
* $data は ArrayObject のため、null も拾えるよう offsetExists() で判定する。
*/
public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)
{
if ($data->offsetExists('password') && !$data['password']) {
unset($data['password']);
}
}
/**
* 認証可能な会員を絞り込む
*
* JWT 認証の場合、BcPrefixAuth で finder を指定しなければ available が使われる。
* ログイン時と JWT 検証時の両方から呼ばれ、
* JWT 検証時はプレフィックスが引数で渡ってくるため任意引数で受ける。
*/
public function findAvailable(SelectQuery $query, ?string $prefix = null): SelectQuery
{
return $query->where(['Members.status' => true]);
}
}
beforeMarshal() は、会員登録やプロフィール更新を実装したときに効いてくるものです。本記事の範囲(ログインと会員情報取得)では発火しませんが、パスワードを伴わない更新で既存のハッシュが消えるのを防ぐために必要になります。コアの UsersTable も同じ考え方で、入力があるときだけ password キーを立てています。
ここで効いてくるのが findAvailable() です。CakePHP の finder の命名規約で、設定値 'available' に対して findAvailable() が呼ばれます。
JWT 認証の場合、BcPrefixAuth で finder を指定しなければ available が既定で使われます。退会・停止した会員をログインさせないといった制御は、ここで絞り込むのがコアの流儀です。ログイン時と、発行済みトークンの検証時の両方から呼ばれるので、一度書けば両方の経路で効きます。
JWT 検証時にはプレフィックスが引数として渡ってくるため、任意引数で受けておく必要があります。
3-5. ログインAPI
<?php
declare(strict_types=1);
namespace MemberAuth\Controller\Api\Member;
use Authentication\Controller\Component\AuthenticationComponent;
use BaserCore\Controller\Api\BcApiController;
/**
* 会員 Web API
*
* @property AuthenticationComponent $Authentication
*/
class MembersController extends BcApiController
{
public function initialize(): void
{
parent::initialize();
$this->loadComponent('Authentication.Authentication');
$this->Authentication->allowUnauthenticated(['login']);
}
/**
* ログイン(アクセストークン発行)
*/
public function login()
{
$this->request->allowMethod(['post']);
$json = [];
$result = $this->Authentication->getResult();
if ($result->isValid()) {
$json = $this->getAccessToken($result);
}
if (!$json) {
$this->setResponse($this->response->withStatus(401));
}
$this->set('json', $json);
$this->viewBuilder()->setOption('serialize', 'json');
}
/**
* ログイン中の会員情報を返す(要認証)
*/
public function me()
{
$this->request->allowMethod(['get']);
$member = $this->Authentication->getIdentity()?->getOriginalData();
$this->set('member', $member);
$this->viewBuilder()->setOption('serialize', 'member');
}
}
認証の実処理は AuthenticationComponent が済ませているので、コントローラ側は結果を受け取るだけです。
トークンの発行は、BcApiController が持つ getAccessToken() に任せます。内部では BcApiUtil::createAccessToken() が呼ばれ、アクセストークンとリフレッシュトークンの2つを返します。 それぞれのペイロードには token_type が入っており、sub は {プレフィックス}_{ユーザーID} の形式です。プレフィックスが含まれるため、管理ユーザーのトークンと会員のトークンは混ざりません。トークンの領域分離がここで効いています。
ここまでが最小構成です。 認証設定を1つ宣言し、エンティティに4つのメソッドを実装し、テーブルとログインAPIを用意する。これだけで、ルーティング・JWT の発行と検証・トークンの領域分離まで、コアの仕組みに乗れます。
ただし、この状態で実際にAPIを叩くと期待どおりには動きません。次章でその話をします。
4. 実装でつまずいたポイント
前章の最小構成でAPIを叩いてみたところ、いくつか期待どおりに動きませんでした。
原因を追っていくと、どれも同じところに行き着きます。
baserCMS 5 のAPI用コントローラは2層になっています。
| クラス | 役割 |
|---|---|
| BcApiController | API共通の基盤(同一オリジン検証、トークン生成、JSON出力) |
| BcAdminApiController | 管理API固有の処理(トークン種別検査、ユーザー有効チェック、FormProtection 無効化) |
独自プレフィックスを立てて BcApiController を直接継承すると、下の層が担っていた処理が丸ごと抜け落ちます。
以下はその結果として踏んだものです。
4-1. 権限チェックを外し忘れると、403ではなく500エラーになる
baserCMS 5 はプレフィックスごとに、ユーザーグループに基づくアクセス権限チェックを行います。既定はホワイトリスト方式です。
ここで問題になるのが、独自テーブルの会員は baserCMS のユーザーグループを持たないという点です。
許可されたルールが1つも見つからないので、全リクエストが拒否されます。しかも返ってくるのは403ではありません。
500 {"message":"An Internal Error Has Occurred."}
[TypeError] Cake\Controller\Controller::redirect(): Argument #1 ($url) must be of type
UriInterface|array|string, null given, called in .../AppController.php on line 190
原因は AppController::beforeFilter() にありました。権限チェックに落ちたあと、プレフィックスごとに処理が分かれています。
if ($this->requirePermission($this->getRequest()) && !$this->checkPermission()) {
$prefix = BcUtil::getRequestPrefix($this->getRequest());
if ($prefix === 'Api/Admin') {
throw new ForbiddenException(...);
} else {
if (BcUtil::loginUser()) {
$this->BcMessage->setError(...);
$url = Configure::read("BcPrefixAuth.{$prefix}.loginRedirect"); // ← ここが null
} else {
$url = Router::url(Configure::read("BcPrefixAuth.{$prefix}.loginAction"), true)
. '?redirect=' . rawurlencode(...);
}
$event->setResult($this->redirect($url));
403を返すのは Api/Admin だけで、それ以外のプレフィックスはすべてリダイレクト処理に流れます。さらにその中が2分岐していて、認証自体は通っている(ログイン済み)場合は loginRedirect を参照します。
今回はJWTでの認証が成功したうえで権限チェックに落ちているため、BcUtil::loginUser() が真になり、こちらの分岐に入ります。独自プレフィックスには loginRedirect が未定義なので、null のまま redirect() に渡って TypeError になっていました。
対処は設定1行です。
'requirePermission' => false,
ただし、これはそのプレフィックスの権限チェックを丸ごと無効化する設定です。baserCMS 側が認可を見なくなるだけなので、代わりの認可は必ず自前で実装してください。 会員サイトであれば、必要なのは「ログインしているか」程度の判定であることが多いので、この割り切り自体は素直だと思います。
4-2. ログイン失敗時だけ、401ではなくCSRFエラーが返る
正常系は問題なく通るのに、パスワードを間違えたときだけ想定外の応答が返りました。
パスワードが正しい場合 → 200 {"access_token": "..."}
パスワードが間違っている場合 → 403 {"message":"Missing or incorrect CSRF cookie type."}
CSRFチェックのスキップ条件を見ると理由が分かります。
// 設定ファイルでスキップの定義がされている場合はスキップ
if ($this->isSkipCsrfUrl($request->getPath())) return true;
// 領域が REST API でない場合はスキップしない
if (empty($authSetting['isRestApi'])) return false;
$authenticator = $request->getAttribute('authentication')->getAuthenticationProvider();
if ($authenticator) {
// 認証済の際、セッション認証以外はスキップ
if (!$authenticator instanceof SessionAuthenticator) return true;
}
return false;
REST API のプレフィックスでは「認証済みで、かつセッション認証以外ならCSRFチェックをスキップする」という作りです。JWTで認証が通っていればスキップされます。
ログインが失敗したリクエストは認証済みではないので、$authenticator が取れず、最後の return false に落ちてCSRFチェックが走ります。 フロント側は401を期待しているのに403が返るため、「ログイン失敗時だけエラーハンドリングが効かない」という形で表面化しました。正常系だけ確認していると気づけません。
対処は、ログインAPIのURLをCSRFチェックの除外リストに登録することです。コア自身も管理APIで同じことをしています。
'skipCsrfUrl' => [
'UsersLogin' => ['plugin' => 'BaserCore', 'controller' => 'Users', 'action' => 'login', '_ext' => 'json'],
'UsersRefreshToken' => ['plugin' => 'BaserCore', 'controller' => 'Users', 'action' => 'refresh_token', '_ext' => 'json'],
],
キーを付けているのは、コアが登録している UsersLogin などと衝突しない識別子にするためです。
4-3. FormProtection も外す必要がある
POSTでログインを叩くと400が返ってきました。AppController が読み込む FormProtection がトークンを要求するためで、JSONで受けるAPIでは検証を無効にする必要があります。
これもコアの BcAdminApiController::initialize() にそのまま同じ行があります。
$this->FormProtection->setConfig('validate', false);
前章のCSRFと合わせて、JSON APIを作る際に外すべき保護機構が2つあるということになります。
4-4. リフレッシュトークンがアクセストークンとして通ってしまう
これは「動かない」のではなく「動いてしまう」問題です。
BcApiUtil::createAccessToken() は、アクセストークンとリフレッシュトークンの2つを返します。ペイロードを見ると、違いは token_type と有効期限だけで、sub はどちらも同じ {プレフィックス}_{ユーザーID} 形式です。
'access_token' => JWT::encode([
'token_type' => 'access_token',
...
'exp' => time() + Configure::read('Jwt.accessTokenExpire'),
], ...),
'refresh_token' => JWT::encode([
'token_type' => 'refresh_token',
...
'exp' => time() + Configure::read('Jwt.refreshTokenExpire'),
], ...),
つまり token_type を見なければ、有効期限の長いリフレッシュトークンがそのままアクセストークンとして通ります。
コアの BcAdminApiController::beforeFilter() には、この検査が入っています。
// トークンタイプチェック
$auth = $this->Authentication->getAuthenticationService()->getAuthenticationProvider();
if ($auth instanceof JwtAuthenticator) {
$payload = $auth->getPayload();
if ($payload->token_type !== 'access_token' && $this->getRequest()->getParam('action') !== 'refresh_token') {
$event->setResult($this->response->withStatus(401));
return;
}
}
BcApiController にこの処理はなく、BcAdminApiController 側に置かれています。トークン種別の検査は各API領域側で行う、という切り分けと読めます。いずれにせよ、独自プレフィックスを立てる場合は同等の処理を自分のコントローラに実装する必要があります。
4-5. 認証プレフィックスが管理画面に現れる
BcPrefixAuth にプレフィックスを宣言すると、name に指定した文字列が管理画面のユーザーグループ設定に、認証プレフィックスの選択肢として表示されます。選択肢は BcUtil::getAuthPrefixList() が生成しています。
foreach(Configure::read('BcPrefixAuth') as $key => $authPrefix) {
if (!empty($authPrefix['disabled'])) continue;
$authPrefixes[$key] = $authPrefix['name'];
}
しかし今回の構成では、会員は独自テーブルで管理されており、baserCMS のユーザーグループとは無関係です。ここで「会員 Web API」を選んでユーザーグループを作成しても、会員の認証や権限には一切影響しません。管理画面に、選んでも意味のない選択肢が増えることになります。
上のコードに disabled の分岐があるので一見使えそうなのですが、これは表示用の設定ではありませんでした。disabled は「この認証領域を丸ごと使わない」という意味で、コアの Front プレフィックスが既定でこの状態になっています。表示を抑える目的で付けると、ルーティングごと消えてAPIが404になります。
結局、表示だけを抑える手段は見つけられませんでした。設定ひとつでルーティングも認証も自動生成される手軽さの裏返しと考えるほかなさそうです。運用でカバーするなら、name を管理者が誤って選ばないラベルにしておくのが現実的かもしれません。
4-6. 移行元のパスワードが bcrypt でないと、JWT認証では救えない
既存システムからの移行では、パスワードが baserCMS と異なる形式でハッシュ化されていることがあります。
セッション認証(type => 'Session')の場合は、passwordHasher を設定することで旧形式のハッシュを扱う仕組みが用意されています。ところが JWT認証(type => 'Jwt')では passwordHasher が読まれません。 設定しても無視され、エラーも出ないまま401が返り続けます。
移行元のハッシュ形式のまま認証したい場合は、ログインアクションで自前に照合し、成功したら BcApiUtil::createAccessToken() を直接呼んでトークンを発行する、という形になります。
なお、移行時に全会員へパスワード再設定を依頼できるのであれば、そちらのほうが素直です。bcrypt に揃えてしまえば、この問題自体が発生しません。
4-7. 別オリジンのフロントから使う場合
フロントを別ドメインに置く構成では、このAPIにCORSヘッダが付かないため、別途対応が必要です。
なお、コアのAPIには環境変数 USE_CORE_API による同一オリジン制限がありますが、これは自作プラグインのAPIには適用されません。
5. まとめ
独自テーブルでフロントユーザー認証を実装する、と聞くと身構えてしまいますが、baserCMS 5 には拡張ポイントがきれいに用意されています。
必要だったのは次の2つだけでした。
- 認証設定を1つ宣言する(BcPrefixAuth)
- エンティティに4つのメソッドを実装する(UserInterface)
これだけで、ルーティング・JWTの発行と検証・トークンの領域分離まで、コアの仕組みに乗ることができます。コアのコードを改変する必要は一切ありません。
一方で、つまずいたポイントはいずれも独自プレフィックスで BcApiController を直接継承したことに起因するものでした。管理APIの場合は BcAdminApiController がトークン種別の検査やFormProtectionの無効化を担っていますが、独自の認証領域を立てるとその層が存在しないため、必要な処理を自分で用意することになります。
振り返ると、実装の勘所は「コアに何が足りないか」ではなく、「コアの管理APIが何をやっているかを読むこと」でした。コアと同じ構造を自分の領域に再現する、という作業だったように思います。
また、権限チェックとCSRFについては、正常系の確認だけでは気づけない類のものでした。権限チェックは403ではなく500で返ってきますし、CSRFに至ってはログインが失敗したときにだけ表面化します。異常系まで通して確認することの大切さを、あらためて実感しました。
同じ構成を検討している方の参考になれば幸いです。
参考資料:完成形のサンプルコード
3章では説明の都合上、4章で扱う設定を外した状態で提示していました。最終的な形をまとめておきます。
エンティティとテーブルは3章のままで変更ありません。
なお、ログインAPIはアクセストークンとリフレッシュトークンの両方を返しますが、本記事では再発行エンドポイントの実装を省略しています。beforeFilter() でリフレッシュトークンを弾いているため、このままではクライアント側で使い道がありません。実装する場合は、コアの UsersController::refresh_token() が参考になります。
config/setting.php
<?php
return [
'BcPrefixAuth' => [
'Api/Member' => [
'name' => '会員 Web API',
'type' => 'Jwt',
'alias' => 'api/member',
'username' => 'email',
'password' => 'password',
// 独自テーブルを指定する
'userModel' => 'MemberAuth.Members',
'isRestApi' => true,
// baserCMS のユーザーグループ権限機構から外す(4-1)
'requirePermission' => false,
],
],
'BcApp' => [
// 未認証でアクセスするログインAPIは CSRF チェックの対象外にする(4-2)
'skipCsrfUrl' => [
'MemberAuthLogin' => [
'plugin' => 'MemberAuth',
'prefix' => 'Api/Member',
'controller' => 'Members',
'action' => 'login',
'_ext' => 'json',
],
],
],
];
<?php
declare(strict_types=1);
namespace MemberAuth\Controller\Api\Member;
use Authentication\Authenticator\JwtAuthenticator;
use Authentication\Controller\Component\AuthenticationComponent;
use BaserCore\Controller\Api\BcApiController;
use Cake\Event\EventInterface;
/**
* 会員 Web API
*
* @property AuthenticationComponent $Authentication
*/
class MembersController extends BcApiController
{
public function initialize(): void
{
parent::initialize();
$this->loadComponent('Authentication.Authentication');
$this->Authentication->allowUnauthenticated(['login']);
// AppController が読み込む FormProtection は POST にトークンを要求するため、
// JSON で受ける API では検証を無効にする(4-3)
$this->FormProtection->setConfig('validate', false);
}
/**
* Before Filter
*
* トークン種別を検査する(4-4)
*/
public function beforeFilter(EventInterface $event)
{
$provider = $this->Authentication->getAuthenticationService()->getAuthenticationProvider();
if ($provider instanceof JwtAuthenticator) {
$payload = $provider->getPayload();
if ($payload->token_type !== 'access_token') {
$event->setResult($this->response->withStatus(401));
return;
}
}
parent::beforeFilter($event);
}
/**
* ログイン(アクセストークン発行)
*/
public function login()
{
$this->request->allowMethod(['post']);
$json = [];
$result = $this->Authentication->getResult();
if ($result->isValid()) {
$json = $this->getAccessToken($result);
}
if (!$json) {
$this->setResponse($this->response->withStatus(401));
}
$this->set('json', $json);
$this->viewBuilder()->setOption('serialize', 'json');
}
/**
* ログイン中の会員情報を返す(要認証)
*/
public function me()
{
$this->request->allowMethod(['get']);
$member = $this->Authentication->getIdentity()?->getOriginalData();
$this->set('member', $member);
$this->viewBuilder()->setOption('serialize', 'member');
}
}