レート制限

レート制限

AdonisJSは、ウェブアプリケーションやAPIサーバーでレート制限を実装するための第一パーティパッケージを提供しています。レート制限は、redismysqlpostgresqlmemoryをストレージオプションとして提供し、カスタムストレージプロバイダの作成も可能です。

@adonisjs/limiterパッケージは、node-rate-limiter-flexibleパッケージをベースにしており、最速のレート制限APIの1つを提供し、競合状態を避けるためにアトミックインクリメントを使用しています。

インストール

次のコマンドを使用してパッケージをインストールし、設定します:

node ace add @adonisjs/limiter
  1. 検出されたパッケージマネージャを使用して@adonisjs/limiterパッケージをインストールします。

  2. adonisrc.tsファイル内に以下のサービスプロバイダを登録します。

    {
    providers: [
    // ...other providers
    () => import('@adonisjs/limiter/limiter_provider')
    ]
    }
  3. config/limiter.tsファイルを作成します。

  4. start/limiter.tsファイルを作成します。このファイルはHTTPスロットルミドルウェアを定義するために使用されます。

  5. start/env.tsファイル内で、以下の環境変数とそのバリデーションを定義します。

    LIMITER_STORE=redis
  6. databaseストアを使用する場合は、rate_limitsテーブルのデータベースマイグレーションを作成することもできます(オプション)。

設定

レート制限の設定は、config/limiter.tsファイル内に保存されます。

参照:レート制限の設定スタブ

import env from '#start/env'
import { defineConfig, stores } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
redis: stores.redis({}),
database: stores.database({
tableName: 'rate_limits'
}),
memory: stores.memory({}),
},
})
export default limiterConfig
declare module '@adonisjs/limiter/types' {
export interface LimitersList extends InferLimiters<typeof limiterConfig> {}
}

default

レート制限を適用するために使用するdefaultストアです。ストアは同じ設定ファイル内のstoresオブジェクトで定義されます。

stores

アプリケーション内で使用するストアのコレクションです。テスト中に使用できるmemoryストアを常に設定することをオススメします。


環境変数

デフォルトのレート制限は、LIMITER_STORE環境変数を使用して定義されています。したがって、異なるストアを異なる環境で切り替えることができます。たとえば、テスト中にmemoryストアを使用し、開発および本番環境ではredisストアを使用できます。

また、環境変数は、start/env.tsファイル内でEnv.schema.enumルールを使用して事前に設定されたストアのいずれかを許可するように検証する必要があります。

{
LIMITER_STORE: Env.schema.enum(['redis', 'database', 'memory'] as const),
}

共有オプション

以下は、すべてのバンドルされたストアで共有されるオプションのリストです。

keyPrefix

データベースストア内に格納されるキーのプレフィックスを定義します。データベースストアは異なるデータベーステーブルを使用してデータを分離できるため、keyPrefixは無視されます。

execEvenly

execEvenlyオプションは、リクエストのスロットリング時に遅延を追加し、すべてのリクエストが指定された期間の終わりに消費されるようにします。

たとえば、ユーザーに1分間に10リクエストを許可する場合、すべてのリクエストに人工的な遅延が追加され、10番目のリクエストが1分の終わりに終了します。rate-limiter-flexibleリポジトリのsmooth out traffic peaks記事を読んで、execEvenlyオプションについて詳しく学びましょう。

inMemoryBlockOnConsumed

メモリ内でキーをブロックするリクエスト数を定義します。たとえば、ユーザーには1分間に10リクエストを許可しますが、最初の10秒ですべてのリクエストを消費しました。

しかし、ユーザーはサーバーに対してリクエストを続けるため、レート制限はリクエストを拒否する前にデータベースに問い合わせる必要があります。

データベースへの負荷を軽減するために、指定されたリクエスト数を定義し、その後はデータベースへの問い合わせを停止し、メモリ内でキーをブロックできます。

{
duration: '1 minute',
requests: 10,
/**
* 12リクエスト後にキーをメモリ内でブロックし、
* データベースへの問い合わせを停止します。
*/
inMemoryBlockOnConsumed: 12,
}

