ข้ามไปยังเนื้อหาหลัก

ปกป้อง API Symfony ของคุณด้วยการควบคุมการเข้าถึงตามบทบาท (RBAC) และการตรวจสอบ JWT

คู่มือนี้จะช่วยให้คุณนำการอนุญาต (Authorization) ไปใช้เพื่อรักษาความปลอดภัยให้กับ API Symfony ของคุณ โดยใช้ การควบคุมการเข้าถึงตามบทบาท (RBAC) และ JSON Web Tokens (JWTs) ที่ออกโดย Logto

ก่อนเริ่มต้น

แอปพลิเคชันไคลเอนต์ของคุณจำเป็นต้องขอรับโทเค็นการเข้าถึง (Access tokens) จาก Logto หากคุณยังไม่ได้ตั้งค่าการเชื่อมต่อกับไคลเอนต์ โปรดดู เริ่มต้นอย่างรวดเร็ว สำหรับ React, Vue, Angular หรือเฟรมเวิร์กฝั่งไคลเอนต์อื่น ๆ หรือดู คู่มือเครื่องต่อเครื่อง สำหรับการเข้าถึงแบบเซิร์ฟเวอร์ต่อเซิร์ฟเวอร์

คู่มือนี้เน้นที่ การตรวจสอบโทเค็นฝั่งเซิร์ฟเวอร์ ในแอป Symfony ของคุณ

A figure showing the focus of this guide

สิ่งที่คุณจะได้เรียนรู้

  • การตรวจสอบ JWT: เรียนรู้วิธีตรวจสอบโทเค็นการเข้าถึง (Access tokens) และดึงข้อมูลการยืนยันตัวตน (Authentication)
  • การสร้าง Middleware: สร้าง middleware ที่นำกลับมาใช้ซ้ำได้สำหรับการปกป้อง API
  • โมเดลสิทธิ์ (Permission models): เข้าใจและนำรูปแบบการอนุญาต (Authorization) ที่แตกต่างกันไปใช้:
    • ทรัพยากร API ระดับโกลบอลสำหรับ endpoint ทั่วทั้งแอปพลิเคชัน
    • สิทธิ์ขององค์กรสำหรับควบคุมฟีเจอร์เฉพาะผู้เช่า (tenant)
    • ทรัพยากร API ระดับองค์กรสำหรับการเข้าถึงข้อมูลแบบหลายผู้เช่า (multi-tenant)
  • การผสาน RBAC: บังคับใช้สิทธิ์และขอบเขต (Scopes) ตามบทบาท (RBAC) ใน endpoint ของ API ของคุณ

ข้อกำหนดเบื้องต้น

  • ติดตั้ง PHP เวอร์ชันเสถียรล่าสุด
  • มีความเข้าใจพื้นฐานเกี่ยวกับ Symfony และการพัฒนาเว็บ API
  • ตั้งค่าแอป Logto เรียบร้อยแล้ว (ดู เริ่มต้นอย่างรวดเร็ว หากยังไม่ได้ตั้งค่า)

ภาพรวมของโมเดลสิทธิ์ (Permission models overview)

ก่อนดำเนินการปกป้องทรัพยากร ให้เลือกโมเดลสิทธิ์ที่เหมาะสมกับสถาปัตยกรรมแอปพลิเคชันของคุณ ซึ่งสอดคล้องกับ สถานการณ์การอนุญาต (authorization scenarios) หลักสามแบบของ Logto:

Global API resources RBAC
  • กรณีการใช้งาน: ปกป้องทรัพยากร API ที่ใช้ร่วมกันทั่วทั้งแอปพลิเคชัน (ไม่เฉพาะองค์กร)
  • ประเภทโทเค็น: โทเค็นการเข้าถึง (Access token) ที่มีผู้รับ (audience) ระดับโกลบอล
  • ตัวอย่าง: Public APIs, บริการหลักของผลิตภัณฑ์, จุดเชื่อมต่อสำหรับผู้ดูแลระบบ
  • เหมาะสำหรับ: ผลิตภัณฑ์ SaaS ที่มี API ใช้ร่วมกันโดยลูกค้าทุกคน, microservices ที่ไม่มีการแยก tenant
  • เรียนรู้เพิ่มเติม: ปกป้องทรัพยากร API ระดับโกลบอล

