API Platform JWT Authentication

JSON Web Token (JWT) は、いくつかのクレームをアサートするアクセス トークンを作成するための JSON ベースのオープン スタンダード (RFC 7519) です。たとえば、サーバーは「管理者としてログイン」というクレームを持つトークンを生成し、それをクライアントに提供できます。クライアントはそのトークンを使用して、管理者としてログインしていることを証明できます。トークンはサーバーのキーによって署名されるため、サーバーはトークンが正当であることを確認できます。トークンはコンパクトで、URL セーフであり、特に Web ブラウザーのシングル サインオン (SSO) コンテキストで使用できるように設計されています。


API プラットフォームでは、LexikJWTAuthenticationBundle を使用して、JWT ベースの認証を API に簡単に追加できます。

LexikJWTAuthenticationBundleのインストール

バンドルをインストールすることから始めます。

docker compose exec php \
    composer require jwt-auth


次に、JWT トークンの署名に使用する公開鍵と秘密鍵を生成する必要があります。 API プラットフォーム ディストリビューションを使用している場合は、プロジェクトのルート ディレクトリからこれを実行できます。

docker compose exec php sh -c '
    set -e
    apk add openssl
    php bin/console lexik:jwt:generate-keypair
    setfacl -R -m u:www-data:rX -m u:"$(whoami)":rwX config/jwt
    setfacl -dR -m u:www-data:rX -m u:"$(whoami)":rwX config/jwt

setfacl コマンドは acl パッケージに依存していることに注意してください。これは、API プラットフォームの docker ディストリビューションを使用するとデフォルトでインストールされますが、setfacl コマンドを実行するために作業環境にインストールする必要がある場合があります。

これにより、鍵ペアの作成 (正しいパスフレーズを使用して秘密鍵を暗号化することを含む) が処理され、Web サーバーがそれらを読み取れるように鍵に正しいアクセス許可が設定されます。

これらのキーはコンテナーからルート ユーザーによって作成されるため、ホスト ユーザーは docker compose build caddy プロセス中にキーを読み取ることができません。 config/jwt/ フォルダーを api/.dockerignore ファイルに追加して、結果イメージからスキップされるようにします。

キーを開発環境で自動生成する場合は、api-platform/demo の docker-entrypoint スクリプトの例を参照してください。

キーはリポジトリにチェックインしないでください (つまり、api/.gitignore にあります)。ただし、JWT トークンは、署名に使用されたのと同じキーのペアに対してのみ署名検証を渡すことができることに注意してください。これは、展開のたびにすべてのクライアントのトークンを誤って無効にしたくない運用環境に特に関連します。

詳細については、バンドルのドキュメントを参照するか、こちらの JWT の一般的な紹介をお読みください。

まだ終わっていません! JWT 認証用の Symfony SecurityBundle の設定に移りましょう。

Symfony SecurityBundleの設定

ユーザー プロバイダーを構成する必要があります。 Symfony が提供する Doctrine エンティティ ユーザー プロバイダーを使用する (推奨)、カスタム ユーザー プロバイダーを作成する、または API プラットフォームの FOSUserBundle 統合を使用する (非推奨) ことができます。

Doctrine エンティティ ユーザー プロバイダーを使用する場合は、まず User クラスを作成します。

Symfony のパーミッションは、常にユーザー オブジェクトにリンクされています。アプリケーション (の一部) を保護する必要がある場合は、ユーザー クラスを作成する必要があります。 UserInterface を実装したクラスです。多くの場合、これは Doctrine エンティティですが、専用の Security ユーザー クラスを使用することもできます。

ユーザー クラスを生成する最も簡単な方法は、MakerBundle の [make:user] コマンドを使用することです。