inMemoryBlockDuration

メモリ内でキーをブロックする期間を定義します。このオプションにより、バックエンドストアはまずメモリ内をチェックしてキーがブロックされているかどうかを確認するため、データベースへの負荷が軽減されます。

{
inMemoryBlockDuration: '1 min'
}

Redisストア

redisストアは、@adonisjs/redisパッケージに依存しています。そのため、Redisストアを使用する前にこのパッケージを設定する必要があります。

以下は、redisストアが受け入れるオプションのリストです(共有オプションも含む)。

{
redis: stores.redis({
connectionName: 'main',
rejectIfRedisNotReady: false,
}),
}

connectionName

connectionNameプロパティは、config/redis.tsファイルで定義された接続を参照します。レート制限用には別のRedisデータベースを使用することをオススメします。

rejectIfRedisNotReady

Redis接続の状態がreadyでない場合、レート制限リクエストを拒否します。


データベースストア

databaseストアは、@adonisjs/lucidパッケージに依存しています。そのため、データベースストアを使用する前にこのパッケージを設定する必要があります。

以下は、データベースストアが受け入れるオプションのリストです(共有オプションも含む)。

データベースストアでは、MySQLとPostgreSQLのみを使用できます。

{
database: stores.database({
connectionName: 'mysql',
dbName: 'my_app',
tableName: 'rate_limits',
schemaName: 'public',
clearExpiredByTimeout: false,
}),
}

connectionName

config/database.tsファイルで定義されたデータベース接続への参照です。定義されていない場合は、デフォルトのデータベース接続が使用されます。

dbName

SQLクエリを実行するために使用するデータベースです。config/database.tsファイルで定義された接続設定からdbNameの値を推測しようとします。ただし、接続文字列を使用する場合は、このプロパティを介してデータベース名を指定する必要があります。

tableName

レート制限を保存するために使用するデータベーステーブルです。

schemaName

SQLクエリを実行するために使用するスキーマ(PostgreSQLのみ)です。

clearExpiredByTimeout

有効期限が切れたキーを5分ごとにクリアするようにデータベースストアが設定されています。ただし、1時間以上有効期限が切れているキーのみがクリアされます。

HTTPリクエストのスロットリング

レート制限が設定された後、limiter.defineメソッドを使用してHTTPスロットルミドルウェアを作成できます。limiterサービスは、config/limiter.tsファイルで定義された設定を使用して作成されたLimiterManagerクラスのシングルトンインスタンスです。

start/limiter.tsファイルを開くと、ルートまたはルートグループに適用できる事前定義されたグローバルスロットルミドルウェアが見つかります。同様に、アプリケーション内で必要な数だけスロットルミドルウェアを作成することもできます。

次の例では、グローバルスロットルミドルウェアがIPアドレスに基づいてユーザーが1分間に10リクエストを行うことを許可します。

start/limiter.ts
import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter.allowRequests(10).every('1 minute')
})

次のようにthrottleミドルウェアをルートに適用できます。

start/routes.ts
import router from '@adonisjs/core/services/router'
import { throttle } from '#start/limiter'
router
.get('/', () => {})
.use(throttle)

ダイナミックレート制限

別のミドルウェアを作成してAPIエンドポイントを保護するために、認証状態に基づいて動的なレート制限を適用することもできます。

start/limiter.ts
export const apiThrottle = limiter.define('api', (ctx) => {
/**
* ログイン済みのユーザーは、ユーザーIDごとに100リクエストを許可します。
*/
if (ctx.auth.user) {
return limiter
.allowRequests(100)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)
}
/**
* ゲストユーザーは、IPアドレスごとに10リクエストを許可します。
*/
return limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`ip_${ctx.request.ip()}`)
})
start/routes.ts
import { apiThrottle } from '#start/limiter'
router
.get('/api/repos/:id/stats', [RepoStatusController])
.use(apiThrottle)

バックエンドストアの切り替え

storeメソッドを使用して、スロットルミドルウェアに特定のバックエンドストアを使用できます。

例:

limiter
.allowRequests(10)
.every('1 minute')
.store('redis')

