メインコンテンツまでスキップ

RBAC と JWT 検証で Symfony API を保護する

このガイドでは、Logto が発行する ロールベースのアクセス制御 (RBAC)JSON Web Token (JWT) を使用して、Symfony API に認可 (Authorization) を実装し、セキュリティを強化する方法を説明します。

始める前に

クライアントアプリケーションは Logto から アクセス トークン (Access token) を取得する必要があります。まだクライアント統合を設定していない場合は、React、Vue、Angular などのクライアントフレームワーク向け クイックスタート や、サーバー間アクセス用の マシン間通信 (M2M) ガイド をご覧ください。

このガイドは、Symfony アプリケーションにおけるこれらのトークンの サーバーサイド検証 に焦点を当てています。

A figure showing the focus of this guide

学べること

  • JWT 検証:アクセス トークン (Access token) を検証し、認証 (Authentication) 情報を抽出する方法
  • ミドルウェア実装:API 保護のための再利用可能なミドルウェアの作成
  • 権限モデル:さまざまな認可 (Authorization) パターンの理解と実装
    • アプリケーション全体のエンドポイント向けグローバル API リソース
    • テナント固有の機能制御のための組織 (Organization) 権限
    • マルチテナントデータアクセスのための組織レベル API リソース
  • RBAC 統合:API エンドポイントでロールベースの権限 (Permission) とスコープ (Scope) を強制する方法

前提条件

  • PHP の最新安定版がインストールされていること
  • Symfony および Web API 開発の基礎知識
  • Logto アプリケーションが設定済み(必要に応じて クイックスタート を参照)

権限モデルの概要

保護を実装する前に、アプリケーションアーキテクチャに適した権限モデルを選択してください。これは Logto の 3 つの主要な 認可 (Authorization) シナリオ に対応しています:

グローバル API リソース RBAC
  • ユースケース: アプリケーション全体で共有される API リソースを保護する(組織固有ではない)
  • トークンタイプ: グローバルオーディエンスを持つアクセス トークン
  • 例: パブリック API、コアプロダクトサービス、管理エンドポイント
  • 最適: すべての顧客が利用する API を持つ SaaS プロダクト、テナント分離のないマイクロサービス
  • 詳細: グローバル API リソースの保護

💡 進める前にモデルを選択してください — このガイド全体で選択したアプローチを参照します。

クイック準備手順

