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

こちらは [Fusic その2 Advent Calendar 2019 - Qiita](https://qiita.com/advent-calendar/2019/fusic-2) の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
<?php

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

[Laravel / ThrottleRequests の云々とかオーバーライドとか｜開発室ブログ｜株式会社アクセスジャパン](https://access-jp.co.jp/blogs/development/203)
こちらの記事に中の挙動や詳しい解説書かれています。大変助かりました。


## 具体的な処理

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

```php
<?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` の２つを使うようにする
  - 最高試行回数 `-1` はもう利用できないAPIキーとする

# 実装

## 準備

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

## キャッシュにDynamoDBを使う

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

`config/cache.php` に追記

```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'),
        ],
        ...
];
```

`.env` の `CACHE_DRIVER` をdynamodbに変更

```
CACHE_DRIVER=dynamodb
```


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

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

```php
<?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
<?php

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




# 参考
[Laravel / ThrottleRequests の云々とかオーバーライドとか｜開発室ブログ｜株式会社アクセスジャパン](https://access-jp.co.jp/blogs/development/203)
本実装を行うにあたり大変参考にさせていただきました。