💡 เลือกโมเดลของคุณก่อนดำเนินการต่อ - การนำไปใช้จะอ้างอิงแนวทางที่คุณเลือกตลอดคู่มือนี้

ขั้นตอนเตรียมความพร้อมอย่างรวดเร็ว

กำหนดค่าทรัพยากรและสิทธิ์ของ Logto

  1. สร้างทรัพยากร API: ไปที่ Console → ทรัพยากร API และลงทะเบียน API ของคุณ (เช่น https://api.yourapp.com)
  2. กำหนดสิทธิ์: เพิ่มขอบเขต (scopes) เช่น read:products, write:orders – ดู กำหนดทรัพยากร API พร้อมสิทธิ์
  3. สร้างบทบาทระดับโกลบอล: ไปที่ Console → บทบาท และสร้างบทบาทที่รวมสิทธิ์ API ของคุณ – ดู กำหนดค่าบทบาทระดับโกลบอล
  4. กำหนดบทบาท: กำหนดบทบาทให้กับผู้ใช้หรือแอป M2M ที่ต้องการเข้าถึง API
ใหม่กับ RBAC?:

เริ่มต้นด้วย คู่มือการควบคุมการเข้าถึงตามบทบาท (RBAC) ของเรา สำหรับคำแนะนำการตั้งค่าแบบทีละขั้นตอน

อัปเดตแอปพลิเคชันฝั่งไคลเอนต์ของคุณ

ร้องขอขอบเขต (scopes) ที่เหมาะสมในไคลเอนต์ของคุณ:

กระบวนการนี้มักเกี่ยวข้องกับการอัปเดตการกำหนดค่าไคลเอนต์ของคุณเพื่อรวมหนึ่งหรือมากกว่ารายการต่อไปนี้:

  • พารามิเตอร์ scope ในกระบวนการ OAuth
  • พารามิเตอร์ resource สำหรับการเข้าถึงทรัพยากร API
  • organization_id สำหรับบริบทขององค์กร
ก่อนเริ่มเขียนโค้ด:

ตรวจสอบให้แน่ใจว่าผู้ใช้หรือแอป M2M ที่คุณทดสอบได้รับการกำหนดบทบาทหรือบทบาทขององค์กรที่มีสิทธิ์ที่จำเป็นสำหรับ API ของคุณแล้ว

เริ่มต้นโปรเจกต์ API ของคุณ

ในการเริ่มต้นโปรเจกต์ Symfony ใหม่สำหรับการพัฒนา API ให้ใช้ 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 พื้นฐาน จากนั้นกำหนดค่า framework สำหรับการพัฒนา API:

config/packages/framework.yaml
framework:
secret: '%env(APP_SECRET)%'
serializer:
enabled: true
property_access:
enabled: true
บันทึก:

ดูรายละเอียดเพิ่มเติมเกี่ยวกับการตั้งค่า controller, service และฟีเจอร์อื่น ๆ ได้จากเอกสารของ Symfony

กำหนดค่าคงที่และยูทิลิตี้

กำหนดค่าคงที่และยูทิลิตี้ที่จำเป็นในโค้ดของคุณเพื่อจัดการการดึงและตรวจสอบโทเค็น คำขอที่ถูกต้องต้องมี header 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 tenant ของคุณ

คุณจะต้องใช้ค่าต่อไปนี้เพื่อยืนยันโทเค็นที่ออกโดย Logto:

  • URI ของ JSON Web Key Set (JWKS): URL ไปยัง public keys ของ Logto ใช้สำหรับตรวจสอบลายเซ็นของ JWT
  • ผู้ออก (Issuer): ค่าผู้ออกที่คาดหวัง (OIDC URL ของ Logto)

ขั้นแรก ให้ค้นหา endpoint ของ Logto tenant ของคุณ คุณสามารถหาได้จากหลายที่:

  • ใน Logto Console ที่ SettingsDomains
  • ในการตั้งค่าแอปพลิเคชันใด ๆ ที่คุณตั้งค่าใน Logto, SettingsEndpoints & Credentials

ดึงค่าจาก OpenID Connect discovery endpoint

ค่าทั้งหมดนี้สามารถดึงได้จาก OpenID Connect discovery endpoint ของ Logto:

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) คุณสามารถเขียนค่าคงที่เหล่านี้ไว้ในโค้ดของคุณได้ อย่างไรก็ตาม ไม่แนะนำให้ใช้วิธีนี้ในแอปพลิเคชัน production เพราะอาจเพิ่มภาระในการดูแลรักษาหากมีการเปลี่ยนแปลงค่าคอนฟิกในอนาคต

  • JWKS URI: https://<your-logto-endpoint>/oidc/jwks
  • ผู้ออก (Issuer): https://<your-logto-endpoint>/oidc