カスタムキーの使用

デフォルトでは、リクエストはユーザーのIPアドレスによってレート制限されます。ただし、usingKeyメソッドを使用してカスタムキーを指定することもできます。

limiter
.allowRequests(10)
.every('1 minute')
.usingKey(`user_${ctx.auth.user.id}`)

ユーザーのブロック

クォータを使い果たした後もリクエストを続けるユーザーを指定した期間ブロックする場合は、blockForメソッドを使用します。このメソッドは、秒または時間表現の形式で期間を受け入れます。

limiter
.allowRequests(10)
.every('1 minute')
/**
* 1分間に10リクエストを超える場合、30分間ブロックされます。
*/
.blockFor('30 mins')

ThrottleExceptionの処理

ユーザーが指定された時間枠内ですべてのリクエストを使い果たした場合、スロットルミドルウェアはE_TOO_MANY_REQUESTS例外をスローします。この例外は、以下のコンテンツネゴシエーションルールにしたがってHTTPレスポンスに自動的に変換されます。

ThrottleExceptionの処理

throttleミドルウェアは、指定された時間枠内ですべてのリクエストを使い果たした場合、E_TOO_MANY_REQUESTS例外をスローします。この例外は、以下のコンテンツネゴシエーションルールにしたがってHTTPレスポンスに自動的に変換されます。

  • Accept=application/jsonヘッダーを持つHTTPリクエストは、エラーメッセージの配列を受け取ります。各配列要素はメッセージプロパティを持つオブジェクトです。

  • Accept=application/vnd.api+jsonヘッダーを持つHTTPリクエストは、JSON API仕様に従ってフォーマットされたエラーメッセージの配列を受け取ります。

  • その他のリクエストは、プレーンテキストの応答メッセージを受け取ります。ただし、limiterエラーのカスタムエラーページを表示するためにstatus pagesを使用することもできます。

また、global exception handler内でエラーを自己処理することもできます。

import { errors } from '@adonisjs/limiter'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
protected debug = !app.inProduction
protected renderStatusPages = app.inProduction
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
const message = error.getResponseMessage(ctx)
const headers = error.getDefaultHeaders()
Object.keys(headers).forEach((header) => {
ctx.response.header(header, headers[header])
})
return ctx.response.status(error.status).send(message)
}
return super.handle(error, ctx)
}
}

エラーメッセージのカスタマイズ

例外をグローバルで処理する代わりに、limitExceededフックを使用してエラーメッセージ、ステータス、およびレスポンスヘッダーをカスタマイズすることもできます。

import limiter from '@adonisjs/limiter/services/main'
export const throttle = limiter.define('global', () => {
return limiter
.allowRequests(10)
.every('1 minute')
.limitExceeded((error) => {
error
.setStatus(400)
.setMessage('リクエストを処理できません。後でもう一度お試しください')
})
})

エラーメッセージの翻訳の使用

@adonisjs/i18nパッケージを設定している場合、errors.E_TOO_MANY_REQUESTSキーを使用してエラーメッセージの翻訳を定義できます。例えば:

resources/lang/fr/errors.json
{
"E_TOO_MANY_REQUESTS": "リクエストが多すぎます"
}

最後に、error.tメソッドを使用してカスタム翻訳キーを定義することもできます。

limitExceeded((error) => {
error.t('errors.rate_limited', {
limit: error.response.limit,
remaining: error.response.remaining,
})
})

直接の使用

HTTPリクエストのスロットリングと並行して、アプリケーションの他の部分にレート制限を適用するためにlimiterを使用することもできます。例えば、ログイン時に無効な資格情報を複数回提供した場合にユーザーをブロックするか、ユーザーが実行できる同時ジョブの数を制限するなどがあります。

リミッターの作成

アクションにレート制限を適用する前に、limiter.useメソッドを使用してLimiterクラスのインスタンスを取得する必要があります。useメソッドは、バックエンドストアの名前と以下のレート制限オプションを受け入れます。

  • requests: 指定された期間に許可するリクエストの数。
  • duration: 秒または時間表現文字列の期間。
  • block (オプション): すべてのリクエストが使い果たされた後にキーをブロックする期間。
  • inMemoryBlockOnConsumed (オプション): 共有オプションを参照。
  • inMemoryBlockDuration (オプション): 共有オプションを参照。
