【即解決】CakePHP4でAPIを実装してCORSが解消しないあなたへ【疑うべきはプリフライト】

2021年7月19日

プログラミング

Webシステムを、フロントサイドとサーバーサイドを分けて構築した際、
気にしないといけないのはCORS問題ですよね。

まずはCORSとはなんぞ?という人には話が進まないので意味を引用させていただきます。

オリジン間リソース共有 (CORS)

追加の HTTP ヘッダーを使用して、あるオリジンで動作しているウェブアプリケーションに、異なるオリジンにある選択されたリソースへのアクセス権を与えるようブラウザーに指示するための仕組みです。ウェブアプリケーションは、自分とは異なるオリジン (ドメイン、プロトコル、ポート番号) にあるリソースをリクエストするとき、オリジン間 HTTP リクエストを実行します。

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS

さて、今回、自社の開発でハマった環境が下記になります。

フロントサイド:localhost。React(Next.js)で構築。
サーバサイド:外部サーバー(EC2)。CakePHP4を採用。

この環境下でNext.jsからaxiosでリクエストすると

Access to XMLHttpRequest at 'http://xx.xx.xx.xx/hoge/api/hoge/hoge.json' from origin 'http://localhost:3000' has been blocked by CORS policy: Request header field authorization is not allowed by Access-Control-Allow-Headers in preflight response.

はいはい、CORSね。
ということでWebに色々とある参考文献を見ながらミドルウェア(CorsMiddleware.php)を作ったりしてみるも全く改善されず・・・。

最終的にいきついた問題はプリフライトでした。

御託は良いから早く答えを教えろ、という人のために原因と解決策をまとめます。

※Axiosも何もセットせずにAPI叩く分は問題ないのです。
※axiosにauthorizationヘッダをつけた際にプリフライトが発動します。これが要因なのです。

Preflight request (プリフライトリクエスト)

「プリフライト」リクエストは始めに OPTIONS メソッドによる HTTP リクエストを他のドメインにあるリソースに向けて送り、実際のリクエストを送信しても安全かどうかを確かめます。サイト間リクエストがユーザーデータに影響を与える可能性があるような場合に、このようにプリフライトを行います。

https://developer.mozilla.org/ja/docs/Web/HTTP/CORS#preflighted_requests

【プリフライトを通過するまでの道のり】

1.AxiosのGETリクエスト時に「Authorization」ヘッダーを付与

2.プリフライトが発動し、一旦サーバにはOPTIONSメソッドでリクエストされ、送信しても大丈夫かお伺いをたてる

3.サーバ側のルーティング設定でOPTIONSメソッドからのリクエストを受けれる状態にしておく

4.サーバー側でリクエスト許可とするためにheaderに
Access-Control-Allow-Origin: XXXX
Access-Control-Allow-Headers: authorization
をセットしてレスポンスを返す必要がある

5.フロント側はこのリクエストが承認されたので実際のGETメソッドをリクエストする

最終的な解決方法はCakePHP4のconfig/bootstrap.phpの最下部に下記のコードを実装すればOKです。

// origin, methodsは適切に変えてください
if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: POST, PUT, PATCH, DELETE, OPTIONS');
    header('Access-Control-Allow-Headers: authorization');
    header('Access-Control-Expose-Headers: authorization');
    exit(0);
}

おそらくはCakeのバグのような気もしてる。

というのもLaravelは何も設定せずとも通過するし、AWS Lambda(APIGateway)では正しくheader設定してあげれば通過します。※Laravelの場合はapi.phpで記載するリクエストはそもそもCORS無視とかだったような。

追記

プリフライトの問題が解決した後はCSRFの問題がありますね。
サーバサイドをAPI用途にする場合CSRFが邪魔になること、フロント側との約束事を決めたりと個別ルールが存在することがあります。

CakePHP4ではroutes.phpにてMiddlewareを挟むのではなく、
src/Application.phpにて記載するようです。