php bin/console make:user
 The name of the security user class (e.g. User) [User]:
 > User

 Do you want to store user data in the database (via Doctrine)? (yes/no) [yes]:
 > yes

 Enter a property name that will be the unique "display" name for the user (e.g. email, username, uuid) [email]:
 > email

 Will this app need to hash/check user passwords? Choose No if passwords are not needed or will be checked/hashed by some other system (e.g. a single sign-on server).

 Does this app need to hash/check user passwords? (yes/no) [yes]:
 > yes

 created: src/Entity/User.php
 created: src/Repository/UserRepository.php
 updated: src/Entity/User.php
 updated: config/packages/security.yaml
// src/Entity/User.php
namespace App\Entity;

use App\Repository\UserRepository;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;
use Symfony\Component\Security\Core\User\UserInterface;

#[ORM\Entity(repositoryClass: UserRepository::class)]
class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column(type: 'integer')]
    private $id;

    #[ORM\Column(type: 'string', length: 180, unique: true)]
    private $email;

    #[ORM\Column(type: 'json')]
    private $roles = [];

    #[ORM\Column(type: 'string')]
    private $password;

    public function getId(): ?int
    {
        return $this->id;
    }

    public function getEmail(): ?string
    {
        return $this->email;
    }

    public function setEmail(string $email): self
    {
        $this->email = $email;

        return $this;
    }

    /**
     * The public representation of the user (e.g. a username, an email address, etc.)
     *
     * @see UserInterface
     */
    public function getUserIdentifier(): string
    {
        return (string) $this->email;
    }

    /**
     * @see UserInterface
     */
    public function getRoles(): array
    {
        $roles = $this->roles;
        // guarantee every user at least has ROLE_USER
        $roles[] = 'ROLE_USER';

        return array_unique($roles);
    }

    public function setRoles(array $roles): self
    {
        $this->roles = $roles;

        return $this;
    }

    /**
     * @see PasswordAuthenticatedUserInterface
     */
    public function getPassword(): string
    {
        return $this->password;
    }

    public function setPassword(string $password): self
    {
        $this->password = $password;

        return $this;
    }

    /**
     * Returning a salt is only needed if you are not using a modern
     * hashing algorithm (e.g. bcrypt or sodium) in your security.yaml.
     *
     * @see UserInterface
     */
    public function getSalt(): ?string
    {
        return null;
    }

    /**
     * @see UserInterface
     */
    public function eraseCredentials()
    {
        // If you store any temporary, sensitive data on the user, clear it here
        // $this->plainPassword = null;
    }
}

上記の例のように、ユーザーが Doctrine エンティティである場合は、移行を作成して実行することでテーブルを作成することを忘れないでください。

php bin/console make:migration
php bin/console doctrine:migrations:migrate

エンティティを作成するだけでなく、[make:user] コマンドはセキュリティ構成にユーザー プロバイダーの構成も追加します。

# config/packages/security.yaml
security:
    # ...

    providers:
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

このユーザープロバイダーは、「ユーザー識別子」(ユーザーのメールアドレスやユーザー名など)に基づいて、ストレージ(データベースなど)からユーザーを(再)ロードする方法を知っています。上記の構成では、Doctrine を使用して、[email] プロパティを「ユーザー識別子」として使用して [User] エンティティをロードします。

ユーザー プロバイダーは、セキュリティ ライフサイクルのいくつかの場所で使用されます。

識別子に基づいてユーザーをロードする

ログイン中 (またはその他のオーセンティケーター)、プロバイダーはユーザー ID に基づいてユーザーをロードします。ユーザーの偽装やRemember Meなどの他の機能もこれを使用します。

セッションからユーザーをリロードします

各リクエストの開始時に、ユーザーはセッションから読み込まれます (ファイアウォールが [ステートレス] でない場合)。プロバイダーは、すべてのユーザー情報が最新であることを確認するために、ユーザーを「更新」します (たとえば、データベースに新しいデータが再度クエリされます)。このプロセスの詳細については、セキュリティを参照してください。

symfony には、いくつかの組み込みのユーザー プロバイダーが付属しています。

Entity User Provider Loads users from a database using 