Logto リソースと権限の設定

  1. API リソースの作成: コンソール → API リソース にアクセスし、API を登録します(例: https://api.yourapp.com
  2. 権限の定義: read:productswrite:orders などのスコープを追加します – 権限付き API リソースの定義 を参照
  3. グローバルロールの作成: コンソール → ロール にアクセスし、API 権限を含むロールを作成します – グローバルロールの設定 を参照
  4. ロールの割り当て: API アクセスが必要なユーザーまたは M2M アプリケーションにロールを割り当てます
RBAC が初めてですか?:

ロールベースのアクセス制御ガイド からステップバイステップのセットアップ手順を始めましょう。

クライアントアプリケーションの更新

クライアントで適切なスコープをリクエストする:

通常、クライアント設定を次のいずれか、または複数を含めるように更新します:

  • OAuth フローでの scope パラメーター
  • API リソースアクセス用の resource パラメーター
  • 組織コンテキスト用の organization_id
コーディング前に:

テストするユーザーまたは M2M アプリが、API に必要な権限を含む適切なロールまたは組織ロールに割り当てられていることを確認してください。

API プロジェクトの初期化

API 開発用の新しい Symfony プロジェクトを初期化するには、Symfony CLI または Composer を使用します。

Symfony CLI を使用する場合(推奨):

symfony new your-api-name --webapp
cd your-api-name

または Composer を使用する場合:

composer create-project symfony/skeleton your-api-name
cd your-api-name
composer require webapp

API 開発用の追加パッケージをインストールします:

composer require symfony/security-bundle
composer require symfony/serializer
composer require doctrine/annotations

開発サーバーを起動します:

symfony serve

または PHP の組み込みサーバーを使用する場合:

php -S localhost:8000 -t public/

これで基本的な Symfony プロジェクトが作成されます。API 開発用にフレームワークを設定します:

config/packages/framework.yaml
framework:
secret: '%env(APP_SECRET)%'
serializer:
enabled: true
property_access:
enabled: true
注記:

コントローラー、サービス、その他の機能のセットアップ方法については、Symfony のドキュメントを参照してください。

定数とユーティリティの初期化

トークンの抽出と検証を処理するために、コード内で必要な定数やユーティリティを定義してください。有効なリクエストには、Authorization ヘッダーが Bearer <アクセス トークン (Access token)> の形式で含まれている必要があります。

AuthConstants.php
<?php

class AuthConstants
{
public const JWKS_URI = 'https://your-tenant.logto.app/oidc/jwks';
public const ISSUER = 'https://your-tenant.logto.app/oidc';
}
AuthInfo.php
<?php

class AuthInfo
{
public function __construct(
public readonly string $sub,
public readonly ?string $clientId = null,
public readonly ?string $organizationId = null,
public readonly array $scopes = [],
public readonly array $audience = []
) {}

public function toArray(): array
{
return [
'sub' => $this->sub,
'client_id' => $this->clientId,
'organization_id' => $this->organizationId,
'scopes' => $this->scopes,
'audience' => $this->audience,
];
}
}
AuthorizationException.php
<?php

class AuthorizationException extends Exception
{
public function __construct(
string $message,
public readonly int $statusCode = 403
) {
parent::__construct($message);
}
}
AuthHelpers.php
<?php

trait AuthHelpers
{
protected function extractBearerToken(array $headers): string
{
$authorization = $headers['authorization'][0] ?? $headers['Authorization'][0] ?? null;

if (!$authorization) {
throw new AuthorizationException('Authorization ヘッダーがありません (Authorization header is missing)', 401);
}

if (!str_starts_with($authorization, 'Bearer ')) {
throw new AuthorizationException('Authorization ヘッダーは "Bearer " で始まる必要があります (Authorization header must start with "Bearer ")', 401);
}

return substr($authorization, 7); // 'Bearer ' プレフィックスを削除
}
}

Logto テナント情報の取得

Logto が発行したトークンを検証するには、次の値が必要です:

  • JSON Web Key Set (JWKS) URI:JWT 署名を検証するために使用される Logto の公開鍵の URL。
  • 発行者 (Issuer):期待される発行者値(Logto の OIDC URL)。

まず、Logto テナントのエンドポイントを見つけます。これはさまざまな場所で確認できます:

  • Logto コンソールの 設定ドメイン で確認できます。
  • Logto で設定した任意のアプリケーション設定内の 設定エンドポイント & 資格情報 で確認できます。

OpenID Connect ディスカバリーエンドポイントから取得する

これらの値は、Logto の OpenID Connect ディスカバリーエンドポイントから取得できます:

https://<your-logto-endpoint>/oidc/.well-known/openid-configuration

以下はレスポンス例です(他のフィールドは省略しています):

{
"jwks_uri": "https://your-tenant.logto.app/oidc/jwks",
"issuer": "https://your-tenant.logto.app/oidc"
}

Logto では JWKS URI や発行者 (Issuer) のカスタマイズができないため、これらの値をコード内にハードコーディングすることも可能です。ただし、将来的に設定が変更された場合のメンテナンス負荷が増加する可能性があるため、本番アプリケーションでは推奨されません。

  • JWKS URI:https://<your-logto-endpoint>/oidc/jwks
  • 発行者 (Issuer):https://<your-logto-endpoint>/oidc

トークンと権限の検証

トークンを抽出し OIDC 設定を取得した後、次の点を検証してください:

  • 署名:JWT は有効であり、Logto(JWKS 経由)によって署名されている必要があります。
  • 発行者 (Issuer):Logto テナントの発行者 (Issuer) と一致している必要があります。
  • オーディエンス (Audience):Logto に登録された API のリソースインジケーター、または該当する場合は組織コンテキストと一致している必要があります。
  • 有効期限:トークンが有効期限切れでないこと。
  • 権限 (スコープ) (Permissions (scopes)):トークンに API / アクションに必要なスコープが含まれている必要があります。スコープは scope クレーム内のスペース区切り文字列です。
  • 組織コンテキスト:組織レベルの API リソースを保護する場合、organization_id クレームを検証してください。

JWT の構造やクレームについて詳しくは JSON Web Token を参照してください。

各権限モデルで確認すべきこと

クレームや検証ルールは権限モデルによって異なります:

  • オーディエンスクレーム (aud): API リソースインジケーター
  • 組織クレーム (organization_id): なし
  • チェックするスコープ(権限) (scope): API リソース権限

非 API 組織権限の場合、組織コンテキストは aud クレーム(例:urn:logto:organization:abc123)で表されます。organization_id クレームは組織レベル API リソーストークンにのみ存在します。

ヒント:

セキュアなマルチテナント API のため、必ず権限(スコープ)とコンテキスト(オーディエンス、組織)の両方を検証してください。

検証ロジックの追加

firebase/php-jwt を使用して JWT の検証を行います。Composer を使ってインストールしてください:

composer require firebase/php-jwt

まず、JWT 検証を処理するための共通ユーティリティを追加します:

JwtValidator.php
<?php

use Firebase\JWT\JWT;
use Firebase\JWT\JWK;
use Firebase\JWT\Key;

class JwtValidator
{
use AuthHelpers;

private static ?array $jwks = null;

public static function fetchJwks(): array
{
if (self::$jwks === null) {
$jwksData = file_get_contents(AuthConstants::JWKS_URI);
if ($jwksData === false) {
throw new AuthorizationException('Failed to fetch JWKS', 401);
}

self::$jwks = json_decode($jwksData, true);
}

return self::$jwks;
}

public static function validateJwt(string $token): array
{
try {
$jwks = self::fetchJwks();
$keys = JWK::parseKeySet($jwks);

$decoded = JWT::decode($token, $keys);
$payload = (array) $decoded;

// 発行者 (Issuer) の検証
if (($payload['iss'] ?? '') !== AuthConstants::ISSUER) {
throw new AuthorizationException('Invalid issuer', 401);
}

self::verifyPayload($payload);
return $payload;

} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('Invalid token: ' . $e->getMessage(), 401);
}
}

public static function createAuthInfo(array $payload): AuthInfo
{
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];
$audience = $payload['aud'] ?? [];

if (is_string($audience)) {
$audience = [$audience];
}

return new AuthInfo(
sub: $payload['sub'],
clientId: $payload['client_id'] ?? null,
organizationId: $payload['organization_id'] ?? null,
scopes: $scopes,
audience: $audience
);
}

