こちらは 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キーとする
- DynamoDB はシンプルに
実装
準備
ローカルでも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'),
],
...
];
.env
の CACHE_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 の云々とかオーバーライドとか|開発室ブログ|株式会社アクセスジャパン
本実装を行うにあたり大変参考にさせていただきました。