Access Tokens 守卫

本指南介绍基于令牌的身份验证。你将学习:

  • access token 的工作原理以及何时使用它们
  • 如何在 User 模型上配置令牌提供者
  • 如何签发带有 abilities 和过期时间的令牌
  • 如何使用令牌对请求进行身份验证
  • 如何管理令牌(列出、删除、撤销)

概述

access token 用于服务器无法使用 cookie 的场景中对 HTTP 请求进行身份验证。这包括原生移动应用、桌面应用、第三方 API 集成,以及托管在与 API 不同域名下的 Web 应用。

AdonisJS 使用的是不透明的 access token(opaque access token),而非 JWT。不透明令牌是一个没有任何内嵌数据的加密安全随机字符串。令牌会被哈希后存储在你的数据库中,验证时通过将提供的令牌与存储的哈希值进行比对来完成。这种方式允许你通过从数据库中删除令牌来立即撤销它,这是 JWT 在过期之前无法做到的。

一个令牌由三部分组成:一个可配置的前缀(默认为 oat_)、随机令牌值,以及一个 CRC32 校验和。前缀和校验和有助于 密钥扫描工具 在代码库中识别出泄露的令牌。

配置 User 模型

在使用 access tokens 守卫之前,需要在 User 模型上配置一个令牌提供者。该提供者负责创建、存储和验证令牌。

app/models/user.ts
import { DateTime } from 'luxon'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'

export default class User extends BaseModel {
  @column({ isPrimary: true })
  declare id: number

  @column()
  declare fullName: string | null

  @column()
  declare email: string

  @column()
  declare password: string

  @column.dateTime({ autoCreate: true })
  declare createdAt: DateTime

  @column.dateTime({ autoCreate: true, autoUpdate: true })
  declare updatedAt: DateTime

  static accessTokens = DbAccessTokensProvider.forModel(User)
}

DbAccessTokensProvider.forModel 方法接受 User 模型作为第一个参数,并接受一个可选的配置对象作为第二个参数:

app/models/user.ts
static accessTokens = DbAccessTokensProvider.forModel(User, {
  expiresIn: '30 days',
  prefix: 'oat_',
  table: 'auth_access_tokens',
  type: 'auth_token',
  tokenSecretLength: 40,
})
OptionDescription
expiresIn令牌的默认生命周期。接受以数字表示的秒数或类似 '30 days' 的时间表达式。默认情况下令牌不会过期。在创建单个令牌时可以被覆盖。
prefix公开令牌值的前缀。有助于密钥扫描器识别你的令牌。默认为 oat_。修改此值会使现有令牌失效。
table用于存储令牌的数据库表。默认为 auth_access_tokens
type该令牌类型的标识符。当你的应用签发多种类型的令牌时很有用。默认为 auth_token
tokenSecretLength随机令牌值的字符长度。默认为 40

创建令牌表

当你选择 access tokens 守卫时,add 命令会为令牌表创建一个迁移。运行迁移以创建该表:

node ace migration:run

如果你是手动配置 access token,请自行创建迁移:

node ace make:migration auth_access_tokens
database/migrations/TIMESTAMP_create_auth_access_tokens_table.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 模型上的令牌提供者来创建令牌。create 方法接受一个用户实例,并返回一个 AccessToken 对象:

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

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.value 属性包含被包裹在 Secret 对象中的实际令牌字符串。调用 .release() 以获取纯字符串值。该值仅在创建时可用。一旦响应被发送,由于只存储了哈希值,纯文本令牌将无法再次获取。

你也可以直接返回令牌对象,它会自动序列化为 JSON:

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

  return token
})

/**
 * 响应:
 * {
 *   "type": "bearer",
 *   "value": "oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU",
 *   "expiresAt": null
 * }
 */

令牌 abilities

abilities 让你可以限制令牌能做的事情。例如,你可能签发一个可以读取项目但不能创建或删除它们的令牌。

start/routes.ts
const token = await User.accessTokens.create(user, ['projects:read', 'projects:list'])

abilities 以字符串数组的形式存储。你可以定义任何对你的应用有意义的 ability。常见的模式包括基于资源的 abilities(projects:readusers:delete)和基于角色的 abilities(admineditor)。

要允许所有 abilities,请使用通配符:

start/routes.ts
const token = await User.accessTokens.create(user, ['*'])

在处理请求时检查 abilities:

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

router
  .delete('/projects/:id', async ({ auth, response }) => {
    if (!auth.user!.currentAccessToken.allows('projects:delete')) {
      return response.forbidden('Token lacks projects:delete ability')
    }

    // 删除项目...
  })
  .use(middleware.auth({ guards: ['api'] }))

AccessToken 类提供了以下用于检查 abilities 的方法:

