LoginSignup
4
2

More than 3 years have passed since last update.

Laravel 6.3 で リクエストのAPIキーへの検証・スロットリングをする

Posted at

こちらは Fusic その2 Advent Calendar 2019 - Qiita の19日目の記事です

GETでJSONを取得するAPIサーバを構築しているのですが、リクエストパラメータに付与されたAPIキーを使ってアクセス回数制限を行いたいという話になりました。

やりたいこと

  • LaravelへのリクエストをAPIキーをもとに回数制限(スロットリング)を行いたい
    • そもそもAPIキーがないリクエストは落としたい
    • そもそもAPIキーの検証もしたい
    • APIキーごとに制限回数は可変としたい
    • Laravel標準のThrottleRequestsを何とかして楽したい
  • 本番はアプリケーションサーバは複数台構成のため
    • キーの管理には DynamoDB を使いたい ←
    • キャッシュも DynamoDB を使いたい ←

環境

  • Laravel : 6.3
  • aws-sdk-php : 3.112.28

Illuminate\Routing\Middleware\ThrottleRequests について

Laravel では何もしなければ最初から APIリクエストに対してスロットルが有効になっています。

設定箇所

app/Http/Kernel.php に記載があります。

<?php

    /**
     * The application's route middleware groups.
     *
     * @var array
     */
    protected $middlewareGroups = [
        ...
        'api' => [
            'throttle:60,1',
            'bindings',
        ],
        ...
    ];

Laravel / ThrottleRequests の云々とかオーバーライドとか|開発室ブログ|株式会社アクセスジャパン
こちらの記事に中の挙動や詳しい解説書かれています。大変助かりました。

具体的な処理

Illuminate\Routing\Middleware\ThrottleRequests::handle で行われている処理を見てみます。

<?php

    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 1, $prefix = '')
    {
        // アクセス元ごとに、アクセス回数を管理するためのキーを発行する
        $key = $prefix.$this->resolveRequestSignature($request);

        // app/Http/Kernel.phpの記載 または リクエストしたユーザの情報から、最高試行回数を取得
        $maxAttempts = $this->resolveMaxAttempts($request, $maxAttempts);

        // キャッシュに記録されているキーのアクセス回数が最高試行回数を超えていないかチェック
        if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
            // 超えていた場合は 429 Too Many Requests を返却
            throw $this->buildException($key, $maxAttempts);
        }

        // キャッシュにキーのアクセスを加算
        $this->limiter->hit($key, $decayMinutes * 60);

        $response = $next($request);

        // ヘッダーに最高試行回数と残りアクセス可能な回数を追加
        return $this->addHeaders(
            $response, $maxAttempts,
            $this->calculateRemainingAttempts($key, $maxAttempts)
        );
    }

やってみる

やりたいことは、APIキーごとの検証・制限となるため APIキー をキャッシュに書き込むキーとすればよさそう

方針

  • APIキーの存在チェック: handleの最初にやる
  • APIキーの検証 : DynamoDBへ存在確認する
  • APIキーごとに制限回数は可変としたい : maxAttempts を DynamoDBから取得する。
    • DynamoDB はシンプルに key, max_attempts の2つを使うようにする
    • 最高試行回数 -1 はもう利用できないAPIキーとする

実装

準備

ローカルでもAWSでもいいので、キャッシュ用のDynamoDBとAPIキー管理用のDynamoDBが必要です。

キャッシュにDynamoDBを使う

Laravel5.8 から キャッシュストアにDynamoが公式で対応しているので簡単です。
envの値などは各自の環境で追加する必要があります。
こっちのDynamoは DYNAMODB_CACHE_TABLE に定義

config/cache.php に追記

<?php

return [
        ...
        'dynamodb' => [
            'driver' => 'dynamodb',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION', 'ap-northeast-1'),
            'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
            'endpoint' => env('DYNAMODB_ENDPOINT'),
        ],
        ...
];

.envCACHE_DRIVER をdynamodbに変更

CACHE_DRIVER=dynamodb

APIキーでスロットルするMiddlewareを作成

