laravelメモ: CSRFトークンをPOST時に再生成するミドルウェア

メモって言っとけば適当な記事でも許されると思ってる

概要

「POSTした後でいちいち $request->session()->regenerateToken(); やるのめんどくない?」
から始まった企画。
多重送信防止に片足突っ込んでるからそういう実装をしたくなる。

動作保証しないけど突っ込みあるなら嬉しい

そもそもコントローラまで処理行ったところでトークンのリジェネするわけ?

これがまず浮かんだ疑問点。
そこまで処理進んじゃってるんなら多重リクエスト普通に通りますよね

というわけでミドルウェアでごちゃごちゃやりたいのでベース探して自分なりにいじることにしました。

実装

ベースは@horikesoさんが作った奴。
ありがとうございました。

設計として、トークンはPOST, PUT, DELETE等のリクエストが発生するまで固定。
上記リクエストが発生したら再生成します。

// app/Http/Middleware/RegenerateCsrfToken.php

class RegenerateCsrfToken
{
    public function handle($request, Closure $next)
    {
        $prevRequestKey = $request->session()->getId() . '_prev_request_token';
        $prevSessionKey = $request->session()->getId() . '_prev_session_token';

        if (in_array($request->method(), ['HEAD', 'GET', 'OPTIONS'])) {
            // キャッシュリクエストトークンを削除する
            Cache::forget($prevRequestKey);

        } else {
            // リフレッシュする前のセッショントークンをキャッシュに保持
            Cache::put(
                $prevSessionKey,
                $request->session()->token(),
                config('session.token_lifetime')
            );

            // 初回POST(PUT, DELETE)時はキャッシュトークンがnullになっている
            if (is_null(Cache::get($prevRequestKey))) {
                // 初回POST時だけキャッシュにリクエストトークンを保存する
                Cache::put(
                    $prevRequestKey,
                    $this->getTokenFromRequest($request),
                    config('session.token_lifetime')
                );

            } else {
                // POSTを続けて2回リクエストした場合にのみ、
                // セッショントークンをリフレッシュ
                $request->session()->regenerateToken();
            }
        }

        return $next($request);
    }

    protected function getTokenFromRequest($request)
    {
        $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

        if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
            $token = $this->encrypter->decrypt($header, static::serialized());
        }

        return $token;
    }
}
// app/Http/Middleware/VerifyCsrfToken .php

class VerifyCsrfToken extends Middleware
{
    /**
     * Indicates whether the XSRF-TOKEN cookie should be set on the response.
     *
     * @var bool
     */
    protected $addHttpCookie = true;

