授权
你可以使用 @adonisjs/bouncer 包在 AdonisJS 应用中编写授权检查。Bouncer 提供了一个以 JavaScript 为先的 API,用于将授权检查编写为 abilities(能力) 和 policies(策略)。
abilities 和 policies 的目标是将授权某个动作的逻辑抽象到单一位置,并在代码库其余部分复用。
Bouncer 不是 RBAC 或 ACL 的实现。相反,它提供了一个细粒度控制的底层 API,用于在 AdonisJS 应用中授权动作。
安装
使用以下命令安装并配置该包:
node ace add @adonisjs/bouncer
-
使用检测到的包管理器安装
@adonisjs/bouncer包。 -
在
adonisrc.ts文件中注册以下服务提供者(service provider)和命令。{commands: [// ...other commands() => import('@adonisjs/bouncer/commands')],providers: [// ...other providers() => import('@adonisjs/bouncer/bouncer_provider')]} -
创建
app/abilities/main.ts文件,用于定义并导出 abilities。 -
创建
app/policies/main.ts文件,用于将所有的策略作为一个集合导出。 -
在
middleware目录中创建initialize_bouncer_middleware。 -
在
start/kernel.ts文件中注册以下中间件。router.use([() => import('#middleware/initialize_bouncer_middleware')])
你更喜欢看视频学习? - 欢迎观看我们来自 Adocasts 的朋友制作的免费 screencast 系列 AdonisJS Bouncer 。
Initialize bouncer 中间件
在配置过程中,我们会创建并在应用中注册 #middleware/initialize_bouncer_middleware 中间件。该初始化中间件负责为当前已身份认证的用户创建一个 Bouncer 类的实例,并通过 ctx.bouncer 属性将其与请求的其余部分共享。
此外,我们使用 ctx.view.share 方法将同一个 Bouncer 实例共享给 Edge 模板。如果你的应用中没有使用 Edge,可以随意从中间件中移除以下代码行。
你拥有应用的源代码,包括初始设置期间创建的文件。因此,不要犹豫去修改它们,使其适配你的应用环境。
async handle(ctx: HttpContext, next: NextFn) {
ctx.bouncer = new Bouncer(
() => ctx.auth.user || null,
abilities,
policies
).setContainerResolver(ctx.containerResolver)
/**
* Remove if not using Edge
*/
if ('view' in ctx) {
ctx.view.share(ctx.bouncer.edgeHelpers)
}
return next()
}
定义 abilities
Abilities 是通常写在 ./app/abilities/main.ts 文件中的 JavaScript 函数。你可以从该文件导出多个 abilities。
在下面的示例中,我们使用 Bouncer.ability 方法定义了一个名为 editPost 的 ability。实现回调函数必须返回 true 以授权用户,返回 false 以拒绝访问。
Ability 应当始终以 User 作为第一个参数,后面跟随授权检查所需的其他参数。
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
})
执行授权
一旦定义了 ability,你就可以使用 ctx.bouncer.allows 方法执行授权检查。
Bouncer 会自动将当前登录的用户作为第一个参数传递给 ability 回调函数,而你必须手动提供其余参数。
import Post from '#models/post'
import { editPost } from '#abilities/main'
import router from '@adonisjs/core/services/router'
router.put('posts/:id', async ({ bouncer, params, response }) => {
/**
* Find a post by ID so that we can perform an
* authorization check for it.
*/
const post = await Post.findOrFail(params.id)
/**
* Use the ability to see if the logged-in user
* is allowed to perform the action.
*/
if (await bouncer.allows(editPost, post)) {
return 'You can edit the post'
}
return response.forbidden('You cannot edit the post')
})
bouncer.allows 方法的对立面是 bouncer.denies 方法。相比编写 if not 语句,你可能更喜欢使用这个方法。
if (await bouncer.denies(editPost, post)) {
response.abort('You cannot edit the post', 403)
}
允许访客用户
默认情况下,Bouncer 会拒绝未登录用户的授权检查,且不会调用 ability 回调函数。
但是,你可能希望定义某些可以与访客用户一起工作的 abilities。例如,允许访客查看已发布的文章,但允许文章的创建者查看草稿。
你可以使用 allowGuest 选项定义一个允许访客用户的 ability。在这种情况下,选项将作为第一个参数定义,回调函数作为第二个参数。
export const viewPost = Bouncer.ability(
{ allowGuest: true },
(user: User | null, post: Post) => {
/**
* Allow everyone to access published posts
*/
if (post.isPublished) {
return true
}
/**
* Guest cannot view non-published posts
*/
if (!user) {
return false
}
/**
* The creator of the post can view non-published posts
* as well.
*/
return user.id === post.userId
}
)
授权除登录用户以外的用户
如果你想授权除登录用户以外的用户,可以使用 Bouncer 构造函数为给定用户创建一个新的 bouncer 实例。
import User from '#models/user'
import { Bouncer } from '@adonisjs/bouncer'
const user = await User.findOrFail(1)
const bouncer = new Bouncer(user)
if (await bouncer.allows(editPost, post)) {
}
定义 policies
Policies 提供了一个抽象层,用于将授权检查组织为类。建议为每个资源创建一个策略。例如,如果你的应用有一个 Post 模型,你必须创建一个 PostPolicy 类来授权诸如创建或更新文章等动作。
Policies 存储在 ./app/policies 目录中,每个文件代表一个策略。你可以通过运行以下命令创建一个新的策略。
另请参阅:Make policy 命令
node ace make:policy post
策略类继承自 BasePolicy 类,你可以为实现想要执行的授权检查编写方法。在下面的示例中,我们定义了对文章的 create、edit 和 delete 授权检查。
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 {
/**
* Every logged-in user can create a post
*/
create(user: User): AuthorizerResponse {
return true
}
/**
* Only the post creator can edit the post
*/
edit(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
/**
* Only the post creator can delete the post
*/
delete(user: User, post: Post): AuthorizerResponse {
return user.id === post.userId
}
}
执行授权
一旦创建了策略,你可以使用 bouncer.with 方法指定要用于授权的策略,然后链式调用 bouncer.allows 或 bouncer.denies 方法来执行授权检查。
链式调用在 bouncer.with 方法之后的 allows 和 denies 方法是类型安全的,并会根据你在策略类上定义的方法显示补全列表。
import Post from '#models/post'
import PostPolicy from '#policies/post_policy'
import type { HttpContext } from '@adonisjs/core/http'
export default class PostsController {
async create({ bouncer, response }: HttpContext) {
if (await bouncer.with(PostPolicy).denies('create')) {
return response.forbidden('Cannot create a post')
}
//Continue with the controller logic
}
async edit({ bouncer, params, response }: HttpContext) {
const post = await Post.findOrFail(params.id)
if (await bouncer.with(PostPolicy).denies('edit', post)) {
return response.forbidden('Cannot edit the post')
}
//Continue with the controller logic
}
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 the post')
}
//Continue with the controller logic
}
}
允许访客用户
与 abilities 类似,policies 也可以使用 @allowGuest 装饰器为访客用户定义授权检查。例如:
import User from '#models/user'
import Post from '#models/post'
import { BasePolicy, allowGuest } from '@adonisjs/bouncer'
import type { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
@allowGuest()
view(user: User | null, post: Post): AuthorizerResponse {
/**
* Allow everyone to access published posts
*/
if (post.isPublished) {
return true
}
/**
* Guest cannot view non-published posts
*/
if (!user) {
return false
}
/**
* The creator of the post can view non-published posts
* as well.
*/
return user.id === post.userId
}
}
策略钩子
你可以在策略类上定义 before 和 after 模板方法,以在授权检查前后运行动作。一个常见的用例是始终允许或拒绝某个特定用户的访问。
before 和 after 方法总是会被调用,不管是否有登录用户。因此请确保处理 user 的值为 null 的情况。
before 的响应解释如下。
true值将被视为授权成功,动作方法不会被调用。false值将被视为拒绝访问,动作方法不会被调用。- 返回
undefined时,bouncer 将执行动作方法来进行授权检查。
export default class PostPolicy extends BasePolicy {
async before(user: User | null, action: string, ...params: any[]) {
/**
* Always allow an admin user without performing any check
*/
if (user && user.isAdmin) {
return true
}
}
}
after 方法接收来自动作方法的原始响应,并可以通过返回新值来覆盖之前响应。after 的响应解释如下。
true值将被视为授权成功,旧响应将被丢弃。false值将被视为拒绝访问,旧响应将被丢弃。- 返回
undefined时,bouncer 将继续使用旧响应。
import { AuthorizerResponse } from '@adonisjs/bouncer/types'
export default class PostPolicy extends BasePolicy {
async after(
user: User | null,
action: string,
response: AuthorizerResponse,
...params: any[]
) {
if (user && user.isAdmin) {
return true
}
}
}
依赖注入
策略类是使用 IoC 容器创建的;因此,你可以使用 @inject 装饰器在策略构造函数中类型提示并注入依赖。
import { inject } from '@adonisjs/core'
import { PermissionsResolver } from '#services/permissions_resolver'
@inject()
export class PostPolicy extends BasePolicy {
constructor(
protected permissionsResolver: PermissionsResolver
) {
super()
}
}
如果在 HTTP 请求期间创建了一个策略类,你也可以在其中注入 HttpContext 的实例。
import { HttpContext } from '@adonisjs/core/http'
import { PermissionsResolver } from '#services/permissions_resolver'
@inject()
export class PostPolicy extends BasePolicy {
constructor(protected ctx: HttpContext) {
super()
}
}
抛出 AuthorizationException
除了 allows 和 denies 方法外,你还可以使用 bouncer.authorize 方法来执行授权检查。当检查失败时,该方法会抛出 AuthorizationException。
router.put('posts/:id', async ({ bouncer, params }) => {
const post = await Post.findOrFail(params.id)
await bouncer.authorize(editPost, post)
/**
* If no exception was raised, you can consider the user
* is allowed to edit the post.
*/
})
AdonisJS 会使用以下内容协商规则将 AuthorizationException 转换为 403 - Forbidden HTTP 响应。
-
带有
Accept=application/json头的 HTTP 请求将收到一个错误消息数组。每个数组元素都是一个带有message属性的对象。 -
带有
Accept=application/vnd.api+json头的 HTTP 请求将收到按照 JSON API 规范格式化的错误消息数组。 -
所有其他请求将收到一个纯文本响应消息。不过,你可以使用状态页面为授权错误显示自定义错误页面。
你也可以在全局异常处理程序中自行处理 AuthorizationException 错误。
import app from '@adonisjs/core/services/app'
import { errors } from '@adonisjs/bouncer'
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_AUTHORIZATION_FAILURE) {
return ctx
.response
.status(error.status)
.send(error.getResponseMessage(ctx))
}
return super.handle(error, ctx)
}
}
自定义授权响应
除了从 abilities 和 policies 返回布尔值之外,你还可以使用 AuthorizationResponse 类构造一个错误响应。
AuthorizationResponse 类让你可以细粒度地控制定义自定义 HTTP 状态码和错误消息。
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 true
}
return AuthorizationResponse.deny('Post not found', 404)
})
如果你正在使用 @adonisjs/i18n 包,你可以使用 .t 方法返回本地化响应。在基于用户语言的 HTTP 请求期间,翻译消息将优先于默认消息使用。
export const editPost = Bouncer.ability((user: User, post: Post) => {
if (user.id === post.userId) {
return true
}
return AuthorizationResponse
.deny('Post not found', 404) // default message
.t('errors.not_found') // translation identifier
})
使用自定义响应构建器
为单个授权检查定义自定义错误消息的灵活性很好。但是,如果你总是想返回相同的响应,每次重复相同的代码可能会很麻烦。
因此,你可以像下面这样覆盖 Bouncer 的默认响应构建器。
import { Bouncer, AuthorizationResponse } from '@adonisjs/bouncer'
Bouncer.responseBuilder = (response: boolean | AuthorizationResponse) => {
if (response instanceof AuthorizationResponse) {
return response
}
if (response === true) {
return AuthorizationResponse.allow()
}
return AuthorizationResponse
.deny('Resource not found', 404)
.t('errors.not_found')
}
预注册 abilities 和 policies
到目前为止,在本指南中,我们每次想要使用 ability 或 policy 时都显式地导入它。但是,一旦你预注册它们,就可以通过字符串形式的名称来引用 ability 或 policy。
在你的 TypeScript 代码库中,预注册 abilities 和 policies 可能不如仅仅清理导入那么有用。不过,它们在 Edge 模板中提供了更好的开发体验(DX)。
看看下面有无预注册策略的 Edge 模板代码示例。
未预注册。不,并不是很干净
{{-- First import the ability --}}
@let(editPost = (await import('#abilities/main')).editPost)
@can(editPost, post)
{{-- Can edit post --}}
@end
已预注册
{{-- Reference ability name as a string --}}
@can('editPost', post)
{{-- Can edit post --}}
@end
如果你打开 initialize_bouncer_middleware.ts 文件,你会发现我们在创建 Bouncer 实例时已经导入并预注册了 abilities 和 policies。
import * as abilities from '#abilities/main'
import { policies } from '#policies/main'
export default class InitializeBouncerMiddleware {
async handle(ctx, next) {
ctx.bouncer = new Bouncer(
() => ctx.auth.user,
abilities,
policies
)
return next()
}
}
注意事项
-
如果你决定在代码库的其他位置定义 abilities,请确保导入并在中间件中预注册它们。
-
对于 policies,每次运行
make:policy命令时,请确保接受在 policies 集合中注册该策略的提示。policies 集合定义在./app/policies/main.ts文件中。app/policies/main.tsexport const policies = {PostPolicy: () => import('#policies/post_policy'),CommentPolicy: () => import('#policies/comment_policy')}
引用预注册的 abilities 和 policies
在下面的示例中,我们去掉了导入,改为通过名称引用 abilities 和 policies。请注意基于字符串的 API 也是类型安全的,但你的代码编辑器的"跳转到定义"功能可能无法工作。
import { editPost } from '#abilities/main'
router.put('posts/:id', async ({ bouncer, params, response }) => {
const post = await Post.findOrFail(params.id)
if (await bouncer.allows(editPost, post)) {
if (await bouncer.allows('editPost', post)) {
return 'You can edit the post'
}
})
import PostPolicy from '#policies/post_policy'
export default class PostsController {
async create({ bouncer, response }: HttpContext) {
if (await bouncer.with(PostPolicy).denies('create')) {
if (await bouncer.with('PostPolicy').denies('create')) {
return response.forbidden('Cannot create a post')
}
//Continue with the controller logic
}
}
Edge 模板中的授权检查
在 Edge 模板中执行授权检查之前,请确保预注册 abilities 和 policies。完成后,你可以使用 @can 和 @cannot 标签来执行授权检查。
这些标签接受 ability 名称或 policy.method 名称作为第一个参数,后面跟随 ability 或 policy 接受的其余参数。
@can('editPost', post)
{{-- Can edit post --}}
@end
@cannot('editPost', post)
{{-- Cannot edit post --}}
@end
@can('PostPolicy.edit', post)
{{-- Can edit post --}}
@end
@cannot('PostPolicy.edit', post)
{{-- Cannot edit post --}}
@end
事件
请查看事件参考指南以查看 @adonisjs/bouncer 包分发的事件列表。