Illuminate\Routing\Middleware\ThrottleRequests を継承した ThrottleByApikey を作成し、handleの内容をオーバライド、必要な関数の追加

<?php

namespace App\Http\Middleware;

use Closure;
use Illuminate\Routing\Middleware\ThrottleRequests;
use RuntimeException;

Class ThrottleByApikey extends ThrottleRequests
{
    /**
     * Handle an incoming request. | override
     *
     * @param  \Illuminate\Http\Request  $request
     * @param  \Closure  $next
     * @param  int|string  $maxAttempts
     * @param  float|int  $decayMinutes
     * @param  string  $prefix
     * @return \Symfony\Component\HttpFoundation\Response
     *
     * @throws \Illuminate\Http\Exceptions\ThrottleRequestsException
     */
    public function handle($request, Closure $next, $maxAttempts = 60, $decayMinutes = 60, $prefix = '')
    {
        // APIキーがなければエラー
        if (empty($request->query('key'))) {
            return response("400 Bad Request. Missing API Key", 400)->header('Content-Type', 'text/plain');
        }

        // APIキー管理用のDynamoDBから最高試行回数を取得
        $maxAttempts = $this->getMaxAttemptsByDynamoDB($request);

        // 回数制限が-1 = 利用停止または不正なキーのためエラー
        if ($maxAttempts === -1) {
            return response("400 Bad Request. Invalid API Key", 400)->header('Content-Type', 'text/plain');
        }

        // APIキーからアクセス回数を管理するためのキーを発行する
        $key = $prefix . $this->resolveRequestSignature($request);

        if ($this->limiter->tooManyAttempts($key, $maxAttempts)) {
            $retryAfter = $this->getTimeUntilNextRetry($key);

            return response("400 Bad Request. Too Many Requests. Please retury after ${retryAfter} seconds", 400)->header('Content-Type', 'text/plain');
        }

        $this->limiter->hit($key, $decayMinutes * 60);

        $response = $next($request);

        return $this->addHeaders(
            $response, $maxAttempts,
            $this->calculateRemainingAttempts($key, $maxAttempts)
        );
    }


    /**
     * DynamoDB から APIキー ごとの回数制限を取得する
     *
     * @param  \Illuminate\Http\Request  $request
     * @return int
     */
    protected function getMaxAttemptsByDynamoDB($request)
    {
        $dynamodb = new \Aws\DynamoDb\DynamoDbClient([
            'version'  => '2012-08-10',
            'key' => env('AWS_ACCESS_KEY_ID'),
            'secret' => env('AWS_SECRET_ACCESS_KEY'),
            'region' => env('AWS_DEFAULT_REGION', 'ap-northeast-1'),
            'endpoint' => env('DYNAMODB_ENDPOINT'),
        ]);

        $result = $dynamodb->getItem([
            'TableName' => env('DYNAMODB_API_KEY_TABLE'),
            'Key' => [
                'key' => ['S' => $request->query('key')],
            ],
        ]);

        $maxAttempts = !is_null($result['Item']) ? $result['Item']['max_attempts']['N'] : -1;

        return (int) $maxAttempts;
    }

    /**
     * Resolve request signature. | Override
     *
     * @NOTE 回数を管理するテーブルのキーをAPIキーにする
     *
     * @param  \Illuminate\Http\Request  $request
     * @return string
     *
     * @throws \RuntimeException
     */
    protected function resolveRequestSignature($request)
    {
        if ($apiKey = $request->query('key')) {
            return sha1($apiKey);
        }

        throw new RuntimeException('Unable to generate the request signature. Route unavailable.');
    }
}

アプリケーションへの適用

上で作ったMiddlewareを app/Http/Kernel.php で割り当てます。

<?php

    protected $middlewareGroups = [
        ...
        'api' => [
            'throttle' => \App\Http\Middleware\ThrottleByApikey::class,
            'bindings',
        ],
        ...
    ];

参考

Laravel / ThrottleRequests の云々とかオーバーライドとか|開発室ブログ|株式会社アクセスジャパン
本実装を行うにあたり大変参考にさせていただきました。

4
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2