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