Skip to content

访问令牌守卫

访问令牌在服务器无法在终端用户设备上持久化 cookies 的 API 上下文中认证 HTTP 请求,例如,第三方对 API 的访问或移动应用程序的认证。

访问令牌可以以任何格式生成;例如,符合 JWT 标准的令牌称为 JWT 访问令牌,而专有格式的令牌称为不透明访问令牌。

AdonisJS 使用不透明访问令牌,其结构和存储方式如下。

  • 令牌由加密安全的随机值表示,后缀为 CRC32 校验和。
  • 令牌值的哈希持久化在数据库中。此哈希用于在认证时验证令牌。
  • 最终的令牌值是 base64 编码的,前缀为 oat_。前缀可以自定义。
  • 前缀和 CRC32 校验和后缀帮助密钥扫描工具识别令牌并防止它们在代码库中泄露。

配置用户模型

在使用访问令牌守卫之前,你必须在 User 模型上设置令牌提供者。令牌提供者用于创建、列出和验证访问令牌

auth 包附带一个数据库令牌提供者,它将令牌持久化在 SQL 数据库中。你可以按如下方式配置它。

ts
import { BaseModel } from '@adonisjs/lucid/orm'
// highlight-start
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
// highlight-end

export default class User extends BaseModel {
  // ...模型的其余属性

  // highlight-start
  static accessTokens = DbAccessTokensProvider.forModel(User)
  // highlight-end
}

DbAccessTokensProvider.forModel 接受 User 模型作为第一个参数,选项对象作为第二个参数。

ts
export default class User extends BaseModel {
  // ...模型的其余属性

  static accessTokens = DbAccessTokensProvider.forModel(User, {
    expiresIn: '30 days',
    prefix: 'oat_',
    table: 'auth_access_tokens',
    type: 'auth_token',
    tokenSecretLength: 40,
  })
}

expiresIn

令牌过期的持续时间。你可以传递以秒为单位的数值或作为字符串的时间表达式

默认情况下,令牌是长期有效的,不会过期。此外,你可以在生成令牌时指定令牌的过期时间。

prefix

公开共享的令牌值的前缀。定义前缀可以帮助密钥扫描工具识别令牌并防止它在代码库中泄露。

发出令牌后更改前缀将使它们无效。因此,请仔细选择前缀,不要经常更改它们。

默认为 oat_

table

存储访问令牌的数据库表名。默认为 auth_access_tokens

type

用于标识令牌桶的唯一类型。如果你在单个应用程序中发出多种类型的令牌,你必须为所有令牌定义唯一的类型。

默认为 auth_token

tokenSecretLength

随机令牌值的长度(字符数)。默认为 40


一旦你配置了令牌提供者,你就可以开始代表用户发出令牌。你不必设置认证守卫来发出令牌。守卫是用来验证令牌的。

创建访问令牌数据库表

我们在初始设置期间为 auth_access_tokens 表创建迁移文件。迁移文件存储在 database/migrations 目录中。

你可以通过执行 migration:run 命令创建数据库表。

sh
node ace migration:run

但是,如果你由于某种原因手动配置 auth 包,你可以手动创建迁移文件并将以下代码片段复制粘贴到其中。

sh
node ace make:migration auth_access_tokens
ts
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
  protected tableName = 'auth_access_tokens'

  async up() {
    this.schema.createTable(this.tableName, (table) => {
      table.increments('id')
      table
        .integer('tokenable_id')
        .notNullable()
        .unsigned()
        .references('id')
        .inTable('users')
        .onDelete('CASCADE')

      table.string('type').notNullable()
      table.string('name').nullable()
      table.string('hash').notNullable()
      table.text('abilities').notNullable()
      table.timestamp('created_at')
      table.timestamp('updated_at')
      table.timestamp('last_used_at').nullable()
      table.timestamp('expires_at').nullable()
    })
  }

  async down() {
    this.schema.dropTable(this.tableName)
  }
}

发出令牌

根据你的应用程序,你可能会在登录期间或登录后从应用程序仪表板发出令牌。无论哪种情况,发出令牌都需要一个用户对象(将为其生成令牌),你可以直接使用 User 模型生成它们。

如果你使用访问令牌作为用户登录/注销的主要方式,你可能更喜欢直接通过 auth 守卫创建/使令牌失效,请参阅登录和注销

在以下示例中,我们通过 id 查找用户并使用 User.accessTokens.create 方法为他们发出访问令牌。当然,在实际应用程序中,你会让这个端点受到认证保护,但现在让我们保持简单。

.create 方法接受 User 模型的实例并返回 AccessToken 类的实例。

token.value 属性包含必须与用户共享的值(包装为 Secret)。该值仅在生成令牌时可用,用户将无法再次看到它。

ts
import router from '@adonisjs/core/services/router'
import User from '#models/user'

router.post('users/:id/tokens', async ({ params }) => {
  const user = await User.findOrFail(params.id)
  const token = await User.accessTokens.create(user)

  return {
    type: 'bearer',
    value: token.value!.release(),
  }
})