    /**
     * 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)
    {
        $requestToken = $this->getTokenFromRequest($request);

        $prevRequestKey = $request->session()->getId() . '_prev_request_token';
        $prevRequestToken = Cache::get($prevRequestKey);

        $sessionToken = $request->session()->token();

        $prevSessionKey = $request->session()->getId() . '_prev_session_token';
        $prevSessionToken = Cache::get($prevSessionKey);

        if (is_string($prevRequestToken)
                && is_string($requestToken)) {
            // セッショントークンとキャッシュされたセッショントークンが等しい場合、初回のPOST時となる
            if (hash_equals($sessionToken, $prevSessionToken)) {
                // セッショントークンとリクエストトークンが等しいかどうかの検証。
                // 任意のトークンを送信されたら不合致とする
                return hash_equals($sessionToken, $requestToken);

            } else {
                // POSTを連続で送信しているので不合致
                return false;
            }

        } else {
            // トークンが文字列でないので不合致
            return false;
        }
    }
}
// app/Http/Kernel.php

protected $middlewareGroups = [
        'web' => [
            \App\Http\Middleware\EncryptCookies::class,
            \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
            \Illuminate\Session\Middleware\StartSession::class,
            // \Illuminate\Session\Middleware\AuthenticateSession::class,
            \Illuminate\View\Middleware\ShareErrorsFromSession::class,
            \App\Http\Middleware\RegenerateCsrfToken::class,
            \App\Http\Middleware\VerifyCsrfToken::class,
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],

        'api' => [
            'throttle:60,1',
            \Illuminate\Routing\Middleware\SubstituteBindings::class,
        ],
];

付録: 調査

セッションのIDが決まるまで

$request->session() でセッションが取得できる。
セッションは Illuminate\Session\Store クラス。

まず、Illuminate\Session\SessionManager の buildSession() でセッションが作成される

// vendor/laravel/framework/src/Illuminate/Session/SessionManager.php

protected function buildSession($handler)
{
    return $this->config->get('session.encrypt')
            ? $this->buildEncryptedSession($handler)
            : new Store($this->config->get('session.cookie'), $handler);
}

セッションのnameは上記のようにconfig/sessionのcookieから設定できる。
実際見てみるとenvの値から設定している。

// config/session.php

'cookie' => env(
    'SESSION_COOKIE',
    Str::slug(env('APP_NAME', 'laravel'), '_').'_session'
),

上記のセッション作成時に呼び出しているコンストラクタを見ると、
$idがデフォルト引数のnullを利用していることが分かる。
これをsetterであるsetId()に投げてセットしている。

// vendor/laravel/framework/src/Illuminate/Session/Store.php

public function __construct($name, SessionHandlerInterface $handler, $id = null)
{
    $this->setId($id);

setId()では渡された$idが適切な形式であるかどうかを精査し、
適切であれば$idを、誤った形式であれば新規にセッションIDを発行している。
今回はnullを渡しているため、新規発行する。

// vendor/laravel/framework/src/Illuminate/Session/Store.php

public function setId($id)
{
    $this->id = $this->isValidId($id) ? $id : $this->generateSessionId();
}

public function isValidId($id)
{
    return is_string($id) && ctype_alnum($id) && strlen($id) === 40;
}

protected function generateSessionId()
{
    return Str::random(40);
}

次に、Illuminate\Session\Middleware\StartSessionミドルウェアでセッションにIDをセットする。
app/Html/kernel.phpでwebのミドルウェアグループに挿入されているIlluminate\Session\Middleware\StartSessionのことを指す。

// handle()
$request->setLaravelSession(
    $session = $this->startSession($request)
);

protected function startSession(Request $request)
{
    return tap($this->getSession($request), function ($session) use ($request) {
        $session->setRequestOnHandler($request);
        $session->start();
    });
}

public function getSession(Request $request)
{
    return tap($this->manager->driver(), function ($session) use ($request) 
{
        $session->setId($request->cookies->get($session->getName()));
    });
}

セッションには先ほど新規発行したIDがあるが、
クッキーのキーの中にセッションのnameが存在しているならそのvalueをIDとして採用しなおす。
$session->setId($request->cookies->get($session->getName()));

セッションがどのように値の保持をしているか

セッション開始時にハンドラを介して、セッションの実ファイルから値を読み込み、
Storeのattributesプロパティに読み込んだ値を配列として格納している。
プログラムの実行中はこのattributesの値を取得したり、または別の値を格納したりとメモリ上でやり取りをしている。

protected function loadSession()
{
    $this->attributes = array_merge($this->attributes, $this->readFromHandler());
}

protected function readFromHandler()
{
    if ($data = $this->handler->read($this->getId())) {
        $data = @unserialize($this->prepareForUnserialize($data));

        if ($data !== false && ! is_null($data) && is_array($data)) {
            return $data;
        }
    }

    return [];
}
// vendor/laravel/framework/src/Illuminate/Session/FileSessionHandler.php
// Storeのhandlerプロパティが保持しているハンドラ

public function read($sessionId)
{
    if ($this->files->isFile($path = $this->path.'/'.$sessionId)) {
        if ($this->files->lastModified($path) >= Carbon::now()->subMinutes($this->minutes)->getTimestamp()) {
            return $this->files->sharedGet($path);
        }
    }

    return '';
}

ここで、トークンがセッションに含まれていない場合、新規発行するという処理が行われる。
上記実装ソースではこれをセッショントークンと呼称している。

public function start()
{
    $this->loadSession();

    if (! $this->has('_token')) {
        $this->regenerateToken();
    }

    return $this->started = true;
}

そして、StartSessionの実行の終了直前にattributesを再度実ファイルに格納するため、
以下のsave()が実行される。

public function save()
{
    $this->ageFlashData();

    $this->handler->write($this->getId(), $this->prepareForStorage(
        serialize($this->attributes)
    ));

    $this->started = false;
}

なんかいろいろ取得できるもの

  • $request->session()
    • セッションを取得している
    • Illuminate\Session\Store
  • $request->session()->token()
    • セッショントークンが取得できる。
    • 実装としてはシンプルに _token をキーとしてセッションから取得しているだけ
public function token()
{
    return $this->get('_token');
}
  • $request->session()->regenerateToken();
    • これまたシンプルにセッションの _token の値をランダム生成しているだけ
public function regenerateToken()
{
    $this->put('_token', Str::random(40));
}

デフォルトのCSRFマッチング処理

セッショントークンと、
POST送信等されたトークン(上記実装ソースではリクエストークンと呼称している)とを比較している。

protected function tokensMatch($request)
{
    $token = $this->getTokenFromRequest($request);

    return is_string($request->session()->token()) &&
           is_string($token) &&
           hash_equals($request->session()->token(), $token);
}

protected function getTokenFromRequest($request)
{
    $token = $request->input('_token') ?: $request->header('X-CSRF-TOKEN');

    if (! $token && $header = $request->header('X-XSRF-TOKEN')) {
        $token = $this->encrypter->decrypt($header, static::serialized());
    }

    return $token;
}

bladeでCSRFトークンが利用される際、
リクエストークンにはセッショントークンの値がそのまんま流用されるため、上記のマッチングは基本的に合致することになる。

付録: 君は二重送信防止をしたいのか?

CSRFトークンの利用と二重送信防止は目的も実現方法も全く異なる。

CSRFトークンを切り替えるようにした上で、
送信ボタンをサブミットしないtype="button"にしておいて、
押下時にjsのイベントでdisabled属性を付与 + submit()とかするとたぶん硬い。

CSRFトークンの切り替え自体はCSRF対策では不要なものだと思う

付録: トークンはワンタイムにすべき?

そこまではいらないと思うんですが誰か言及してないかなぁ
文献がなかなか見つからん