import limiter from '@adonisjs/limiter/services/main'
const reportsLimiter = limiter.use('redis', {
requests: 1,
duration: '1 hour'
})

デフォルトのストアを使用する場合は、最初のパラメータを省略できます。例えば:

const reportsLimiter = limiter.use({
requests: 1,
duration: '1 hour'
})

アクションにレート制限を適用する

リミッターのインスタンスを作成したら、attemptメソッドを使用してアクションにレート制限を適用できます。 このメソッドは、以下のパラメータを受け入れます。

  • レート制限に使用する一意のキー。
  • すべての試行が使い果たされるまで実行されるコールバック関数。

attemptメソッドは、コールバック関数の結果を返します(実行された場合)。それ以外の場合はundefinedを返します。

const key = 'user_1_reports'
/**
* 指定されたキーでアクションを実行しようとします。
* 結果はコールバック関数の戻り値または、コールバックが実行されなかった場合はundefinedになります。
*/
const executed = reportsLimiter.attempt(key, async () => {
await generateReport()
return true
})
/**
* 制限を超えたことをユーザーに通知します。
*/
if (!executed) {
const availableIn = await reportsLimiter.availableIn(key)
return `${availableIn}秒後に再試行してください`
}
return 'レポートが生成されました'

多数のログイン失敗を防止する

直接の使用の別の例として、ログインフォームで複数回の無効な試行を行った場合にIPアドレスからのログインを許可しないことがあります。

次の例では、limiter.penalizeメソッドを使用して、ユーザーが無効な資格情報を提供した場合に1つのリクエストを消費し、すべての試行が使い果たされた後に20分間ブロックするようにします。

limiter.penalizeメソッドは、次の引数を受け入れます。

  • レート制限に使用する一意のキー。
  • 実行された場合に1つのリクエストが消費されるコールバック関数。

penalizeメソッドは、コールバック関数の結果またはThrottleExceptionのインスタンスを返します。例外を使用して、次の試行までの残り時間を取得できます。

import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'
import limiter from '@adonisjs/limiter/services/main'
export default class SessionController {
async store({ request, response, session }: HttpContext) {
const { email, password } = request.only(['email', 'passwords'])
/**
* リミッターを作成する
*/
const loginLimiter = limiter.use({
requests: 5,
duration: '1 min',
blockDuration: '20 mins'
})
/**
* IPアドレス+メールの組み合わせを使用します。これにより、
* 攻撃者がメールを悪用している場合、実際のユーザーが
* ログインできなくなることなく、攻撃者のIPアドレスのみをペナルティとします。
*/
const key = `login_${request.ip()}_${email}`
/**
* User.verifyCredentialsを"penalize"メソッドでラップして、
* 無効な資格情報のエラーごとに1つのリクエストを消費します。
*/
const [error, user] = await loginLimiter.penalize(key, () => {
return User.verifyCredentials(email, password)
})
/**
* ThrottleExceptionの場合、カスタムエラーメッセージを含む
* ユーザーを元のページにリダイレクトします。
*/
if (error) {
session.flashAll()
session.flashErrors({
E_TOO_MANY_REQUESTS: `${error.response.availableIn}秒後に再試行してください`
})
return response.redirect().back()
}
/**
* それ以外の場合は、ユーザーをログインします。
*/
}
}

リクエストの手動消費

attemptメソッドとpenalizeメソッドの他にも、残りのリクエストを確認し、手動で消費するためにリミッターと対話できます。

次の例では、remainingメソッドを使用して、指定されたキーがすべてのリクエストを消費したかどうかを確認します。それ以外の場合は、incrementメソッドを使用して1つのリクエストを消費します。

import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute'
})
if (await requestsLimiter.remaining('unique_key') > 0) {
await requestsLimiter.increment('unique_key')
await performAction()
} else {
return 'リクエストが多すぎます'
}

