LaravelのCSRFトークンをワンタイムトークン化

LaravelのCSRFトークンは画面を更新しても毎回同じ結果、CSRF
対策には物足りなさを感じたり、連打したら何回もリクエストが通ってしまうことを防ぎたいので考えてみました。

普通に別のトークンをhiddenに入れてチェックを行えば目的を達成できるのですがせっかくCSRFトークンがあるのでそれをいじってみる方法にしました。

フォーム画面で確認した程度なので不具合はあるかもしれません。

# ポイント

ミドルウェアのVerifyCsrfTokenでトークンをリフレッシュしても画面に出てくるトークンには反映されない、もっと上のレベルでリフレッシュを行う必要がありました。

# 実装

```php:app\Http\Middleware\OneTimeCsrfToken.php
<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Support\Facades\Cache;

class OneTimeCsrfToken
{
    public function handle($request, Closure $next)
    {
        // トークンをリフレッシュ
        $request->session()->regenerateToken();

        $key = $request->session()->getId().'_cache_token';
        $input_key = $request->session()->getId().'_input_token';

        if ($request->method() !== 'POST')
        {
            Cache::forget($key);
            // 画面に表示されるトークンを保持
            $input_token = $request->session()->token();
            Cache::put($input_key, $input_token, 1);
        }
        else
        {
            // POST時の初回のみキャッシュにリフレッシュしたトークンを載せる
            $cache_token = Cache::get($key);
            if (is_null($cache_token))
            {
                $cache_token = $request->session()->token();
                Cache::put($key, $cache_token, 1);
            }
        }

        return $next($request);
    }
}
```

```php:app\Http\Middleware\VerifyCsrfToken.php
<?php

namespace App\Http\Middleware;

use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as Middleware;
use Illuminate\Support\Facades\Cache;

class VerifyCsrfToken extends Middleware
{
    /**
     * The URIs that should be excluded from CSRF verification.
     *
     * @var array
     */
    protected $except = [
        //
    ];

    /**
     * Determine if the session and input CSRF tokens match.
     *
     * @param  \Illuminate\Http\Request  $request
     * @return bool
     */
    protected function tokensMatch($request)
    {
        $key = $request->session()->getId().'_cache_token';
        $input_key = $request->session()->getId().'_input_token';

        $cache_token = Cache::get($key);
        $input_token = Cache::get($input_key);

        $token = $this->getTokenFromRequest($request);

        // キャッシュに載せたリフレッシュしたトークン（POST時の初回のみ）とリフレッシュしたトークンが同じ場合は
        // 初回アクセスなのでチェックを通過させる
        if ($cache_token === $request->session()->token())
        {
            // フォーム画面からきたトークンと同じトークンか判定し、同じ場合はチェックを通過させる
            if ($token === $input_token)
            {
                // 強制的に通過させる為にsessionのトークンをinputのものと同じにする。
                $request->session()->put('_token', $token);
            }
        }

        $result_flag = is_string($request->session()->token()) &&
               is_string($token) &&
               hash_equals($request->session()->token(), $token);

        return $result_flag;
    }
}
```

```diff:app\Http\Kernel.php
protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
+            \App\Http\Middleware\OneTimeCsrfToken::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            'throttle:60,1',
            'bindings',
        ],
    ];
```