👻

LaravelのRateLimiterについて

2024/06/28に公開

Laravelにはレートリミッター機能があります。これに関して調べてます。

確認環境

一部しか検証してませんが、Laravel 9での実行を確認してます。
ただソースをチェックする際は11の方も見てたりみてなかったり。

簡単にルーティングにレート制限を定義する

この方法は簡単で、かつユーザー情報があればユーザーIDを元に、それ以外ならipを元にCache用のキーを生成するので、人を区別します。

// 1分間に60回アクセスできる状態
Route::get('index', [IndexController::class, 'index'])->middleware(['throttle:60']);

// 2分間に60回アクセス出来る状態
Route::get('index', [IndexController::class, 'index'])->middleware(['throttle:60,2']);

// 専用のprefixを設ける(基本的にはこれ
Route::get('index', [IndexController::class, 'index'])->middleware(['throttle:60,1,index']);

内部実装を見る限り、prefixを設けてない場合は他のリクエストでも同様に扱われてるのでprefixはつけたほうが良さそうです。(もともとの想定としては、api全体やグループをレート制限したいみたいなときに使われるような感じだった)
また、最近のLaravelだとドキュメントにもこの記述が載ってないので次に紹介する方法のほうが良いと思われる。

もうちょっと詳しくルーティングに制限を定義する

もう少し詳しくルーティング自体に設定する場合、RouteServiceProviderに以下のようなconfigureRateLimittingメソッドを用意します。

protected function configureRateLimiting(): void
{
    //
}

例えば、ユーザー毎に1分間に1回しか実行させないようなsendVerifyCodeを定義します。
byを指定しない場合、全体でということになるのでユーザーを指定しない場合は定義しません。

protected function configureRateLimiting(): void
{
    RateLimiter::for('sendVerifyCode', function(Request $request) {
        return Limit::perMinute(1)->by($request->user()?->id ?: $request->ip());
    });
}

middlewareとして適用したいRouteにlimitter名を指定します。

Route::post('send', [AccountController::class, 'postSendVerifyCode'])->middleware(['throttle:sendVerifyCode']);

リミットに引っかかった場合、429エラーが返されます。

解説(どうやってforの実装にたどり着くのか)

Laravel側自体の解説なので、ここは飛ばしたい人は飛ばそう。

一応forメソッドに触れますが、forメソッドはRateLimiter内のメンバ変数に入れてるだけなので実行する場所ではないです。

public function for(string $name, Closure $callback)
{
    $this->limiters[$name] = $callback;

    return $this;
}

app/Http/Kernel.php のrouteMiddlewareにthrottleのキーが記載されてますね。

protected $routeMiddleware = [
    'auth' => Authenticate::class,
    'can' => Authorize::class,
    'signed' => ValidateSignature::class,
    'trust.app' => TrustApp::class,
    'verified' => EnsureEmailIsVerified::class,
    'throttle' => ThrottleRequests::class,
];

ちなみにここはRedisキャッシュを利用する場合はThrottleRequestsではなくThrottleRequestsWithRedisを利用するのが良いみたいな記述をドキュメントのどこかで見たような気がします。ただ今回は通常の方を見ます。

public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
{
    if (is_string($maxAttempts)
        && func_num_args() === 3
        && ! is_null($limiter = $this->limiter->limiter($maxAttempts))) {
        return $this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter);
    }

    // 以降の実装は簡単にレート実装をする場合の処理コードなので省きます
}

既存の命名の問題でここはわかりにくいのですがmiddleware(['throttle:sendVerifyCode'])のように記述していた場合、第1引数と第2引数に関しては最初から用意されているため$maxAttemtsの部分にsendVerifyCode 文字列が入ることになります。
なので、文字列であって、引数の数が3つであり$this->limiter->limiter($maxAttempts)でforですでに指定していたlimiterを取得できるとifの中に入ります。

このあとの$this->handleRequestUsingNamedLimiter($request, $next, $maxAttempts, $limiter)に関してはすぐにlimiterのクロージャー(forで設定しておいたもの)を実行させてレスポンスを返す感じになります。

$limiterResponse = $limiter($request);

コントローラーなどで制御する

もっと細かく制御したい場合、ルーティングの設定ではなく他の設定を行います。
コントローラーの設定したいアクションで以下のような記述をします。

// リミット管理用のKey(自由に決めてよいが、ユーザーごとに単一である方が良いと思う
$rateLimiterKey = 'code-verify:' . auth()->user()->id;

if (\RateLimiter::tooManyAttempts($rateLimiterKey, 5)) {
    // 実行が5回を超えてる場合にこちらに入る
}

// 実処理(ここは自由に)
$result = $this->process();

// 失敗してたらリミット用の管理値を上昇させる
if (!$result) {
    // 10分間の有効期限でリミット値を上昇
    \RateLimiter::hit($rateLimiterKey, 600);
    // 何らかのエラーを返却
    return XXX;
}

// 正しい場合はリセットするのも手
\RateLimiter::clear($rateLimiterKey);

解説

リミット用のキー

まず、リミット用のkeyに関しては自由に設定ができ、このキーは多少クリーニングされて利用されます。

public function cleanRateLimiterKey($key)
{
    return preg_replace('/&([a-z])[a-z]+;/i', '$1', htmlentities($key));
}

おそらくXSSなどの対策とされますが、イマイチわかりません。とりあえずこの内容的に&などをaに変換する処理です。
あまりに変なkeyを設定しない限りはこの問題に引っかることはないでしょう。

hit

tooManyAttemptsはまずhitがわからないと理解が難しいのでhitから。

public function hit($key, $decaySeconds = 60)
{
    // keyのクリーニング後のkey
    $key = $this->cleanRateLimiterKey($key);

    // `$key名:timer` というキーで有効秒数まで有効なキャッシュキーを追加する
    // $this->availableAt($decaySeconds)は有効期限のタイムスタンプ値を返すので、
    // このキーの値には有効期限のunixタイムスタンプ値が入る。
    // addメソッドなので、すでに存在する場合は書き換えられない
    $this->cache->add(
        $key.':timer', $this->availableAt($decaySeconds), $decaySeconds
    );

    // keyが存在しなかったときのために初期値をaddする
    // addメソッドなのですでにキーが存在してたら結果はfalseになる
    $added = $this->cache->add($key, 0, $decaySeconds);

    // キーの値をインクリメントする
    $hits = (int) $this->cache->increment($key);

    // 追加されてないかつヒット数が1なら新規でキャッシュに追加
    if (! $added && $hits == 1) {
        $this->cache->put($key, 1, $decaySeconds);
    }

    return $hits;
}

コメントで結構びっしり書きましたが、
カウントをするようのキーと、有効期限を管理するようのキーをキャッシュとして保存するようになっています。

tooManyAttempts

tooManyAttemptsはhitの方がわかってしまえばわかりやすいですね。

public function tooManyAttempts($key, $maxAttempts)
{
    // キャッシュから取得した試行数 >= 最大試行数
    if ($this->attempts($key) >= $maxAttempts) {
        // タイマーのキャッシュも残っているなら試行回数が多いとしてtrue
        if ($this->cache->has($this->cleanRateLimiterKey($key).':timer')) {
            return true;
        }

        // タイマーのキャッシュが残ってないならこの試行数のキャッシュキーをリセットする
        $this->resetAttempts($key);
    }

    return false;
}

ということで、RateLimiterの機能が大体把握できたのではないかと思います。
RateLimiter、そこまでめちゃくちゃ使うわけでもないですがちょっと覚えておくと良いかもです。

Discussion