MethodDescription
allows(ability)如果令牌拥有指定的 ability 或通配符(*),则返回 true
denies(ability)如果令牌不拥有指定的 ability,则返回 true

令牌过期时间

在创建令牌时设置过期时间:

start/routes.ts
const token = await User.accessTokens.create(user, ['*'], {
  expiresIn: '7 days',
})

你也可以在提供者的配置中设置默认过期时间,该时间会应用于所有令牌,除非被单独覆盖。

令牌名称

为令牌分配名称,以便用户在管理界面中识别它们:

start/routes.ts
const token = await User.accessTokens.create(user, ['*'], {
  name: 'CLI Tool Token',
})

配置守卫

设置好令牌提供者后,在 config/auth.ts 中配置身份验证守卫:

config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'

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

export default authConfig

tokensGuard 方法创建了 AccessTokensGuard 的实例。

tokensUserProvider 方法接受两个选项:

OptionDescription
model一个导入你 User 模型的函数。
tokens你的模型上引用令牌提供者的静态属性名称(通常是 accessTokens)。

对请求进行身份验证

客户端在 Authorization 头中以 bearer token 的形式包含令牌:

Authorization: Bearer oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU

使用 auth 中间件

auth 中间件应用到需要身份验证的路由:

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

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'] }))

如果令牌缺失、无效或已过期,中间件会抛出 E_UNAUTHORIZED_ACCESS 异常。

手动身份验证

要在不使用中间件的情况下进行身份验证,可调用 auth.authenticate()auth.authenticateUsing()

start/routes.ts
router.post('/projects', async ({ auth }) => {
  /**
   * 使用默认守卫进行身份验证
   */
  const user = await auth.authenticate()

  /**
   * 使用指定的守卫进行身份验证
   */
  const user = await auth.authenticateUsing(['api'])
})

检查身份验证状态

使用 auth.isAuthenticated 检查请求是否已通过身份验证:

app/controllers/projects_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class ProjectsController {
  async store({ auth }: HttpContext) {
    if (auth.isAuthenticated) {
      await auth.user!.related('projects').create(projectData)
    }
  }
}

避免使用非空断言

使用 auth.getUserOrFail() 代替 auth.user!,以避免使用非空断言操作符:

app/controllers/projects_controller.ts
import type { HttpContext } from '@adonisjs/core/http'

export default class ProjectsController {
  async store({ auth }: HttpContext) {
    const user = auth.getUserOrFail()
    await user.related('projects').create(projectData)
  }
}

当前 access token

身份验证成功后,守卫会将令牌附加到 user.currentAccessToken。可用它来检查 abilities、过期时间或其他令牌属性:

start/routes.ts
router
  .get('/projects', async ({ auth }) => {
    const token = auth.user!.currentAccessToken

    console.log(token.identifier)   // 数据库中的令牌 ID
    console.log(token.abilities)    // abilities 数组
    console.log(token.isExpired())  // 布尔值
    console.log(token.lastUsedAt)   // DateTime 或 null
  })
  .use(middleware.auth({ guards: ['api'] }))

每当令牌被用于身份验证时,守卫都会更新 last_used_at 列。

如果你在代码库中的其他地方(例如在 Bouncer abilities 中)通过 currentAccessToken 引用 User 模型,请在你的模型上声明该属性以避免类型错误:

app/models/user.ts
import { AccessToken } from '@adonisjs/auth/access_tokens'

export default class User extends BaseModel {
  // ...其他属性

  currentAccessToken?: AccessToken
}

列出令牌

使用 all 方法检索用户的所有令牌:

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

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

all 方法会同时返回有效和已过期的令牌。请在你的 UI 中过滤或标记已过期的令牌:

@each(token in tokens)
  <div>
    <h3>{{ token.name ?? 'Unnamed token' }}</h3>
    @if(token.isExpired())
      <span class="badge">Expired</span>
    @end
    <p>Abilities: {{ token.abilities.join(', ') }}</p>
  </div>
@end

删除令牌

根据标识符删除一个令牌:

start/routes.ts
await User.accessTokens.delete(user, tokenId)

通过守卫登录和登出

当使用 access token 作为主要身份验证方式时(常见于移动应用),守卫提供了 createTokeninvalidateToken 方法,它们分别对应 session 守卫的 loginlogout

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

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

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

  async destroy({ auth }: HttpContext) {
    await auth.use('api').invalidateToken()
  }
}
start/routes.ts
import { middleware } from '#start/kernel'
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 异常。对于 API 客户端,请包含 Accept: application/json 头以接收 JSON 错误响应,而不是重定向。

Tip

如果你的 API 仅通过 access token 访问(而非来自浏览器),你可能希望在 API 路由上禁用 CSRF 保护 。详见 shield 配置参考

事件

access tokens 守卫会在身份验证过程中发出事件。完整列表请参阅 事件参考指南