Cake公式もそう言ってるっぽいです。
https://book.cakephp.org/4/ja/controllers/middleware.html
以下サンプル。

 public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
{
    $csrf = new CsrfProtectionMiddleware(['httponly'=>true]);
    $csrf->skipCheckCallback(function($request) { 
        $controller = $request->getParam('controller');
        $action = $request->getParam('action');
        if (is_null($controller) || is_null($action)) {
            return false;
        }
        if (strcmp($controller, 'Api') == 0) {    // ApiControllerはCSRFを無視
            return true;
        }
        return false;
    });

    $middlewareQueue
        // Catch any exceptions in the lower layers,
        // and make an error page/response
        ->add(new ErrorHandlerMiddleware(Configure::read('Error')))

        // Handle plugin/theme assets like CakePHP normally does.
        ->add(new AssetMiddleware([
            'cacheTime' => Configure::read('Asset.cacheTime'),
        ]))

        // Add routing middleware.
        // If you have a large number of routes connected, turning on routes
        // caching in production could improve performance. For that when
        // creating the middleware instance specify the cache config name by
        // using it's second constructor argument:
        // `new RoutingMiddleware($this, '_cake_routes_')`
        ->add(new RoutingMiddleware($this))

        // Parse various types of encoded request bodies so that they are
        // available as array through $request->getData()
        // https://book.cakephp.org/4/en/controllers/middleware.html#body-parser-middleware
        ->add(new BodyParserMiddleware())

        // Cross Site Request Forgery (CSRF) Protection Middleware
        // https://book.cakephp.org/4/en/controllers/middleware.html#cross-site-request-forgery-csrf-middleware
        // 本来のcsrf処理をコメントアウト
        //->add(new CsrfProtectionMiddleware([
        //    'httponly' => true,
        //]));
        ->add($csrf);

    return $middlewareQueue;
}

※ただし、特定のルーティングスコープにCsrfProtectionMiddlewareを適用する場合はroutes.phpに記載する。

さてさて、ここでも追記です。
Cake側でAPI時のCORSの無効はこれでOKなのですが、フロント側のaxiosがこれを許してくれませんでした。
具体的に言うとCake側からのレスポンスのHeaderに
Access-Control-Allow-Origin
を付与してやる必要があります。 これをMiddleWare\CorsMiddleware.phpにて担うことにします。

<?php
declare(strict_types=1);

namespace App\Middleware;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Server\MiddlewareInterface;
use Psr\Http\Server\RequestHandlerInterface;
use Cake\Log\Log;

class CorsMiddleware implements MiddlewareInterface
{
    /**
     * @param \Psr\Http\Message\ServerRequestInterface $request request
     * @param \Psr\Http\Server\RequestHandlerInterface $handler handler
     * @return \Psr\Http\Message\ResponseInterface
     */
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
    {
        $response = $handler->handle($request);
        
        $response = $response->withHeader('Access-Control-Allow-Origin', '*');
        $response = $response->withHeader('Access-Control-Allow-Methods', '*');
        $response = $response->withHeader('Access-Control-Allow-Headers', 'Content-Type');
        $response = $response->withHeader('Access-Control-Max-Age', '172800');
        return $response;
    }
}

※必要なのはAccess-Control-Allow-Originなのですが、Methodなども一応つけています。
※('Access-Control-Allow-Origin’, '*’)としてますが*は実際のフロント側のドメインを指定します。

CorsMiddlewareを作成したらconfig/routes.phpにちゃんと記載しておきましょう。

$routes->scope('/api', ['prefix' => 'Api'], function (RouteBuilder $builder) {
    $builder->registerMiddleware('bodies', new BodyParserMiddleware());
    $builder->applyMiddleware('bodies');

    // 追記
    $builder->registerMiddleware('cors', new CorsMiddleware());
    $builder->applyMiddleware('cors');

ここまで書いておいてあれなんですが、Middlewareを挟まなくてもbootstrap.phpですべて対応させる方法もあります。
メソッドがOPTIONS(プリフライト)の時はそこで処理を終了させ、それ以外の通常メソッドはHearderにAccess-Control-Allow-Originを付与することをデフォルトとするやり方です。

if ($_SERVER['REQUEST_METHOD'] == 'OPTIONS') {
    Log::debug('bootstrap-options');
    header('Access-Control-Allow-Origin: *');
    header('Access-Control-Allow-Methods: POST, PUT, PATCH, DELETE, OPTIONS');
    header('Access-Control-Allow-Headers: authorization, Content-Type');
    exit(0);
} else {
    header('Access-Control-Allow-Origin: *');
}

この辺のベストプラクティスってどうなんでしょうね。

要約すると、
「Cake側のApplication.phpでCORSはスキップするが、レスポンスヘッダーにはオリジン許可をする」
が正しい理解となりそうです。

2〜3日ほどハマったことなので備忘録として。
同じように悩まれている方がいらっしゃれば参考にしてもらえると嬉しいです。

それでは、また。