上記の例では、remainingメソッドとincrementメソッドの間で競合状態が発生する可能性があります。そのため、代わりにconsumeメソッドを使用することをお勧めします。consumeメソッドはリクエスト数を増やし、すべてのリクエストが消費された場合に例外をスローします。

import { errors } from '@adonisjs/limiter'
try {
await requestsLimiter.consume('unique_key')
await performAction()
} catch (error) {
if (error instanceof errors.E_TOO_MANY_REQUESTS) {
return 'リクエストが多すぎます'
}
}

キーのブロック

リクエストがすべて消費された後にユーザーがリクエストを続ける場合、キーをより長い期間ブロックできます。

ブロックは、consumeメソッド、attemptメソッド、およびpenalizeメソッドが自動的に実行します。blockDurationオプションを使用してリミッターのインスタンスを作成することで、ブロックが実行されます。例えば:

import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute',
blockDuration: '30 mins'
})
/**
* ユーザーは1分間に10リクエストを行うことができます。ただし、
* 11番目のリクエストを送信すると、キーを30分間ブロックします。
*/
await requestLimiter.consume('a_unique_key')
/**
* consumeと同じ動作
*/
await requestLimiter.attempt('a_unique_key', () => {
})
/**
* 10回の失敗を許可し、その後30分間キーをブロックします。
*/
await requestLimiter.penalize('a_unique_key', () => {
})

最後に、blockメソッドを使用して指定された期間の間キーをブロックできます。

const requestsLimiter = limiter.use({
requests: 10,
duration: '1 minute',
})
await requestsLimiter.block('a_unique_key', '30 mins')

試行のリセット

リクエスト数を減らすか、キー全体をストレージから削除するために次のいずれかのメソッドを使用できます。

decrementメソッドはリクエスト数を1減らし、deleteメソッドはキーを削除します。ただし、decrementメソッドはアトミックではなく、並行性が高い場合にリクエスト数を-1に設定する可能性があります。

リクエスト数の減少
import limiter from '@adonisjs/limiter/services/main'
const jobsLimiter = limiter.use({
requests: 2,
duration: '5 mins',
})
await jobsLimiter.attempt('unique_key', async () => {
await processJob()
/**
* ジョブの処理が完了した後に消費されたリクエストを減らします。
* これにより、他のワーカーがスロットを使用できるようになります。
*/
await jobsLimiter.decrement('unique_key')
})
キーの削除
import limiter from '@adonisjs/limiter/services/main'
const requestsLimiter = limiter.use({
requests: 2,
duration: '5 mins',
})
await requestsLimiter.delete('unique_key')

テスト

レート制限に単一の(つまり、デフォルトの)ストアを使用している場合は、テスト中にmemoryストアに切り替えるために.env.testファイル内でLIMITER_STORE環境変数を定義できます。

.env.test
LIMITER_STORE=memory

テスト間でレート制限ストレージをクリアするには、limiter.clearメソッドを使用します。clearメソッドはストア名の配列を受け入れ、データベースをフラッシュします。

Redisを使用する場合、レートリミッター用に別のデータベースを使用することをオススメします。そうしないと、clearメソッドがデータベース全体をフラッシュし、アプリケーションの他の部分に影響を与える可能性があります。

import limiter from '@adonisjs/limiter/services/main'
test.group('Reports', (group) => {
group.each.setup(() => {
return () => limiter.clear(['redis', 'memory'])
})
})

または、引数なしでclearメソッドを呼び出すこともできます。すると、すべての設定されたストアがクリアされます。

test.group('Reports', (group) => {
group.each.setup(() => {
return () => limiter.clear()
})
})

カスタムストレージプロバイダの作成

カスタムストレージプロバイダは、LimiterStoreContractインターフェイスを実装し、以下のプロパティ/メソッドを定義する必要があります。

実装は任意のファイル/フォルダ内に記述できます。カスタムストアを作成するためには、サービスプロバイダは必要ありません。