你也可以直接在响应中返回 token,它将被序列化为以下 JSON 对象。

ts
router.post('users/:id/tokens', async ({ params }) => {
  const user = await User.findOrFail(params.id)
  const token = await User.accessTokens.create(user)

  // delete-start
  return {
    type: 'bearer',
    value: token.value!.release(),
  }
  // delete-end
  // insert-start
  return token
  // insert-end
})

/**
 * response: {
 *   type: 'bearer',
 *   value: 'oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU',
 *   expiresAt: null,
 * }
 */

定义能力

根据你正在构建的应用程序,你可能希望将访问令牌限制为仅执行特定任务。例如,发出一个允许读取和列出项目而不能创建或删除它们的令牌。

在以下示例中,我们将能力数组定义为第二个参数。能力被序列化为 JSON 字符串并持久化在数据库中。

对于 auth 包,能力没有真正的意义。你的应用程序需要在执行给定操作之前检查令牌能力。

ts
await User.accessTokens.create(user, ['server:create', 'server:read'])

令牌能力 vs. Bouncer 能力

你不应该将令牌能力与 bouncer 授权检查混淆。让我们通过一个实际的例子来理解区别。

  • 假设你定义了一个允许管理员用户创建新项目的 bouncer 能力

  • 同一个管理员用户为自己创建了一个令牌,但为了防止令牌滥用,他们将令牌能力限制为读取项目

  • 现在,在你的应用程序中,你需要实现访问控制,允许管理员用户创建新项目,同时不允许令牌创建新项目。

你可以按如下方式为此用例编写 bouncer 能力。

TIP

user.currentAccessToken 指的是在当前 HTTP 请求期间用于认证的访问令牌。你可以在认证请求部分了解更多信息。

ts
import { AccessToken } from '@adonisjs/auth/access_tokens'
import { Bouncer } from '@adonisjs/bouncer'

export const createProject = Bouncer.ability(
  (user: User & { currentAccessToken?: AccessToken }) => {
    /**
     * 如果没有 "currentAccessToken" 令牌属性,这意味着用户在没有访问令牌的情况下进行了认证
     */
    if (!user.currentAccessToken) {
      return user.isAdmin
    }

    /**
     * 否则,检查用户是否是管理员以及他们用于认证的令牌是否允许 "project:create" 能力。
     */
    return user.isAdmin && user.currentAccessToken.allows('project:create')
  }
)

令牌过期

默认情况下,令牌是长期有效的,它们永不过期。但是,你可以在配置令牌提供者或生成令牌时定义过期时间。

过期可以定义为表示秒的数值或基于字符串的时间表达式。

ts
await User.accessTokens.create(
  user, // 为用户
  ['*'], // 具有所有能力
  {
    expiresIn: '30 days' // 30 天后过期
  }
)

命名令牌

默认情况下,令牌没有名称。但是,你可以在生成令牌时为它们分配名称。例如,如果你允许应用程序的用户自己生成令牌,你可能会要求他们也指定一个可识别的名称。

ts
await User.accessTokens.create(
  user,
  ['*'],
  {
    name: request.input('token_name'),
    expiresIn: '30 days'
  }
)

配置守卫

现在我们可以发出令牌了,让我们配置一个认证守卫来验证请求并认证用户。守卫必须在 config/auth.ts 文件的 guards 对象下配置。

ts
// title: config/auth.ts
import { defineConfig } from '@adonisjs/auth'
// highlight-start
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
// highlight-end

const authConfig = defineConfig({
  default: 'api',
  guards: {
    // highlight-start
    api: tokensGuard({
      provider: tokensUserProvider({
        tokens: 'accessTokens',
        model: () => import('#models/user'),
      })
    }),
    // highlight-end
  },
})

export default authConfig

tokensGuard 方法创建 AccessTokensGuard 类的实例。它接受一个可用于验证令牌和查找用户的用户提供者。

tokensUserProvider 方法接受以下选项并返回 AccessTokensLucidUserProvider 类的实例。

  • model:用于查找用户的 Lucid 模型。
  • tokens:模型的静态属性名称,用于引用令牌提供者。

认证请求

配置守卫后,你可以使用 auth 中间件或手动调用 auth.authenticate 方法开始认证请求。

auth.authenticate 方法返回已认证用户的 User 模型实例,或者在无法认证请求时抛出 E_UNAUTHORIZED_ACCESS 异常。

ts
import router from '@adonisjs/core/services/router'

router.post('projects', async ({ auth }) => {
  // 使用默认守卫认证
  const user = await auth.authenticate()

  // 使用命名守卫认证
  const user = await auth.authenticateUsing(['api'])
})

使用 auth 中间件

你可以使用 auth 中间件来认证请求或抛出异常,而不是手动调用 authenticate 方法。

auth 中间件接受用于认证请求的守卫数组。认证过程在其中一个提到的守卫认证请求后停止。

ts
import router from '@adonisjs/core/services/router'
import { middleware } from '#start/kernel'