Doctrine;LDAP User Provider Loads users from a LDAP server;

Memory User Provider Loads users from a configuration file;

Chain User Provider Merges two or more user providers into a new user provider.

組み込みのユーザー プロバイダーは、アプリケーションの最も一般的なニーズをカバーしますが、独自のユーザー プロバイダーを作成することもできます。

場合によっては、ユーザー プロバイダーを別のクラス (カスタム オーセンティケーターなど) に挿入する必要があります。すべてのユーザー プロバイダーは、サービス ID に対して次のパターンに従います: [security.user.provider.concrete.<your-provider-name>] ([<your-provider-name>] は [app_user_provider] などの構成キーです)。ユーザー プロバイダーが 1 つしかない場合は、UserProviderInterface タイプ ヒントを使用して自動接続できます。

ユーザーの登録: パスワードのハッシュ化

多くのアプリケーションでは、ユーザーはパスワードでログインする必要があります。これらのアプリケーションに対して、SecurityBundle はパスワードのハッシュと検証機能を提供します。

まず、User クラスが PasswordAuthenticatedUserInterface を実装していることを確認します。

// src/Entity/User.php

// ...
use Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface;

class User implements UserInterface, PasswordAuthenticatedUserInterface
{
    // ...

    /**
     * @return string the hashed password for this user
     */
    public function getPassword(): string
    {
        return $this->password;
    }
}

次に、このクラスに使用するパスワード ハッシャーを構成します。 [security.yaml] ファイルがまだ事前構成されていない場合は、[make:user] がこれを行っているはずです。

# config/packages/security.yaml
security:
    # ...
    password_hashers:
        # Use native password hasher, which auto-selects and migrates the best
        # possible hashing algorithm (which currently is "bcrypt")
        Symfony\Component\Security\Core\User\PasswordAuthenticatedUserInterface: 'auto'

パスワードをハッシュする方法を Symfony が認識したので、[UserPasswordHasherInterface] サービスを使用して、ユーザーをデータベースに保存する前にこれを行うことができます。

// src/Controller/RegistrationController.php
namespace App\Controller;

// ...
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;

class RegistrationController extends AbstractController
{
    public function index(UserPasswordHasherInterface $passwordHasher)
    {
        // ... e.g. get the user data from a registration form
        $user = new User(...);
        $plaintextPassword = ...;

        // hash the password (based on the security.yaml config for the $user class)
        $hashedPassword = $passwordHasher->hashPassword(
            $user,
            $plaintextPassword
        );
        $user->setPassword($hashedPassword);

        // ...
    }
}

[make:registration-form] maker コマンドは、登録コントローラーをセットアップし、SymfonyCastsVerifyEmailBundleを使用してメールアドレス検証などの機能を追加するのに役立ちます。

composer require symfonycasts/verify-email-bundle
php bin/console make:registration-form

次のコマンドを実行して、パスワードを手動でハッシュすることもできます。

php bin/console security:hash-password

次に、セキュリティ構成を更新します。

