访问令牌守卫

访问令牌守卫(Access tokens guard)

访问令牌用于在服务器无法在终端用户设备上持久化 cookie 的 API 场景下对 HTTP 请求进行认证,例如第三方对 API 的访问,或移动应用的认证。

访问令牌可以生成任意格式;例如,符合 JWT 标准的令牌被称为 JWT 访问令牌,而采用专有格式的令牌被称为不透明访问令牌(opaque access tokens)。

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

  • 令牌由一个加密安全的随机值表示,并附带一个 CRC32 校验和(checksum)后缀。
  • 令牌值的哈希会被持久化存储在数据库中。该哈希用于在认证时验证令牌。
  • 最终的令牌值经过 base64 编码,并以 oat_ 作为前缀。该前缀可自定义。
  • 前缀和 CRC32 校验和后缀有助于密钥扫描工具(secret scanning tools) 识别令牌,防止其泄漏到代码库中。

配置用户模型

在使用访问令牌守卫之前,你必须使用 User 模型设置一个令牌提供器(tokens provider)。令牌提供器用于创建、列出和验证访问令牌

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

import { BaseModel } from '@adonisjs/lucid/orm'
import { DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
export default class User extends BaseModel {
// ...其余模型属性
static accessTokens = DbAccessTokensProvider.forModel(User)
}

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

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 命令来创建数据库表。

node ace migration:run

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

node ace make:migration auth_access_tokens
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)。该值仅在生成令牌时可用,用户之后将无法再次看到它。

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 对象。

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(),
}
return token
})
/**
* response: {
* type: 'bearer',
* value: 'oat_MTA.aWFQUmo2WkQzd3M5cW0zeG5JeHdiaV9rOFQzUWM1aTZSR2xJaDZXYzM5MDE4MzA3NTU',
* expiresAt: null,
* }
*/

定义能力(abilities)

根据你的应用,你可能希望限制访问令牌只执行特定任务。例如,颁发一个允许读取和列出项目、但不允许创建或删除它们的令牌。

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

对于 auth 软件包而言,能力(abilities)本身没有实际意义。由你的应用在执行业务操作前自行检查令牌能力。

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

令牌能力 vs. Bouncer 能力

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

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

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

  • 现在,在你的应用内部,你将必须实现访问控制,允许管理员用户创建新项目,同时禁止该令牌创建新项目。

你可以为此用例编写如下的 bouncer 能力。

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

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
}
/**
* 否则,检查用户是否为 isAdmin,
* 以及他们用于认证的令牌是否允许
* "project:create" 能力。
*/
return user.isAdmin && user.currentAccessToken.allows('project:create')
}
)

令牌过期

默认情况下,令牌是长生命周期的,它们永远不会过期。不过,你可以在配置令牌提供器 时或生成令牌时定义过期时间。

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

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

命名令牌

默认情况下,令牌没有名称。不过,你可以在生成令牌时为其分配一个名称。例如,如果你允许应用的用户自行生成令牌,你可以要求他们同时指定一个可识别的名称。

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

配置守卫

既然我们可以颁发令牌了,接下来配置一个身份认证守卫来验证请求并对用户进行认证。该守卫必须在 config/auth.ts 文件的 guards 对象下进行配置。

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 方法接受以下选项,并返回 AccessTokensLucidUserProvider 类的实例。

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

认证请求

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

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

import router from '@adonisjs/core/services/router'
router.post('projects', async ({ auth }) => {
// 使用默认守卫进行认证
const user = await auth.authenticate()
// 使用命名守卫进行认证
const user = await auth.authenticateUsing(['api'])
})

使用 auth 中间件

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

auth 中间件接受一组用于认证请求的守卫。一旦上述某个守卫成功认证了请求,认证过程即停止。

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 的值始终是有定义的。

import { HttpContext } from '@adonisjs/core/http'
class PostsController {
async store({ auth }: HttpContext) {
if (auth.isAuthenticated) {
await auth.user!.related('posts').create(postData)
}
}
}

获取已认证用户,否则失败

如果你不喜欢在 auth.user 属性上使用非空断言运算符(non-null assertion operator),你可以使用 auth.getUserOrFail 方法。该方法会返回用户对象,否则抛出 E_UNAUTHORIZED_ACCESS 异常。

import { HttpContext } from '@adonisjs/core/http'
class PostsController {
async store({ auth }: HttpContext) {
const user = auth.getUserOrFail()
await user.related('posts').create(postData)
}
}

当前访问令牌(The current access token)

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

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

如果你在代码库的其他地方以 currentAccessToken 作为类型来引用 User 模型,你可能希望在模型本身上声明该属性。

不要合并 currentAccessToken

import { AccessToken } from '@adonisjs/auth/access_tokens'
Bouncer.ability((
user: User & { currentAccessToken?: AccessToken }
) => {
})

将其声明为模型上的一个属性

import { AccessToken } from '@adonisjs/auth/access_tokens'
export default class User extends BaseModel {
currentAccessToken?: AccessToken
}
Bouncer.ability((user: User) => {
})

列出所有令牌

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

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

all 方法也会返回已过期的令牌。你可能想在渲染列表之前对它们进行过滤,或者在令牌旁边显示一条**"令牌已过期(Token expired)"** 消息。例如

@each(token in tokens)
<h2> {{ token.name }} </h2>
@if(token.isExpired())
<p> Expired </p>
@end
<p> Abilities: {{ token.abilities.join(',') }} </p>
@end

删除令牌

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

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

你也可以使用 accessTokens.deleteAll 方法删除该用户的所有令牌。该方法仅接受用户作为参数。例如,在密码重置之后这很有用

await User.accessTokens.deleteAll(user)

事件

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

登录与登出

访问令牌有时是登录/登出用户的首选方式——例如在对原生应用进行认证时。

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

登录:

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

登出(当前已认证的令牌):

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

会话控制器示例

假设已经配置好了一个访问令牌守卫 api(例如你已经配置了:用户模型访问令牌 以及 auth 守卫),就可以按如下方式实现一个会话控制器:

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

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

在上述示例的情况下,客户端应在对 /session 的 POST 请求中包含 Accept=application/json 请求头。这样可以确保在失败时会得到 JSON 格式的响应,而不是重定向。

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