private static function verifyPayload(array $payload): void
{
// 権限モデルに基づく検証ロジックをここに実装してください
// この内容は下記の権限モデルセクションで説明します
}
}

次に、アクセス トークンを検証するミドルウェアを実装します:

src/Security/JwtAuthenticator.php
<?php

namespace App\Security;

use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Exception\AuthenticationException;
use Symfony\Component\Security\Http\Authenticator\AbstractAuthenticator;
use Symfony\Component\Security\Http\Authenticator\Passport\Badge\UserBadge;
use Symfony\Component\Security\Http\Authenticator\Passport\Passport;
use Symfony\Component\Security\Http\Authenticator\Passport\SelfValidatingPassport;

class JwtAuthenticator extends AbstractAuthenticator
{
use AuthHelpers;

public function supports(Request $request): ?bool
{
return $request->headers->has('authorization');
}

public function authenticate(Request $request): Passport
{
try {
$token = $this->extractBearerToken($request->headers->all());
$payload = JwtValidator::validateJwt($token);
$authInfo = JwtValidator::createAuthInfo($payload);

// 認証情報をリクエスト属性に保存し、汎用的に利用できるようにします
$request->attributes->set('auth', $authInfo);

return new SelfValidatingPassport(new UserBadge($payload['sub']));

} catch (AuthorizationException $e) {
throw new AuthenticationException($e->getMessage());
}
}

public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
{
return null; // コントローラーへ処理を継続
}

public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
{
return new JsonResponse(['error' => $exception->getMessage()], Response::HTTP_UNAUTHORIZED);
}
}