ตรวจสอบโทเค็นและสิทธิ์ (permissions)

หลังจากดึงโทเค็นและดึงข้อมูล OIDC config แล้ว ให้ตรวจสอบสิ่งต่อไปนี้:

  • ลายเซ็น (Signature): JWT ต้องถูกต้องและลงนามโดย Logto (ผ่าน JWKS)
  • ผู้ออก (Issuer): ต้องตรงกับผู้ออกของ Logto tenant ของคุณ
  • ผู้รับ (Audience): ต้องตรงกับตัวบ่งชี้ทรัพยากร API ที่ลงทะเบียนใน Logto หรือบริบทขององค์กรหากเกี่ยวข้อง
  • วันหมดอายุ (Expiration): โทเค็นต้องไม่หมดอายุ
  • สิทธิ์ (ขอบเขต) (Permissions (scopes)): โทเค็นต้องมีขอบเขตที่จำเป็นสำหรับ API / การกระทำของคุณ ขอบเขตจะเป็นสตริงที่คั่นด้วยช่องว่างใน scope การอ้างสิทธิ์ (claim)
  • บริบทองค์กร (Organization context): หากปกป้องทรัพยากร API ระดับองค์กร ให้ตรวจสอบการอ้างสิทธิ์ organization_id

ดู JSON Web Token เพื่อเรียนรู้เพิ่มเติมเกี่ยวกับโครงสร้างและการอ้างสิทธิ์ของ JWT

สิ่งที่ต้องตรวจสอบสำหรับแต่ละโมเดลสิทธิ์ (What to check for each permission model)

การอ้างสิทธิ์ (claims) และกฎการตรวจสอบจะแตกต่างกันไปตามโมเดลสิทธิ์:

  • การอ้างสิทธิ์ผู้รับ (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('ไม่สามารถดึง 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('ผู้ออกไม่ถูกต้อง', 401);
}

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

} catch (AuthorizationException $e) {
throw $e;
} catch (Exception $e) {
throw new AuthorizationException('โทเค็นไม่ถูกต้อง: ' . $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
{
// เพิ่มตรรกะการตรวจสอบของคุณที่นี่ตามโมเดลสิทธิ์
// ตัวอย่างจะอยู่ในส่วนโมเดลสิทธิ์ด้านล่าง
}
}

จากนั้น ให้สร้างมิดเดิลแวร์เพื่อตรวจสอบ access token:

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);

// เก็บข้อมูล auth ใน attributes ของ request เพื่อใช้งานทั่วไป
$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; // ดำเนินการต่อไปยัง controller
}

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 claim) ให้ตรงกับตัวบ่งชี้ทรัพยากร API ของคุณ
$audiences = $payload['aud'] ?? [];
if (is_string($audiences)) {
$audiences = [$audiences];
}

