Symfony5でAttributeによるリクエスト制御
PHP8のAttributeを使ったリクエスト制御
以前
AnnotationReaderを利用して
docsアノテーションの付いたControllerのリクエストを制御しました。
しかし時代はPHP8となり
Attribute
が追加されたので、前回と同様にXMLHttpRequestを制御するコードをAttributeで書いてみます。
- SymfonyのAutowiring設定が有効になっている前提です
- X-Requested-Withヘッダーの無いリクエストの場合に、4xxを返します
- CSRFのトークンvalidationも行います
- AttributeはReflectionで取ります
- 前回同様kernel.requestなどの カーネルイベント で処理します
今回のサンプルはXMLHttpRequestの制御ですが、他にも応用可能なのでぜひ色々試してみて下さい。
Attributeの作成
XMLHttpRequest.php
<?php
namespace App\Annotation;
use Attribute;
// Attributeの宣言自体をアトリビュートで行います
// Attribute::TARGET_METHOD でメソッドのみ利用できるAttributeになります
// 別途 Attribute::IS_REPEATABLE を設定すると、重複してAttributeを設置することができます
#[Attribute(Attribute::TARGET_METHOD)]
class XMLHttpRequest
{
// CSRF validation用のトークンキー
// 実際はCSRF validation用のAttributeに分けた方が良いです
public ?string $csrfValidationKey;
// レスポンスに利用するヘッダー
/** @var array<string> */
public array $headers;
// コンストラクタの引数は、そのままAttributeのパラメータになります
public function __construct(
array $headers = [],
?string $csrfValidationKey = null
)
{
$this->headers = $headers;
$this->csrfValidationKey = $csrfValidationKey;
}
public function hasCsrfValidationKey(): bool
{
return $this->csrfValidationKey !== null && trim($this->csrfValidationKey) !== '';
}
}
Controllerの作成
XHRTestController.php
<?php
namespace App\Controller;
use App\Annotation\XMLHttpRequest;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
class XHRTestController extends AbstractController
{
const KEY = 'TEST_KEY';
const HEADERS = [
// XSS防御用のヘッダー
'X-Content-Type-Options' => 'nosniff',
];
// Attributeにはconstや変数なども指定することができます
#[XMLHttpRequest(headers: self::HEADERS)]
#[Route('/1st', methods: 'GET')]
public function first(CsrfTokenManagerInterface $manager): array
{
// CSRFトークンを発行します
$token = $manager->refreshToken(self::KEY);
// Responseオブジェクト以外の返却は、この後のViewEventで制御します
return ['token' => $token];
}
// csrfValidationKeyを設定した場合、CSRF validationを行うようにします
#[XMLHttpRequest(headers: self::HEADERS, csrfValidationKey: self::KEY)]
#[Route('/2nd', methods: 'GET')]
public function second(): array
{
return ['stat' => 'wow'];
}
}
ひとまずAttributeとControllerを用意しました。
これから作成するEventSubscriberでコントローラのリクエストを制御していきます。
EventSubscriberの作成
XMLHttpRequestListener.php
<?php
namespace App\EventListener;
use App\Annotation\XMLHttpRequest;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Event\ExceptionEvent;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Symfony\Component\HttpKernel\Event\ViewEvent;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManager;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
class XMLHttpRequestListener implements EventSubscriberInterface
{
private CsrfTokenManager $csrfTokenManager;
public function __construct(CsrfTokenManagerInterface $csrfTokenManager)
{
$this->csrfTokenManager = $csrfTokenManager;
}
public static function getSubscribedEvents(): array
{
// イベントの発火条件・タイミングは公式docをご覧ください
// https://symfony.com/doc/current/reference/events.html
return [
KernelEvents::REQUEST => 'onKernelRequest',
KernelEvents::VIEW => 'onKernelView',
KernelEvents::EXCEPTION => 'onKernelException',
];
}
private function getAttribute(Request $request): ?XMLHttpRequest
{
try {
// リクエストを受け付けたコントローラメソッドのリクレクションを取得します
$method = new \ReflectionMethod($request->attributes->get('_controller'));
} catch (\Exception $e) {
return null;
}
// メソッドに設定されているAttributeのリフレクションを取得します
// 今回は Attribute::IS_REPEATABLE ではないので、空か1つ設定されているかのどちらかになります
$refs = $method->getAttributes(XMLHttpRequest::class);
if ($refs === []) {
return null;
}
// AttributeリフレクションをXMLHttpRequestオブジェクトして返します
return $refs[0]->newInstance();
}
public function onKernelRequest(RequestEvent $event): void
{
$request = $event->getRequest();
$attribute = $this->getAttribute($request);
if ($attribute === null) {
// 対象のAttributeが設定されていない場合はスルー
return;
}
// XHRでない場合は400を返します
if (!$request->isXmlHttpRequest()) {
$event->setResponse(new JsonResponse(null, 400, $attribute->headers));
}
// CSRFトークンキーが設定されている場合、トークンのvalidationを行います
if ($attribute->hasCsrfValidationKey()) {
$token = new CsrfToken(
$attribute->csrfValidationKey,
// 今回はリクエストヘッダーでトークンが設定されているケースを対象にしています
$request->headers->get($attribute->csrfValidationKey),
);
if (!$this->csrfTokenManager->isTokenValid($token)) {
$event->setResponse(new JsonResponse(null, 403, $attribute->headers));
}
}
}
// コントローラがResponseオブジェクト以外を戻り値にした場合に発火します
// ViewEventを設定することで、コントローラで配列を返すことが可能になっています
public function onKernelView(ViewEvent $event): void
{
$attribute = $this->getAttribute($event->getRequest());
if ($attribute === null) {
return;
}
$event->setResponse(new JsonResponse(
$event->getControllerResult(),
200,
$attribute->headers,
));
}
// コントローラで例外が投げられた際に発火します
public function onKernelException(ExceptionEvent $event): void
{
$attribute = $this->getAttribute($event->getRequest());
if ($attribute === null) {
return;
}
$event->setResponse(new JsonResponse(
['error' => $event->getThrowable()->getMessage()],
500,
$attribute->headers,
));
}
}
ここまで作成できたら、実際に各コントローラにアクセスしてみてください。
リクエストヘッダにX-Requested-With: XMLHttpRequestが無い場合は400が返却されます。
また、CSRFトークンが無い状態で/2ndにアクセスすると403が返却されるはずです。
/1stで取得したトークンをリクエストヘッダにTEST_KEY: [トークン]でセットして
/2ndにアクセスすることでwowが返却されます。