速率限制

本指南介绍 AdonisJS 应用中的速率限制。你将学习如何:

  • 安装并配置 limiter 包,使用 Redis、数据库或内存存储
  • 为 HTTP 请求创建 throttle 中间件
  • 根据用户的身份验证状态应用动态速率限制
  • 直接使用速率限制来保护登录和任务队列
  • 处理速率限制异常并自定义错误消息
  • 创建自定义的存储提供者

概述

速率限制控制用户在给定的时间段内可以向你的应用发起多少请求。当用户超过其限制时,后续请求会被拒绝,直到时间窗口重置。

你需要速率限制来保护你的应用免受滥用。如果没有它,单个用户(或机器人)可以用请求淹没你的服务器,消耗本应属于合法用户的资源。速率限制还有助于防止对登录表单的暴力破解攻击,保护昂贵的 API 端点不被过度使用,并确保对共享资源的公平访问。

@adonisjs/limiter 包构建在 node-rate-limiter-flexible 之上,后者提供了最快的速率限制 API 之一,并使用原子递增(atomic increment)来避免竞态条件。

安装

使用以下命令安装并配置该包:

node ace add @adonisjs/limiter
查看 add 命令执行的步骤
  1. 使用检测到的包管理器安装 @adonisjs/limiter 包。

  2. adonisrc.ts 文件中注册以下服务提供者。

    {
      providers: [
        // ...其他提供者
        () => import('@adonisjs/limiter/limiter_provider')
      ]
    }
  3. 创建 config/limiter.ts 文件。

  4. 创建 start/limiter.ts 文件,用于定义 HTTP throttle 中间件。

  5. start/env.ts 文件中定义以下环境变量及其校验。

    LIMITER_STORE=redis
  6. 如果使用 database 存储,可选地创建 rate_limits 表的数据库迁移。

配置

速率限制配置存储在 config/limiter.ts 文件中。你可以定义哪些存储后端可用,以及默认使用哪一个。

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 属性指定用于速率限制的存储。stores 对象定义了所有可用的存储后端。我们建议始终配置 memory 存储,以便你在测试期间使用它。

另请参阅: 速率限制器配置桩

环境变量

默认存储由 LIMITER_STORE 环境变量控制,允许你在不同环境之间切换存储。例如,你可以在测试期间使用 memory,在生产环境中使用 redis

环境变量必须在 start/env.ts 中进行校验,以确保只允许已配置的存储:

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

共享选项

所有存储后端都接受以下选项:

config/limiter.ts
{
  duration: '1 minute',
  requests: 10,

  /**
   * 在 12 次请求之后,在内存中阻塞该键,
   * 并停止查询数据库。
   */
  inMemoryBlockOnConsumed: 12,
  inMemoryBlockDuration: '1 min'
}
keyPrefix

存储中键的前缀。数据库存储会忽略此项,因为独立的表已经提供了隔离。

execEvenly

添加人为延迟,以将请求均匀地分散到时间窗口中。详情参见 平滑流量峰值

inMemoryBlockOnConsumed

在达到该请求次数后,在内存中阻塞该键,从而减少来自滥用用户的数据库查询。

inMemoryBlockDuration

在内存中阻塞键的时长。通过先检查内存来减少数据库负载。inMemoryBlockOnConsumed 选项在用户耗尽配额后继续发起请求时很有用。你可以不在内存中阻塞他们,而是不对每个被拒绝的请求都查询数据库:

Redis 存储

Redis 存储要求先配置好 @adonisjs/redis 包。

config/limiter.ts
{
  redis: stores.redis({
    connectionName: 'main',
    rejectIfRedisNotReady: false,
  }),
}
connectionName

来自 config/redis.ts 的 Redis 连接。我们建议使用独立的数据库来作为 limiter 的存储。

rejectIfRedisNotReady

当为 true 时,如果 Redis 连接状态不是 ready,则拒绝速率限制请求。

数据库存储

数据库存储要求先配置好 @adonisjs/lucid 包。

Warning

数据库存储仅支持 MySQL、PostgreSQL 和 SQLite。其他数据库(如 MongoDB)不兼容,会在运行时抛出错误。

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

来自 config/database.ts 的数据库连接。如果未指定,使用默认连接。

dbName

用于 SQL 查询的数据库名称。从连接配置推断,但在使用连接字符串时必填。

tableName

用于存储速率限制数据的表。

schemaName

用于 SQL 查询的 schema(仅 PostgreSQL)。

clearExpiredByTimeout

当为 true 时,每 5 分钟清理一次过期的键。只移除已过期超过 1 小时的键。

对 HTTP 请求进行节流

最常见的用例是使用中间件对 HTTP 请求进行节流。limiter.define 方法创建可复用的 throttle 中间件,你可以将其应用到路由上。

