Symfony 6でアクセストークンによるユーザー認証を実装し、開発・テスト時にはアクセストークンなしで任意のユーザーをログインさせる方法
やりたいこと
以下のようなユースケースを想定します。
- Symfony 6でSPAのバックエンドを実装する
- ユーザー認証にはIDaaSを使い、フロントエンドが直接IDaaSにログインしてアクセストークンを取得している
- Symfony側のユーザーエンティティにはIDaaS上のユーザーIDが保存されている
この場合に、フロントエンドからのリクエストヘッダーに乗っているアクセストークンをもとに、Symfony側で対応するユーザーをログイン状態にする方法を解説します。
また、このような構成においては、開発時にまでいちいち本物のアクセストークンを乗せないとリクエストできないのでは不便すぎる ので、開発環境・テスト環境においてのみ、ユーザーIDなどを指定して任意のユーザーをログイン状態にすることができるようにしたい です。その方法についても解説します。
1. アクセストークンによるユーザー認証
まず、アクセストークンによるユーザー認証についてです。
これについては、
How to use Access Token Authentication (Symfony Docs)
こちらの公式ドキュメントで解説されている AccessTokenHandler
を使えば一瞬で実装できてしまいます🙆♂️
AccessTokenHandler
は Symfony 6.2で新たに導入された 機能です。
具体的には、まず以下のようなクラスを実装します。
<?php
declare(strict_types=1);
namespace App\Security;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class AccessTokenHandler implements AccessTokenHandlerInterface
{
// `Auth` は IDaaS の SDK のイメージ
public function __construct(private readonly Auth $auth)
{
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
try {
// 例えば IDaaS の SDK の login() というメソッドによって IDaaS のユーザー情報が取得できるイメージ
$idaasUser = $this->auth->login($accessToken);
return new UserBadge($idaasUser->id);
} catch (IdaasAccessTokenExpiredException|IdaasAccessTokenInvalidException|IdaasAccessTokenRevokedException $e) {
// 例えば IDaaS への認証失敗時に、失敗理由に応じて上記のような例外がスローされるイメージ
throw new BadCredentialsException(previous: $e);
}
}
}
あとは、security.yaml
でファイアウォールの access_token.token_handler
に上記クラスを設定してあげるだけです。
# config/packages/security.yaml
security:
providers:
app_user_provider:
entity:
# 例えば User エンティティに idaasId というプロパティがあるイメージ
class: App\Entity\User
property: idaasId
firewalls:
main:
lazy: true
stateless: true
access_token:
token_handler: App\Security\AccessTokenHandler
簡単ですね!🙌
Symfony 6.1以前では Custom Authenticator を書いて対応していましたが、AccessTokenHandler
はアクセストークンによる認証に特化することでいくらかコード量を減らせるようになっています。
2. 開発環境・テスト環境においてのみ任意のユーザーをログインさせられるように
次に、開発環境・テスト環境においてのみ任意のユーザーをログインさせられるようにする方法です。
方針として、
- 開発環境においては、
.env.local
で環境変数を設定することによって、常にそのユーザーでログインした状態になるようにしたい - ただし、
.env.local
で特に環境変数が設定されていない場合は、本番環境と同様に実際にアクセストークンによってユーザー認証が行われるようにしたい - テスト環境においては、テスコード内から動的にログインさせるユーザーを指定できるようにしたい
という3点を満たす実装を目指します。
これを叶えるべく、まずは以下のようなデバッグ用の AccessTokenHandler
を作ります。
<?php
declare(strict_types=1);
namespace App\Dev\Security;
use Symfony\Component\Security\Http\AccessToken\AccessTokenHandlerInterface;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
class DebugAccessTokenHandler implements AccessTokenHandlerInterface
{
public function __construct(
private readonly AccessTokenHandlerInterface $decorated,
private readonly ?string $idaasId = null,
) {
}
public function getUserBadgeFrom(string $accessToken): UserBadge
{
// コンストラクタで $idaasId が注入されている場合はアクセストークンの内容にかかわらず $idaasId でログイン
// 注入されていない場合はデフォルトの AccessTokenHandler に処理を委譲
return $this->idaasId ? new UserBadge($this->idaasId) : $this->decorated->getUserBadgeFrom($accessToken);
}
}
このクラスのコンストラクタに
- 本番環境用の(前項で実装した)
AccessTokenHandler
-
.env.local
で環境変数によって指定された、User::$idaasId
と照合したい値
の2つをインジェクトしておけば、所望の動作を実現できそうです。services.yaml
の設定内容は以下のようになるでしょう。
# config/packages.services.yaml
# ...
when@dev:
services:
App\Dev\Security\DebugAccessTokenHandler:
arguments:
- '@App\Security\AccessTokenHandler'
- '%env(DEBUG_AUTH_IDAAS_ID)%'
あとは security.yaml
で、開発環境においてのみ、使用する AccessTokenHandler
を差し替えてあげればよいでしょう。(後述しますが、テスト環境については実は特に差し替える必要なく任意のユーザーをログインさせることが可能です)
# config/packages/security.yaml
security:
# ...
when@dev:
security:
firewalls:
main:
access_token:
token_handler: App\Dev\Security\DebugAccessTokenHandler
ちなみに、以下のように サービスのデコレート機能 を使えば
security.yaml
でwhen@dev
の場合のみtoken_handler
を上書きするということ自体やらなくて済みそうな気がしますが、実際に試してみると、これだとデコレータであるDebugAccessTokenHandler
ではなく本体であるAccessTokenHandler
そのものが呼ばれてしまいます。token_handler
に渡すサービスIDについては、デコレート機能は使えないようです。(要出典)# config/packages.services.yaml # ... when@dev: services: App\Dev\Security\DebugAccessTokenHandler: decorates: App\Security\AccessTokenHandler arguments: - '@.inner' - '%env(DEBUG_AUTH_IDAAS_ID)%'
さて、一見これで万事良さそうに思えますが、実はまだ足りません。
このままだと、開発環境においても、リクエストヘッダーでアクセストークンを送らない限り、そもそもこの DebugAccessTokenHandler
が実行されず、問答無用で401エラーになる という挙動になります。
そこで、開発環境用に、実際にはアクセストークンが送られてきていない場合にも、常にダミーのアクセストークンを受け取ったことにする ための Custom AccessTokenExtractor を実装します。
<?php
declare(strict_types=1);
namespace App\Dev\Security;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Http\AccessToken\AccessTokenExtractorInterface;
class DebugAccessTokenExtractor implements AccessTokenExtractorInterface
{
public function extractAccessToken(Request $request): ?string
{
return 'dummy';
}
}
この上で、security.yaml
の設定を以下のように変更します。
when@dev:
security:
firewalls:
main:
access_token:
token_handler: App\Dev\Security\DebugAccessTokenHandler
+ token_extractors:
+ - header
+ - App\Dev\Security\DebugAccessTokenExtractor
これで、アクセストークンを取得する動作が、まずデフォルトと同様にリクエストヘッダーからの取得を試み、リクエストヘッダーにアクセストークンがなければ DebugAccessTokenExtractor
から取得する(この場合は常に dummy
という文字列が取得される) という挙動になります。
こうしておけば、開発環境においてアクセストークンを特に送らなかった場合にもアクセストークンによる認証が必要と認識されて、意図どおり DebugAccessHandler
に処理を渡すことができますね👌
ところで、今回作成した AccessTokenHandler
AccessTokenExtractor
ですが、万が一にも間違って本番環境上で実行されてしまうと、致命的なセキュリティリスクになってしまいます。
なので、本番環境ではこれらのクラスがインスタンス化自体されないようにしておく のが賢明です。
実はそのために、上記の2クラスは名前空間が App\Security
ではなく App\Dev\Security
になっていました。
まず、これらのクラスは src
配下ではなく src-dev
というディレクトリを作ってその配下に設置します。
src-dev/Security/DebugAccessTokenHandler.php
src-dev/Security/DebugAccessTokenExtractor.php
そして、composer.json
の autoload-dev
に以下のようにオートロードの設定を1行追記します。
"autoload": {
"psr-4": {
"App\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
+ "App\\Dev\\": "src-dev/",
"App\\Tests\\": "tests/"
}
},
これで、src-dev
配下のクラス群は開発環境でしかオートロードされなくなります。
続いて、services_dev.yaml
と services_test.yaml
を作成し、以下のような内容とします。
# config/services_dev.yaml
services:
_defaults:
autowire: true
autoconfigure: true
App\Dev\:
resource: '../src-dev/'
App\Dev\Security\DebugAccessTokenHandler:
arguments:
- '@App\Security\AccessTokenHandler'
- '%env(DEBUG_AUTH_IDAAS_ID)%'
# config/services_test.yaml
services:
_defaults:
autowire: true
autoconfigure: true
public: true
App\Dev\:
resource: '../src-dev/'
exclude:
# autoconfigure が試みられてしまうとコンストラクタ第1引数に注入すべきサービスが自動で決定できずエラーになるので
- '../src-dev/Security/DebugAccessTokenHandler.php'
これで、
- 開発環境・テスト環境においてのみ、
src-dev
配下のクラス群がオートワイヤリングによって自動でサービスコンテナに登録される - 開発環境においてのみ、
DebugAccessTokenHandler
のコンストラクタ引数にDEBUG_AUTH_IDAAS_ID
環境変数の値が渡される - (テスト環境においてのみ、すべてのサービスがデフォルトで
public
($container->get(サービスID)
でインスタンスを取得できる)となる)
という設定ができました。
これにより、.env.local
に DEBUG_AUTH_IDAAS_ID={DB上の任意のユーザーのidaasIdカラムの値}
を書いておけば、開発環境においては常にそのユーザーでログインした状態で動作させることができます。
.env
のほうには雛形としてDEBUG_AUTH_IDAAS_ID=
とだけ書いておく必要があります。.env
にも.env.local
にもDEBUG_AUTH_IDAAS_ID
環境変数自体が存在しない状態だと、services_dev.yaml
のApp\Dev\Security\DebugAccessTokenHandler
サービスの構築がエラーになってしまうためです。
.env
にだけDEBUG_AUTH_IDAAS_ID=
が書かれていて.env.local
には何も書かれていない、という状態であれば、DebugAccessTokenHandler
クラスの$idaasId
プロパティの値は''
になり、getUserBadgeFrom()
メソッドは常に本体のAccessTokenHandler
に処理を委譲するようになります。
さて、テスト環境についてはここまであまり触れてきませんでしたが、実はこれ以上特別な対応は特に必要ありません。
Symfony 5.1以降 では、WebTestCase::createClient()
によって得られる KernelBrowser
クラスに loginUser()
というメソッドが実装されており、これを使えば特に AccessTokenHandler
を差し替えるなどの対応をせずとも任意のユーザーを擬似的にログインさせることができます。
例えばテストクラス内に(あるいは基底クラスやトレイトに)以下のようなメソッドを書いておけば、必要に応じていつでも簡単に $idaasId
を指定してユーザーをログインさせることができます。
private function createAuthorizedClient(string $idaasId, KernelBrowser $client = null): KernelBrowser
{
$client ??= static::createClient();
if (!($user = static::getContainer()->get(UserRepository::class)->findOneBy(['idaasId' => $idaasId]))) {
throw new \LogicException();
}
return $client->loginUser($user);
}
これで開発・テストがしやすくなりましたね!🙌
Discussion