-ao- ramune blog

©2024 unio / GO2直営からふるラムネ
2022年12月29日

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が返却されます。