打开 start/limiter.ts 文件,查看预定义的全局 throttle 中间件。该中间件基于用户的 IP 地址,允许用户每分钟发起 10 个请求:

start/limiter.ts
import limiter from '@adonisjs/limiter/services/main'

export const throttle = limiter.define('global', () => {
  return limiter.allowRequests(10).every('1 minute')
})

将中间件应用到任意路由:

start/routes.ts
import router from '@adonisjs/core/services/router'
import { throttle } from '#start/limiter'

router
  .get('/', () => {})
  .use(throttle)

当用户在 1 分钟内超过 10 个请求时,在窗口重置之前,他们会收到一个 429 Too Many Requests 响应。

使用自定义键

默认情况下,请求按用户的 IP 地址进行速率限制。你可以使用 usingKey 方法指定一个不同的键。当你想按用户 ID、API key 或任何其他标识符进行限流时,这会很有用:

start/limiter.ts
export const throttle = limiter.define('global', (ctx) => {
  return limiter
    .allowRequests(10)
    .every('1 minute')
    .usingKey(`user_${ctx.auth.user.id}`)
})

切换后端存储

你可以使用 store 方法为特定的中间件覆盖默认存储:

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

阻塞滥用用户

blockFor 方法会在用户耗尽配额后继续发起请求时,延长锁定时间。这比简单地重置计数器更能有效地阻止滥用:

start/limiter.ts
limiter
  .allowRequests(10)
  .every('1 minute')
  /**
   * 如果用户在一分钟内发起了第 11 个请求,
   * 将其阻塞 30 分钟,而不只是等待
   * 1 分钟的窗口重置。
   */
  .blockFor('30 mins')

动态速率限制

不同的用户通常需要不同的速率限制。已身份验证的用户可能会获得比访客更高的限制,或者高级订阅者可能获得无限访问权限,而免费用户则会受到限制。

传给 limiter.define 的回调会接收 HTTP 上下文,使你能够根据请求的属性应用不同的限制:

start/limiter.ts
export const apiThrottle = limiter.define('api', (ctx) => {
  /**
   * 已身份验证的用户每分钟可发起 100 个请求,
   * 按他们的用户 ID 进行跟踪。
   */
  if (ctx.auth.user) {
    return limiter
      .allowRequests(100)
      .every('1 minute')
      .usingKey(`user_${ctx.auth.user.id}`)
  }

  /**
   * 访客用户每分钟可发起 10 个请求,
   * 按他们的 IP 地址进行跟踪。
   */
  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)

处理 ThrottleException

当用户耗尽其速率限制时,中间件会抛出 E_TOO_MANY_REQUESTS 异常。该异常会通过内容协商自动转换为一个 HTTP 响应:

  • 带有 Accept: application/json 的请求会收到一个 JSON 错误对象。
  • 带有 Accept: application/vnd.api+json 的请求会收到一个 JSON API 格式的错误。
  • 所有其他请求会收到一个纯文本消息。你可以使用 状态页 来显示一个自定义错误页面。

另请参阅: E_TOO_MANY_REQUESTS 异常参考

该中间件还会添加以下响应头:

  • X-RateLimit-Limit:可发起的请求总数
  • X-RateLimit-Remaining:剩余的请求数
  • Retry-After:客户端可以重试之前的秒数
  • X-RateLimit-Reset:客户端可以重试的时间戳

自定义错误响应

你可以使用 limitExceeded 钩子自定义错误消息,而无需全局处理异常:

start/limiter.ts
export const throttle = limiter.define('global', () => {
  return limiter
    .allowRequests(10)
    .every('1 minute')
    .limitExceeded((error) => {
      error
        .setStatus(400)
        .setMessage('Cannot process request. Try again later')
    })
})

使用翻译

如果你已经配置了 @adonisjs/i18n 包,使用 errors.E_TOO_MANY_REQUESTS 键定义一个翻译:

resources/lang/fr/errors.json
{
  "E_TOO_MANY_REQUESTS": "Trop de demandes"
}

你也可以使用带有插值值的自定义翻译键:

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

全局处理异常

如需更多控制,可在你的 全局异常处理程序 中处理异常:

app/exceptions/handler.ts
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)
  }
}

直接使用

除了 HTTP 中间件,你还可以在应用的任何部分直接使用 limiter。这对于保护登录表单免受暴力破解攻击、限制后台任务执行,或控制对昂贵操作的访问很有用。

创建 limiter 实例

使用 limiter.use 方法创建一个具有特定设置的 limiter 实例:

app/services/reports_service.ts
import limiter from '@adonisjs/limiter/services/main'

const reportsLimiter = limiter.use('redis', {
  requests: 1,
  duration: '1 hour'
})
OptionDescription
requests在时长内允许的请求数。
duration时间窗口,以秒为单位,或作为一个 时间表达式 字符串。
blockDuration可选。在请求全部耗尽后阻塞该键的时长。
inMemoryBlockOnConsumed可选。参见 共享选项
inMemoryBlockDuration可选。参见 共享选项

