🔐

Symfony 6でアクセストークンによるユーザー認証を実装し、開発・テスト時にはアクセストークンなしで任意のユーザーをログインさせる方法

2023/07/24に公開

やりたいこと

以下のようなユースケースを想定します。

  • Symfony 6でSPAのバックエンドを実装する
  • ユーザー認証にはIDaaSを使い、フロントエンドが直接IDaaSにログインしてアクセストークンを取得している
  • Symfony側のユーザーエンティティにはIDaaS上のユーザーIDが保存されている

この場合に、フロントエンドからのリクエストヘッダーに乗っているアクセストークンをもとに、Symfony側で対応するユーザーをログイン状態にする方法を解説します。

また、このような構成においては、開発時にまでいちいち本物のアクセストークンを乗せないとリクエストできないのでは不便すぎる ので、開発環境・テスト環境においてのみ、ユーザーIDなどを指定して任意のユーザーをログイン状態にすることができるようにしたい です。その方法についても解説します。

1. アクセストークンによるユーザー認証

まず、アクセストークンによるユーザー認証についてです。

これについては、

How to use Access Token Authentication (Symfony Docs)

こちらの公式ドキュメントで解説されている AccessTokenHandler を使えば一瞬で実装できてしまいます🙆‍♂️

AccessTokenHandlerSymfony 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.yamlwhen@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.jsonautoload-dev に以下のようにオートロードの設定を1行追記します。

      "autoload": {
          "psr-4": {
              "App\\": "src/"
          }
      },
      "autoload-dev": {
          "psr-4": {
+             "App\\Dev\\": "src-dev/",
              "App\\Tests\\": "tests/"
          }
      },

これで、src-dev 配下のクラス群は開発環境でしかオートロードされなくなります。

続いて、services_dev.yamlservices_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.localDEBUG_AUTH_IDAAS_ID={DB上の任意のユーザーのidaasIdカラムの値} を書いておけば、開発環境においては常にそのユーザーでログインした状態で動作させることができます。

.env のほうには雛形として DEBUG_AUTH_IDAAS_ID= とだけ書いておく必要があります。.env にも .env.local にも DEBUG_AUTH_IDAAS_ID 環境変数自体が存在しない状態だと、services_dev.yamlApp\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);
}

これで開発・テストがしやすくなりましたね!🙌

GitHubで編集を提案

Discussion