router
  .post('projects', async ({ auth }) => {
    console.log(auth.user) // User
    console.log(auth.authenticatedViaGuard) // 'api'
    console.log(auth.user!.currentAccessToken) // AccessToken
  })
  .use(middleware.auth({
    guards: ['api']
  }))

检查请求是否已认证

你可以使用 auth.isAuthenticated 标志检查请求是否已认证。对于已认证的请求,auth.user 的值将始终被定义。

ts
import { HttpContext } from '@adonisjs/core/http'

class PostsController {
  async store({ auth }: HttpContext) {
    if (auth.isAuthenticated) {
      await auth.user!.related('posts').create(postData)
    }
  }
}

获取已认证用户或失败

如果你不喜欢在 auth.user 属性上使用非空断言运算符,你可以使用 auth.getUserOrFail 方法。此方法将返回用户对象或抛出 E_UNAUTHORIZED_ACCESS 异常。

ts
import { HttpContext } from '@adonisjs/core/http'

class PostsController {
  async store({ auth }: HttpContext) {
    const user = auth.getUserOrFail()
    await user.related('posts').create(postData)
  }
}

当前访问令牌

访问令牌守卫在成功认证请求后在用户对象上定义 currentAccessToken 属性。currentAccessToken 属性是 AccessToken 类的实例。

你可以使用 currentAccessToken 对象获取令牌的能力或检查令牌的过期时间。此外,在认证期间,守卫将更新 last_used_at 列以反映当前时间戳。

如果你在代码库的其余部分将 User 模型与 currentAccessToken 作为类型引用,你可能需要在模型本身上声明此属性。

:::caption

不要合并 currentAccessToken

:::

ts
import { AccessToken } from '@adonisjs/auth/access_tokens'

Bouncer.ability((
  user: User & { currentAccessToken?: AccessToken }
) => {
})

:::caption

将其声明为模型上的属性

:::

ts
import { AccessToken } from '@adonisjs/auth/access_tokens'

export default class User extends BaseModel {
  currentAccessToken?: AccessToken
}
ts
Bouncer.ability((user: User) => {
})

列出所有令牌

你可以使用令牌提供者通过 accessTokens.all 方法获取所有令牌的列表。返回值将是 AccessToken 类实例的数组。

ts
router
  .get('/tokens', async ({ auth }) => {
    return User.accessTokens.all(auth.user!)
  })
  .use(
    middleware.auth({
      guards: ['api'],
    })
  )

all 方法也返回过期的令牌。你可能需要在渲染列表之前过滤它们或在令牌旁边显示 "令牌已过期" 消息。例如

edge
@each(token in tokens)
  <h2> {{ token.name }} </h2>
  @if(token.isExpired())
    <p> 已过期 </p>
  @end

  <p> 能力: {{ token.abilities.join(',') }} </p>
@end

删除令牌

你可以使用 accessTokens.delete 方法删除令牌。该方法接受用户作为第一个参数,令牌 id 作为第二个参数。

ts
await User.accessTokens.delete(user, token.identifier)

事件

请查看事件参考指南以查看访问令牌守卫发出的可用事件列表。

登录和注销

访问令牌有时是用户登录和注销的首选方法——例如,当从移动应用程序进行认证时。

为了适应这些情况,访问令牌守卫提供了类似于 session 守卫loginlogout 方法的 API。

登录:

ts
const token = await auth.use('api').createToken(user)

注销(当前认证的令牌):

ts
await auth.use('api').invalidateToken()

Session 控制器示例

假设访问令牌守卫 api 已经就位(例如,你已经设置了:用户模型访问令牌认证守卫),可以按以下方式实现 session 控制器:

ts
// title: app/controllers/session_controller.ts
import User from '#models/user'
import { HttpContext } from '@adonisjs/core/http'

export default class SessionController {
  async store({ request, auth, response }: HttpContext) {
    const { email, password } = request.only(['email', 'password'])
    const user = await User.verifyCredentials(email, password)

    return await auth.use('api').createToken(user)
  }

  async destroy({ request, auth, response }: HttpContext) {
    await auth.use('api').invalidateToken()
  }
}
ts
// title: start/routes.ts
import router from '@adonisjs/core/services/router'

const SessionController = () => import('#controllers/session_controller')

router.post('session', [SessionController, 'store'])
router.delete('session', [SessionController, 'destroy'])
  .use(middleware.auth({ guards: ['api'] }))

WARNING

User.verifyCredentials 失败(并抛出 E_INVALID_CREDENTIALS)时,使用内容协商来获取适当的响应。

在上面示例的情况下,客户端应该在对 /session 的 post 请求中包含 Accept=application/json 头。这确保失败将导致 json 格式的响应而不是重定向。

TIP

如果你使用访问令牌从外部来源(如移动应用程序)登录,你可能需要禁用 CSRF 保护。 你可以全局禁用 CSRF 保护(如果你的应用程序仅用作 API),或为 API 路由(包括 /session 路由)添加例外。请参阅 shield 配置参考