授权
本指南介绍如何使用 Bouncer 在 AdonisJS 中实现授权。你将学习如何:
- 将授权检查定义为 abilities 和 policies
- 在应用的各个部分(控制器、模板、API)使用授权
- 处理访客用户和策略钩子等高级场景
- 在 API 和 Inertia 应用中实现授权
概述
授权决定已身份验证的用户在你的应用中被允许做什么。身份验证回答「你是谁?」,而授权回答「你能做什么?」。Bouncer 提供了一种结构化的方式,来定义并检查 AdonisJS 应用中的权限。
而不是将授权检查分散在代码库的各个角落,Bouncer 鼓励你将它们提取到专用的位置。这使得你的授权逻辑集中化、可复用且可测试。
安装
使用以下命令安装并配置 Bouncer。
node ace add @adonisjs/bouncer
add 命令执行的步骤
- 在
adonisrc.ts文件中注册@adonisjs/bouncer/bouncer_provider服务提供者和@adonisjs/bouncer/commands。 - 创建
app/abilities/main.ts文件以定义并导出 abilities。 - 在中间件目录中创建
initialize_bouncer_middleware.ts文件,并在start/kernel.ts文件中注册它。
定义 abilities
ability 是一个检查用户是否有权执行特定操作的函数。当你只有少量简单的授权检查时,abilities 轻量且适用。
abilities 在 app/abilities/main.ts 文件中使用 Bouncer.ability() 方法定义。每个 ability 接收用户作为第一个参数,随后是做出授权决策所需的任何资源,然后返回一个布尔值,表示该操作是否被允许。
import User from '#models/user'
import Post from '#models/post'
import { Bouncer } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
return user.id === post.userId
})
export const sendEmail = Bouncer.ability((user: User) => {
return user.role === 'admin'
})
editPost ability 通过比较用户 ID 来检查用户是否拥有某个特定的帖子。sendEmail ability 验证用户是否拥有 admin 角色。注意 sendEmail 只需要用户参数,因为它不针对特定资源检查权限。
在控制器中使用 abilities
你可以使用 ctx.bouncer 对象在控制器中检查 abilities。导入你想要检查的 ability,并将其传给 bouncer 的某个方法。
import Post from '#models/post'
import router from '@adonisjs/core/services/router'
import type { HttpContext } from '@adonisjs/core/http'
import { editPost } from '#abilities/main'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.denies(editPost, post)) {
return response.forbidden('You cannot edit this post')
}
// 继续更新逻辑
return 'Post updated successfully'
}
}
注意你只向 bouncer.denies() 传递了 post 参数,而不是用户。bouncer 已经与当前登录的用户绑定,并会自动将其作为第一个参数提供给你的 ability。
授权方法
Bouncer 提供了四种用于检查授权的方法,各自适用于不同的使用场景。
使用 allows 和 denies
allows 方法检查用户是否被授权,如果是则返回 true。denies 方法则相反,当用户未被授权时返回 true。
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.allows(editPost, post)) {
return 'You can edit this post'
}
return response.forbidden('You cannot edit this post')
}
}
使用 authorize
当授权失败时,authorize 方法会抛出 AuthorizationException 异常。该异常会根据内容协商自动转换为适当的 HTTP 响应。
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params }: HttpContext) {
const post = await Post.findOrFail(params.id)
await bouncer.authorize(editPost, post)
// 如果能执行到这里,说明授权成功
return 'Post updated successfully'
}
}
使用 execute
execute 方法返回一个 AuthorizationResponse 对象,其中包含关于授权检查的详细信息。当你需要在简单的布尔值之外检查授权结果时,这很有用。
import Post from '#models/post'
import { editPost } from '#abilities/main'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async update({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
const result = await bouncer.execute(editPost, post)
if (!result.authorized) {
return response
.status(result.status || 403)
.send({ error: result.message || 'Unauthorized' })
}
return 'Post updated successfully'
}
}
AuthorizationResponse 对象有三个属性:authorized(布尔值)、message(字符串或 undefined)和 status(数字或 undefined)。你可以使用它们来创建带有特定状态码和消息的自定义错误响应。
自定义授权响应
默认情况下,abilities 返回布尔值。然而,你可以返回一个 AuthorizationResponse 对象来指定自定义的错误消息和状态码。
import User from '#models/user'
import Post from '#models/post'
import { Bouncer, AuthorizationResponse } from '@adonisjs/bouncer'
export const editPost = Bouncer.ability((user: User, post: Post) => {
if (user.id === post.userId) {
return AuthorizationResponse.allow()
}
return AuthorizationResponse.deny('Post not found', 404)
})
在此示例中,当授权失败时,错误消息将是「Post not found」,状态码为 404 而非默认的 403 Forbidden。当你希望向未授权用户隐藏某个资源的存在时,这会很有用。
定义 policies
policy 是一个将针对某个特定资源的多个授权检查归为一组的类。当你需要对特定资源进行结构化授权,或你的应用中存在大量授权检查时,推荐使用 policies。例如,你可以为 Post 模型创建一个 policy,为 Comment 模型创建另一个。
Policies 继承自 BasePolicy 类,并存储在 app/policies 目录中。它们受益于通过 IoC 容器的依赖注入,使得注入服务和其他依赖变得容易。
使用 make:policy 命令生成一个新 policy。
node ace make:policy post
这会在 app/policies 目录中创建一个空的 policy 类。
import User from '#models/user'
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
}
为你的 policy 添加用于每个授权操作的方法。每个方法接收用户作为第一个参数,可选地后跟资源,并返回一个 AuthorizerResponse(一个布尔值或 AuthorizationResponse 对象)。
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
create(user: User): AuthorizerResponse {
return true
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
即使多个操作拥有相同的逻辑(如本例中的 edit 和 delete),也要为每个操作创建独立的方法。这样在需求变化时,更容易独立地演进各逻辑。
在控制器中使用 policies
你可以通过调用 bouncer.with() 来选择 policy,然后使用与 abilities 相同的授权方法,从而在控制器中使用 policies。
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async delete({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.with(PostPolicy).denies('delete', post)) {
return response.forbidden('Cannot delete this post')
}
await post.delete()
return { message: 'Post deleted successfully' }
}
}
bouncer.with() 方法接受 policy 类,并返回一个拥有相同 allows、denies、authorize 和 execute 方法的对象。TypeScript 会针对来自你 policy 的可用操作提供自动补全。
基于字符串的 policy 引用
与其导入 policy 类,你也可以用字符串按名称引用它。这是可行的,因为 Bouncer 在 .adonisjs/server/policies.ts 处维护了一个包含所有 policies 的 barrel 文件。
import Post from '#models/post'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async delete({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.with('PostPolicy').denies('delete', post)) {
return response.forbidden('Cannot delete this post')
}
await post.delete()
return { message: 'Post deleted successfully' }
}
}
barrel 文件 会在你启动开发服务器时自动生成,并在你添加或移除 policies 时保持最新。该文件导出一个对象,其中每个键是 policy 名称,值是该 policy 模块的惰性导入。
Policy 钩子
Policies 支持 before 和 after 钩子,它们会在授权检查前后运行。这些钩子提供了创建可复用授权逻辑的强大方式。
before 钩子
before 钩子在真正的授权方法被调用之前运行。这对于实现适用于 policy 中所有操作的逻辑很有用,例如向管理员授予完全访问权限。
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
before(user: User | null, action: string, ...params: any[]) {
if (user && user.role === 'admin') {
return true
}
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
before 钩子接收用户、正在检查的操作名称,以及传给操作方法的任何附加参数。返回值控制授权如何进行。
| Return Value | Behavior |
|---|---|
true | 授权立即成功。操作方法不会被调用。 |
false | 授权立即失败。操作方法不会被调用。 |
undefined | 继续调用操作方法以执行授权检查。 |
在此示例中,任何拥有 role = 'admin' 属性的用户都会绕过所有授权检查。普通用户则会经过正常的 edit 和 delete 方法。
after 钩子
after 钩子在操作方法完成后运行。这允许你检查或覆盖授权响应。
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
after(
user: User | null,
action: string,
response: AuthorizerResponse,
...params: any[]
) {
if (user && user.isAdmin) {
return true
}
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
after 钩子接收用户、操作名称、来自操作方法的授权响应,以及任何附加参数。返回值决定最终结果。
| Return Value | Behavior |
|---|---|
true | 授权成功。原始响应被丢弃。 |
false | 授权失败。原始响应被丢弃。 |
undefined | 使用来自操作方法的原始响应。 |
after 钩子对于应用会覆盖资源特定检查的组织级策略很有用。例如,你可能希望允许管理员或支持人员访问所有资源,而不受单独的授权规则限制。
处理访客用户
默认情况下,当没有已身份验证的用户时,授权检查会自动返回 false。这意味着访客会被拒绝访问,除非你显式地允许他们。
在 abilities 中允许访客
要在 ability 中允许访客用户,请将 allowGuest 选项作为第一个参数传给 Bouncer.ability()。用户参数会被类型化为 User | null。
import User from '#models/user'
import Post from '#models/post'
import { Bouncer } from '@adonisjs/bouncer'
export const viewPost = Bouncer.ability(
{ allowGuest: true },
(user: User | null, post: Post) => {
if (post.isPublished) {
return true
}
if (!user) {
return false
}
return user.id === post.userId
}
)
在 policies 中允许访客
在应该接受访客用户的 policy 方法上使用 @allowGuest 装饰器。
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
@allowGuest()
view(user: User | null, post: Post): AuthorizerResponse {
if (post.isPublished) {
return true
}
if (!user) {
return false
}
return user.id === post.userId
}
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
@allowGuest 装饰器期望用户参数的类型为 User | null,并且即使没有用户通过身份验证,授权检查也会执行。
在 Edge 模板中使用 Bouncer
你可以在 Edge 模板中使用授权检查来条件性地显示或隐藏 UI 元素。Edge 提供了 @can 和 @cannot 标签,可同时配合 abilities 和 policies 使用。
在模板中使用 abilities
通过导出的名称(字符串形式)引用 abilities。Edge 会自动解析并导入它们。
@can('editPost', post)
@link({ route: 'posts.edit', routeParams: [post.id] })
Edit Post
@end
@end
@cannot('deletePost', post)
<p>You cannot delete this post</p>
@end
在模板中使用 policies
使用 PolicyName.methodName 格式引用 policy 操作。
@can('PostPolicy.edit', post)
@link({ route: 'posts.edit', routeParams: [post.id] })
Edit Post
@end
@end
@can('PostPolicy.delete', post)
@form({ method: 'delete', route: 'posts.delete', routeParams: [post.id] })
@!button({ type: 'submit', text: 'Delete Post' })
@end
@end
创建自定义 Bouncer 实例
在 HTTP 请求期间,InitializeBouncerMiddleware 会自动通过从 ctx.auth.user 获取当前登录用户来为其创建一个 Bouncer 实例。该实例可通过 ctx.bouncer 使用,并且也会被共享给 Edge 模板。
你可以为不同的用户创建自定义的 Bouncer 实例,例如在发送通知或执行后台任务时。
import User from '#models/user'
import { Bouncer } from '@adonisjs/bouncer'
import * as abilities from '#abilities/main'
import { policies } from '#generated/policies'
export default class NotificationService {
async sendNotification(user: User, post: Post) {
const bouncer = new Bouncer(user, abilities, policies)
if (await bouncer.allows(editPost, post)) {
// 发送关于帖子编辑的通知
}
}
}
TypeScript 会根据用户类型提供智能自动补全,仅建议接受相同用户类型的 policies 和 abilities。
Policies 中的依赖注入
Policy 类是通过 IoC 容器实例化的,这意味着你可以向构造函数中注入依赖。
import User from '#models/user'
import Post from '#models/post'
import { inject } from '@adonisjs/core'
import { BasePolicy } from '@adonisjs/bouncer'
import { Logger } from '@adonisjs/core/logger'
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
@inject()
export default class PostPolicy extends BasePolicy {
constructor(protected logger: Logger) {
super()
}
edit(user: User, post: Post): AuthorizerResponse {
this.logger.info('Checking edit permission', { userId: user.id, postId: post.id })
return user.id === post.userId
}
}
@inject() 装饰器告诉 IoC 容器自动解析依赖。
在 API 和 Inertia 应用中进行授权
Abilities 和 policies 是在服务端定义的,需要访问服务端资源,如模型、数据库和服务。这意味着你无法在 API 或 Inertia 应用中将它们直接共享给前端代码。
不过,你可以在服务端计算授权结果,并将其包含在你的 API 响应中。你的前端应用只需要知道用户可以执行哪些操作,以适当地显示或隐藏 UI 元素。
在 transformers 中计算权限
对于需要为单条记录检查授权的行级权限,请在 transformers 中计算权限。
import Post from '#models/post'
import { inject } from '@adonisjs/core'
import PostPolicy from '#policies/post_policy'
import { HttpContext } from '@adonisjs/core/http'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class PostTransformer extends BaseTransformer<Post> {
@inject()
async toObject({ bouncer }: HttpContext) {
const policy = bouncer.with(PostPolicy)
return {
id: this.resource.id,
title: this.resource.title,
content: this.resource.content,
permissions: {
edit: await policy.allows('edit', this.resource),
delete: await policy.allows('delete', this.resource),
},
}
}
}
在 Inertia 中间件中计算权限
对于不随记录变化的、应用级别的权限,请在 Inertia 中间件中作为共享数据计算一次。
import type { HttpContext } from '@adonisjs/core/http'
import BaseInertiaMiddleware from '@adonisjs/inertia/inertia_middleware'
export default class InertiaMiddleware extends BaseInertiaMiddleware {
async share(ctx: HttpContext) {
const postPolicy = ctx.bouncer.with('PostPolicy')
return {
// ...属性的其余部分
permissions: ctx.inertia.once(async () => {
return {
post: {
create: await postPolicy.allows('create'),
}
}
}),
}
}
}
测试授权逻辑
你可以使用针对单个 policies 和 abilities 的单元测试,或者通过 HTTP 请求端到端验证授权的功能测试,来测试你的授权逻辑。
单元测试 policies
通过实例化 policy 类并传入所需参数调用其方法,直接测试 policy 类。
import { test } from '@japa/runner'
import User from '#models/user'
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
test.group('Post policy', () => {
test('allows owner to edit post', async ({ assert }) => {
const user = new User()
user.id = 1
const post = new Post()
post.userId = 1
const policy = new PostPolicy()
const canEdit = policy.edit(user, post)
assert.isTrue(canEdit)
})
test('denies non-owner from editing post', async ({ assert }) => {
const user = new User()
user.id = 1
const post = new Post()
post.userId = 2
const policy = new PostPolicy()
const canEdit = policy.edit(user, post)
assert.isFalse(canEdit)
})
})
通过 HTTP 请求进行功能测试
通过发起 HTTP 请求并验证未授权用户收到适当的错误响应,来端到端测试授权。
import { test } from '@japa/runner'
import User from '#models/user'
import Post from '#models/post'
test.group('Posts update', () => {
test('allows owner to update post', async ({ client }) => {
const user = await User.create({ email: '[email protected]' })
const post = await Post.create({ userId: user.id, title: 'Test' })
const response = await client
.put(`/posts/${post.id}`)
.loginAs(user)
.json({ title: 'Updated' })
response.assertStatus(200)
})
test('denies non-owner from updating post', async ({ client }) => {
const owner = await User.create({ email: '[email protected]' })
const otherUser = await User.create({ email: '[email protected]' })
const post = await Post.create({ userId: owner.id, title: 'Test' })
const response = await client
.put(`/posts/${post.id}`)
.loginAs(otherUser)
.json({ title: 'Updated' })
response.assertStatus(403)
})
})