config/packages/security.yaml でセキュリティを設定します:

config/packages/security.yaml
security:
firewalls:
api:
pattern: ^/api/protected
stateless: true
custom_authenticators:
- App\Security\JwtAuthenticator

権限モデルに応じて、JwtValidator 内で適切な検証ロジックを実装してください:

JwtValidator.php
private static function verifyPayload(array $payload): void
{
// オーディエンス (Audience) クレームが API リソースインジケーターと一致するか確認
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}

if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('Invalid audience');
}

// グローバル API リソースに必要なスコープ (Scope) を確認
$requiredScopes = ['api:read', 'api:write']; // 実際に必要なスコープに置き換えてください
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];

foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('Insufficient scope');
}
}
}

ミドルウェアを API に適用する

これで、保護された API ルートにミドルウェアを適用します。

src/Controller/Api/ProtectedController.php
<?php

namespace App\Controller\Api;

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;

#[Route('/api/protected')]
#[IsGranted('IS_AUTHENTICATED_FULLY')]
class ProtectedController extends AbstractController
{
#[Route('', methods: ['GET'])]
public function index(Request $request): JsonResponse
{
// リクエスト属性から認証 (Authentication) 情報へアクセス
$auth = $request->attributes->get('auth');
return $this->json(['auth' => $auth->toArray()]);
}
}

保護された API のテスト

アクセス トークン (Access tokens) の取得

クライアントアプリケーションから: クライアント統合を設定している場合、アプリは自動的にトークンを取得できます。アクセス トークン (Access token) を抽出し、API リクエストで使用してください。

curl / Postman でのテスト用:

  1. ユーザートークン: クライアントアプリの開発者ツールを使い、localStorage またはネットワークタブからアクセス トークン (Access token) をコピーします。

  2. マシン間通信トークン: クライアントクレデンシャルフローを使用します。以下は curl を使った非公式な例です:

    curl -X POST https://your-tenant.logto.app/oidc/token \
    -H "Content-Type: application/x-www-form-urlencoded" \
    -d "grant_type=client_credentials" \
    -d "client_id=your-m2m-client-id" \
    -d "client_secret=your-m2m-client-secret" \
    -d "resource=https://your-api-resource-indicator" \
    -d "scope=api:read api:write"

    resourcescope パラメーターは API リソースや権限に応じて調整が必要です。API が組織スコープの場合は organization_id パラメーターも必要になる場合があります。

ヒント:

トークンの内容を確認したい場合は、 JWT デコーダー を使って JWT をデコード・検証できます。

保護されたエンドポイントのテスト

有効なトークンリクエスト
curl -H "Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..." \
http://localhost:3000/api/protected

期待されるレスポンス:

{
"auth": {
"sub": "user123",
"clientId": "app456",
"organizationId": "org789",
"scopes": ["api:read", "api:write"],
"audience": ["https://your-api-resource-indicator"]
}
}
トークンなし
curl http://localhost:3000/api/protected

期待されるレスポンス (401):

{
"error": "Authorization header is missing"
}
無効なトークン
curl -H "Authorization: Bearer invalid-token" \
http://localhost:3000/api/protected

期待されるレスポンス (401):

{
"error": "Invalid token"
}

権限モデルごとのテスト

グローバルスコープで保護された API のテストシナリオ:

  • 有効なスコープ: 必要な API スコープ(例: api:read, api:write)を含むトークンでテスト
  • スコープ不足: 必要なスコープがない場合は 403 Forbidden を期待
  • 誤ったオーディエンス: オーディエンスが API リソースと一致しない場合は 403 Forbidden を期待
# 必要なスコープがないトークン - 403 を期待
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

さらに詳しく

実践でのロールベースのアクセス制御 (RBAC):アプリケーションのための安全な認可 (Authorization) の実装

マルチテナント SaaS アプリケーションの構築:設計から実装までの完全ガイド