要使用默认存储,省略第一个参数:

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

限制昂贵的操作

attempt 方法仅在速率限制未被超过时才执行回调。它返回回调的结果,如果已达到限制则返回 undefined

app/services/reports_service.ts
import limiter from '@adonisjs/limiter/services/main'

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

export async function generateUserReport(userId: number) {
  const key = `reports_user_${userId}`

  const executed = await reportsLimiter.attempt(key, async () => {
    await generateReport(userId)
    return true
  })

  if (!executed) {
    const availableIn = await reportsLimiter.availableIn(key)
    throw new Error(`Too many requests. Try after ${availableIn} seconds`)
  }

  return 'Report generated'
}

防止暴力破解登录攻击

penalize 方法专为这样的场景而设计:你只想在操作失败时消耗一个请求。这非常适合登录保护,因为你想要跟踪失败的尝试,而不是成功的尝试。

app/controllers/session_controller.ts
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', 'password'])

    /**
     * 创建一个每分钟允许 5 次失败尝试,
     * 然后阻塞 20 分钟的 limiter。
     */
    const loginLimiter = limiter.use({
      requests: 5,
      duration: '1 min',
      blockDuration: '20 mins'
    })

    /**
     * 使用 IP + email 组合作为键。这确保了如果
     * 攻击者正在尝试多个 email,我们阻塞的是
     * 攻击者的 IP,而不会影响使用自己 email
     * 从不同 IP 登录的合法用户。
     */
    const key = `login_${request.ip()}_${email}`

    /**
     * penalize 方法仅当回调抛出错误时
     * 才消耗一个请求。
     */
    const [error, user] = await loginLimiter.penalize(key, () => {
      return User.verifyCredentials(email, password)
    })

    if (error) {
      session.flashAll()
      session.flashErrors({
        E_TOO_MANY_REQUESTS: `Too many login attempts. Try again after ${error.response.availableIn} seconds`
      })
      return response.redirect().back()
    }

    /**
     * 登录成功 - 继续创建 session
     */
  }
}

手动消耗请求

为了进行精细控制,你可以手动检查并消耗请求,而不是使用 attemptpenalize

Warning

分别调用 remainingincrement 会产生一个竞态条件,多个并发请求可能在任一方递增计数器之前都通过检查。请改用 consume 方法,它会执行原子化的检查并递增。

consume 方法会递增计数器,并在达到限制时抛出异常。你可以选择性地传入一个 amount 作为第二个参数,以一次性消耗多个槽位(适用于「加权」速率限制)。

app/services/api_service.ts
import { errors } from '@adonisjs/limiter'
import limiter from '@adonisjs/limiter/services/main'

const requestsLimiter = limiter.use({
  requests: 10,
  duration: '1 minute'
})

export async function handleApiRequest(userId: number) {
  const key = `api_user_${userId}`

  try {
    /**
     * 为一个重量级操作一次性消耗 5 个槽位
     */
    await requestsLimiter.consume(key, 5)
    return await performAction()
  } catch (error) {
    if (error instanceof errors.E_TOO_MANY_REQUESTS) {
      throw new Error('Rate limit exceeded')
    }
    throw error
  }
}

递增而不抛出

increment 方法的工作方式类似于 consume,但在达到限制时不会抛出异常,它也接受一个可选的 amount。它不会抛出异常,而是返回 limiter 响应对象,使你可以检查剩余的请求数并决定如何处理这种情况:

app/services/api_service.ts
import limiter from '@adonisjs/limiter/services/main'

const requestsLimiter = limiter.use({
  requests: 10,
  duration: '1 minute'
})

export async function handleApiRequest(userId: number) {
  const key = `api_user_${userId}`
  const response = await requestsLimiter.increment(key, 5)

  if (response.remainingPoints < 0) {
    throw new Error('Rate limit exceeded')
  }

  return await performAction()
}

对 amount 参数的校验 amount 参数必须是正整数。为了防止逻辑错误或安全绕过,如果你提供的值小于或等于 0,limiter 会自动回退到 1

阻塞键

你可以为那些在耗尽配额后仍然继续发起请求的用户延长锁定时间。这比标准的速率限制更具惩罚性,并能阻止滥用。

自动阻塞会在你使用 blockDuration 选项创建 limiter 时发生:

app/services/api_service.ts
import limiter from '@adonisjs/limiter/services/main'

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

/**
 * 用户每分钟可发起 10 个请求。如果他们发送了
 * 第 11 个请求,他们会被阻塞 30 分钟。
 * consume、attempt 和 penalize 方法都
 * 会自动强制执行此行为。
 */