if (!in_array('https://your-api-resource-indicator', $audiences)) {
throw new AuthorizationException('ผู้รับไม่ถูกต้อง');
}

// ตรวจสอบขอบเขตที่จำเป็นสำหรับทรัพยากร API ระดับโกลบอล
$requiredScopes = ['api:read', 'api:write']; // เปลี่ยนเป็นขอบเขตที่คุณต้องการจริง
$scopes = !empty($payload['scope']) ? explode(' ', $payload['scope']) : [];

foreach ($requiredScopes as $scope) {
if (!in_array($scope, $scopes)) {
throw new AuthorizationException('ขอบเขตไม่เพียงพอ');
}
}
}

นำ middleware ไปใช้กับ API ของคุณ

ตอนนี้ ให้นำ middleware ไปใช้กับเส้นทาง 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
{
// เข้าถึงข้อมูลการยืนยันตัวตนจากแอตทริบิวต์ของ request
$auth = $request->attributes->get('auth');
return $this->json(['auth' => $auth->toArray()]);
}
}

ทดสอบ API ที่ได้รับการป้องกันของคุณ

รับโทเค็นการเข้าถึง (Access tokens)

จากแอปพลิเคชันไคลเอนต์ของคุณ: หากคุณได้ตั้งค่าการเชื่อมต่อไคลเอนต์แล้ว แอปของคุณจะสามารถรับโทเค็นได้โดยอัตโนมัติ ดึงโทเค็นการเข้าถึงและนำไปใช้ในคำขอ API

สำหรับการทดสอบด้วย curl / Postman:

  1. โทเค็นผู้ใช้: ใช้เครื่องมือสำหรับนักพัฒนาของแอปไคลเอนต์ของคุณเพื่อคัดลอกโทเค็นการเข้าถึงจาก localStorage หรือแท็บ network

  2. โทเค็นเครื่องต่อเครื่อง: ใช้ client credentials flow ตัวอย่างที่ไม่เป็นทางการโดยใช้ 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"

    คุณอาจต้องปรับพารามิเตอร์ resource และ scope ให้ตรงกับทรัพยากร API และสิทธิ์ของคุณ; อาจต้องใช้พารามิเตอร์ organization_id หาก API ของคุณอยู่ในขอบเขตองค์กร

เคล็ดลับ:

ต้องการตรวจสอบเนื้อหาโทเค็นใช่ไหม? ใช้ JWT decoder ของเราเพื่อถอดรหัสและตรวจสอบ JWT ของคุณ

ทดสอบ endpoint ที่ได้รับการป้องกัน

คำขอที่มีโทเค็นถูกต้อง
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"
}

การทดสอบเฉพาะโมเดลสิทธิ์ (Permission model-specific testing)

กรณีทดสอบสำหรับ API ที่ได้รับการป้องกันด้วย global scopes:

  • ขอบเขตถูกต้อง: ทดสอบด้วยโทเค็นที่มีขอบเขต API ที่ต้องการ (เช่น api:read, api:write)
  • ขาดขอบเขต: คาดหวัง 403 Forbidden เมื่อโทเค็นไม่มีขอบเขตที่จำเป็น
  • audience ไม่ถูกต้อง: คาดหวัง 403 Forbidden เมื่อ audience ไม่ตรงกับทรัพยากร API
# โทเค็นที่ขาดขอบเขต - คาดหวัง 403
curl -H "Authorization: Bearer token-without-required-scopes" \
http://localhost:3000/api/protected

อ่านเพิ่มเติม

RBAC ในทางปฏิบัติ: การนำการอนุญาต (Authorization) ที่ปลอดภัยมาใช้กับแอปพลิเคชันของคุณ

สร้างแอปพลิเคชัน SaaS แบบหลายผู้เช่า: คู่มือฉบับสมบูรณ์ตั้งแต่การออกแบบจนถึงการนำไปใช้