# api/config/packages/security.yaml
security:
    # https://symfony.com/doc/current/security.html#c-hashing-passwords
    password_hashers:
        App\Entity\User: 'auto'

    # https://symfony.com/doc/current/security/authenticator_manager.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/_(profiler|wdt)
            security: false
        main:
            stateless: true
            provider: app_user_provider
            json_login:
                check_path: /authentication_token
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure
            jwt: ~

    access_control:
        - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing the Swagger UI
        - { path: ^/authentication_token, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

/authentication_token に使用するルートも宣言する必要があります。

# api/config/routes.yaml
authentication_token:
    path: /authentication_token
    methods: ['POST']

JWT トークンの認証が必要になるたびにデータベースから User エンティティをロードするのを避けたい場合は、LexikJWTAuthenticationBundle が提供するデータベースなしのユーザー プロバイダーの使用を検討してください。ただし、必要に応じて (おそらく Doctrine EntityManager を介して) 自分でデータベースから User エンティティをフェッチする必要があることを意味します。

API リソースと操作へのアクセスを制御する方法については、セキュリティのセクションを参照してください。 JWT 認証用に Swagger UI を構成することもできます。

パスプレフィックスを使用する API への認証の追加

API がパス プレフィックスを使用する場合、セキュリティ構成は代わりに次のようになります。

# api/config/packages/security.yaml
security:
    # https://symfony.com/doc/current/security.html#c-hashing-passwords
    password_hashers:
        App\Entity\User: 'auto'

    # https://symfony.com/doc/current/security/authenticator_manager.html
    enable_authenticator_manager: true
    # https://symfony.com/doc/current/security.html#where-do-users-come-from-user-providers
    providers:
        # used to reload user from session & other features (e.g. switch_user)
        app_user_provider:
            entity:
                class: App\Entity\User
                property: email

    firewalls:
        dev:
            pattern: ^/_(profiler|wdt)
            security: false
        api:
            pattern: ^/api/
            stateless: true
            provider: app_user_provider
            jwt: ~
        main:
            json_login:
                check_path: /authentication_token
                username_path: email
                password_path: password
                success_handler: lexik_jwt_authentication.handler.authentication_success
                failure_handler: lexik_jwt_authentication.handler.authentication_failure

    access_control:
        - { path: ^/docs, roles: PUBLIC_ACCESS } # Allows accessing API documentations and Swagger UI
        - { path: ^/authentication_token, roles: PUBLIC_ACCESS }
        - { path: ^/, roles: IS_AUTHENTICATED_FULLY }

useridentityfield に lexikjwtauthentication が設定されていることを確認してください

# api/config/packages/lexik_jwt_authentication.yaml
lexik_jwt_authentication:
    secret_key: '%env(resolve:JWT_SECRET_KEY)%'
    public_key: '%env(resolve:JWT_PUBLIC_KEY)%'
    pass_phrase: '%env(JWT_PASSPHRASE)%'

    user_identity_field: email # Or the field you have setted using make:user

Swagger/Open API を使用した認証メカニズムのドキュメント

JWT 認証で保護された API のルートをテストしたいですか?

# api/config/packages/api_platform.yaml
api_platform:
    swagger:
         api_keys:
             JWT:
                name: Authorization
                type: header

Swagger UI に [Authorize] ボタンが自動的に表示されます。

新しい API キーの追加

値フィールドに API キーを設定するだけです。デフォルトでは、認証ヘッダー モードのみが LexikJWTAuthenticationBundle で有効になっています。以下のように JWT トークンを設定し、[Authorize] ボタンをクリックする必要があります。

Bearer MY_NEW_TOKEN

JWT トークンを取得するための SwaggerUI へのエンドポイントの追加

POST /authentication_token エンドポイントを SwaggerUI に追加して、必要なときにトークンを簡単に取得できます。

そのためには、デコレータを作成する必要があります。

<?php
// api/src/OpenApi/JwtDecorator.php

namespace App\OpenApi;

use ApiPlatform\OpenApi\Factory\OpenApiFactoryInterface;
use ApiPlatform\OpenApi\OpenApi;
use ApiPlatform\OpenApi\Model;

final class JwtDecorator implements OpenApiFactoryInterface
{
    public function __construct(
        private OpenApiFactoryInterface $decorated
    ) {}

    public function __invoke(array $context = []): OpenApi
    {
        $openApi = ($this->decorated)($context);
        $schemas = $openApi->getComponents()->getSchemas();

        $schemas['Token'] = new \ArrayObject([
            'type' => 'object',
            'properties' => [
                'token' => [
                    'type' => 'string',
                    'readOnly' => true,
                ],
            ],
        ]);
        $schemas['Credentials'] = new \ArrayObject([
            'type' => 'object',
            'properties' => [
                'email' => [
                    'type' => 'string',
                    'example' => 'johndoe@example.com',
                ],
                'password' => [
                    'type' => 'string',
                    'example' => 'apassword',
                ],
            ],
        ]);

        $schemas = $openApi->getComponents()->getSecuritySchemes() ?? [];
        $schemas['JWT'] = new \ArrayObject([
            'type' => 'http',
            'scheme' => 'bearer',
            'bearerFormat' => 'JWT',
        ]);
        
        $pathItem = new Model\PathItem(
            ref: 'JWT Token',
            post: new Model\Operation(
                operationId: 'postCredentialsItem',
                tags: ['Token'],
                responses: [
                    '200' => [
                        'description' => 'Get JWT token',
                        'content' => [
                            'application/json' => [
                                'schema' => [
                                    '$ref' => '#/components/schemas/Token',
                                ],
                            ],
                        ],
                    ],
                ],
                summary: 'Get JWT token to login.',
                requestBody: new Model\RequestBody(
                    description: 'Generate new JWT Token',
                    content: new \ArrayObject([
                        'application/json' => [
                            'schema' => [
                                '$ref' => '#/components/schemas/Credentials',
                            ],
                        ],
                    ]),
                ),
                security: [],
            ),
        );
        $openApi->getPaths()->addPath('/authentication_token', $pathItem);

        return $openApi;
    }
}

このサービスを config/services.yaml に登録します。

# api/config/services.yaml
services:
    # ...

    App\OpenApi\JwtDecorator:
        decorates: 'api_platform.openapi.factory'
        arguments: ['@.inner']

Testing

ApiTestCase で認証をテストするには、次のようにメソッドを記述できます。

<?php
// tests/AuthenticationTest.php

namespace App\Tests;

use ApiPlatform\Symfony\Bundle\Test\ApiTestCase;
use App\Entity\User;
use Hautelook\AliceBundle\PhpUnit\ReloadDatabaseTrait;

class AuthenticationTest extends ApiTestCase
{
    use ReloadDatabaseTrait;

    public function testLogin(): void
    {
        $client = self::createClient();
        $container = self::getContainer();

        $user = new User();
        $user->setEmail('test@example.com');
        $user->setPassword(
            $container->get('security.user_password_hasher')->hashPassword($user, '$3CR3T')
        );

        $manager = $container->get('doctrine')->getManager();
        $manager->persist($user);
        $manager->flush();

        // retrieve a token
        $response = $client->request('POST', '/authentication_token', [
            'headers' => ['Content-Type' => 'application/json'],
            'json' => [
                'email' => 'test@example.com',
                'password' => '$3CR3T',
            ],
        ]);

        $json = $response->toArray();
        $this->assertResponseIsSuccessful();
        $this->assertArrayHasKey('token', $json);

        // test not authorized
        $client->request('GET', '/greetings');
        $this->assertResponseStatusCodeSame(401);

        // test authorized
        $client->request('GET', '/greetings', ['auth_bearer' => $json['token']]);
        $this->assertResponseIsSuccessful();
    }
}

API プラットフォームのテストの詳細については、API のテストを参照してください。

テスト スイートの速度の向上

JWT 認証を使用したため、機能テストでは、API エンドポイントをテストするたびにログインする必要があります。ここで、Password Hasher の出番です。

ハッシャーが使用される理由は 2 つあります。

生のパスワードのハッシュを生成するには ($container->get('security.user_password_hasher')->hashPassword($user, '$3CR3T'))

認証中にパスワードを確認するには

1 つのパスワードのハッシュ化と検証は非常に高速な操作ですが、信頼できるハッシュ化アルゴリズムはその性質上低速であるため、テスト スイートで数百回または数千回実行するとボトルネックになります。

テスト スイートの速度を大幅に向上させるために、テスト環境専用のより単純なパスワード ハッシャーを使用できます。

# override in api/config/packages/test/security.yaml for test env
security:
    password_hashers:
        App\Entity\User:
            algorithm: md5
            encode_as_base64: false
            iterations: 0