await requestsLimiter.consume('a_unique_key')

你也可以手动阻塞一个键:

await requestsLimiter.block('a_unique_key', '30 mins')

重置尝试次数

有时你需要为用户恢复请求。例如,如果一个后台任务完成了,你可能希望让用户再排队一个任务。

decrement 方法会减少请求计数。默认情况下,它递减 1,但你可以将自定义的 amount 作为第二个参数传入。

app/jobs/process_report.ts
import limiter from '@adonisjs/limiter/services/main'

const jobsLimiter = limiter.use({
  requests: 10,
  duration: '5 mins',
})

export async function processReportJob(userId: number) {
  const key = `jobs_user_${userId}`

  await jobsLimiter.attempt(key, async () => {
    await processJob()

    /**
     * 任务完成 - 归还槽位,以便
     * 可以排队另一个任务。
     */
    await jobsLimiter.decrement(key, 5)
  })
}

与 increment 和 consume 一样,提供 amount <= 0 会导致该方法回退到 1

Tip

decrement 方法不是原子的。在高并发下,请求计数可能会短暂地变为 -1。如果你需要完全重置一个键,请使用 delete 方法。

delete 方法会彻底移除一个键:

await requestsLimiter.delete('unique_key')

测试

在测试期间,你通常希望使用 memory 存储而不是 Redis 或数据库。在你的 .env.test 文件中设置环境变量:

.env.test
LIMITER_STORE=memory

使用 limiter.clear 方法在测试之间清除速率限制存储:

tests/functional/reports.spec.ts
import limiter from '@adonisjs/limiter/services/main'

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

你也可以不带参数调用 clear,以刷新所有已配置的存储:

return () => limiter.clear()
Warning

使用 Redis 时,clear 方法会刷新整个数据库。请为速率限制器使用独立的 Redis 数据库,以避免清除应用数据。在 config/redis.ts 中通过创建一个专用连接来进行配置。

创建自定义存储提供者

你可以通过实现 LimiterStoreContract 接口来创建自定义的存储提供者。当你需要使用内置存储不支持的数据库时,这会很有用。

app/limiter/mongodb_store.ts
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(config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
    this.requests = config.requests
    this.duration = string.seconds.parse(config.duration)
    this.blockDuration = string.seconds.parse(config.blockDuration)
  }

  /**
   * 为键消耗一个请求。当所有请求都被消耗时
   * 抛出错误。
   */
  async consume(key: string | number, amount?: number): Promise<LimiterResponse> {}

  /**
   * 消耗一个请求,但在耗尽时不抛出。
   */
  async increment(key: string | number, amount?: number): Promise<LimiterResponse> {}

  /**
   * 为键恢复一个请求。
   */
  async decrement(key: string | number, amount?: 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> {}

  /**
   * 获取键的 limiter 响应,如果
   * 键不存在则为 null。
   */
  async get(key: string | number): Promise<LimiterResponse | null> {}
}

创建配置辅助函数

创建一个辅助函数,以便在配置文件中使用你的存储。该辅助函数应返回一个 LimiterManagerStoreFactory 函数:

app/limiter/mongodb_store.ts
import { LimiterManagerStoreFactory } from '@adonisjs/limiter/types'

export function mongoDbStore(config: MongoDbLimiterConfig) {
  const storeFactory: LimiterManagerStoreFactory = (runtimeOptions) => {
    return new MongoDbLimiterStore({
      ...config,
      ...runtimeOptions
    })
  }

  return storeFactory
}

使用你的自定义存储

config/limiter.ts
import env from '#start/env'
import { mongoDbStore } from '#app/limiter/mongodb_store'
import { defineConfig } from '@adonisjs/limiter'

const limiterConfig = defineConfig({
  default: env.get('LIMITER_STORE'),

  stores: {
    mongodb: mongoDbStore({
      client: mongoDb
    })
  },
})

包装 rate-limiter-flexible 驱动

如果你正在包装 node-rate-limiter-flexible 中已有的驱动,请使用 RateLimiterBridge 类来简化实现:

app/limiter/mongodb_store.ts
import { RateLimiterBridge } from '@adonisjs/limiter'
import { RateLimiterMongo } from 'rate-limiter-flexible'

export class MongoDbLimiterStore extends RateLimiterBridge {
  readonly name = 'mongodb'

  constructor(config: MongoDbLimiterConfig & LimiterConsumptionOptions) {
    super(
      new RateLimiterMongo({
        storeClient: config.client,
        points: config.requests,
        duration: string.seconds.parse(config.duration),
        blockDuration: string.seconds.parse(config.blockDuration)
      })
    )
  }

  /**
   * 桥接器处理了大部分方法,但你必须
   * 自己实现 clear()。
   */
  async clear() {
    await this.config.client.collection('rate_limits').deleteMany({})
  }
}