import string from '@adonisjs/core/helpers/string'
import { LimiterResponse } from '@adonisjs/limiter'
import {
LimiterStoreContract,
LimiterConsumptionOptions
} from '@adonisjs/limiter/types'
/**
* 受け入れるカスタムオプションのセット
*/
export type MongoDbLimiterConfig = {
client: MongoDBConnection
}
export class MongoDbLimiterStore implements LimiterStoreContract {
readonly name = 'mongodb'
declare readonly requests: number
declare readonly duration: number
declare readonly blockDuration: number
constructor(public config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
this.requests = this.config.requests
this.duration = string.seconds.parse(this.config.duration)
this.blockDuration = string.seconds.parse(this.config.blockDuration)
}
/**
* 指定されたキーのリクエストを1つ消費します。すべてのリクエストが既に消費されている場合は、エラーをスローする必要があります。
*/
async consume(key: string | number): Promise<LimiterResponse> {
}
/**
* 指定されたキーのリクエストを1つ消費しますが、すべてのリクエストが消費されている場合はエラーをスローしません。
*/
async increment(key: string | number): Promise<LimiterResponse> {}
/**
* 指定されたキーのリクエストを1つ減らします。可能な場合は、リクエスト数を負の値に設定しないようにします。
*/
async decrement(key: string | number): Promise<LimiterResponse> {}
/**
* 指定された期間キーをブロックします。
*/
async block(
key: string | number,
duration: string | number
): Promise<LimiterResponse> {}
/**
* 指定されたキーの消費されたリクエスト数を設定します。明示的な期間が指定されていない場合は、設定ファイルから期間を推測する必要があります。
*/
async set(
key: string | number,
requests: number,
duration?: string | number
): Promise<LimiterResponse> {}
/**
* キーをストレージから削除します。
*/
async delete(key: string | number): Promise<boolean> {}
/**
* データベースからすべてのキーをフラッシュします。
*/
async clear(): Promise<void> {}
/**
* 指定されたキーに対するレートリミットのレスポンスを取得します。キーが存在しない場合は`null`を返します。
*/
async get(key: string | number): Promise<LimiterResponse | null> {}
}

設定ヘルパーの定義

実装が完了したら、設定ファイル内でプロバイダを使用するための設定ヘルパーを作成する必要があります。設定ヘルパーはLimiterManagerStoreFactory関数を返す必要があります。

MongoDbLimiterStoreの実装と同じファイル内に以下の関数を記述できます。

import { LimiterManagerStoreFactory } from '@adonisjs/limiter/types'
/**
* 設定ファイル内でmongoDbストアを使用するための設定ヘルパー
*/
export function mongoDbStore(config: MongoDbLimiterConfig) {
const storeFactory: LimiterManagerStoreFactory = (runtimeOptions) => {
return new MongoDbLimiterStore({
...config,
...runtimeOptions
})
}
}

設定ヘルパーの使用

完了したら、次のようにmongoDbStoreヘルパーを使用できます。

config/limiter.ts
import env from '#start/env'
import { mongoDbStore } from 'my-custom-package'
import { defineConfig } from '@adonisjs/limiter'
const limiterConfig = defineConfig({
default: env.get('LIMITER_STORE'),
stores: {
mongodb: mongoDbStore({
client: mongoDb // create mongoDb client
})
},
})

rate-limiter-flexibleドライバのラップ

node-rate-limiter-flexibleパッケージから既存のドライバをラップする場合は、RateLimiterBridgeを使用できます。

今度はブリッジを使用して同じMongoDbLimiterStoreを再実装してみましょう。

import { RateLimiterBridge } from '@adonisjs/limiter'
import { RateLimiterMongo } from 'rate-limiter-flexible'
export class MongoDbLimiterStore extends RateLimiterBridge {
readonly name = 'mongodb'
constructor(public config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
super(
new RateLimiterMongo({
storeClient: config.client,
points: config.requests,
duration: string.seconds.parse(config.duration),
blockDuration: string.seconds.parse(this.config.blockDuration)
// ... 他のオプションも提供します
})
)
}
/**
* clearメソッドを自己実装します。理想的には、
* config.clientを使用して削除クエリを発行します
*/
async clear() {}
}