授权

本指南介绍如何使用 Bouncer 在 AdonisJS 中实现授权。你将学习如何:

  • 将授权检查定义为 abilities 和 policies
  • 在应用的各个部分(控制器、模板、API)使用授权
  • 处理访客用户和策略钩子等高级场景
  • 在 API 和 Inertia 应用中实现授权

概述

授权决定已身份验证的用户在你的应用中被允许做什么。身份验证回答「你是谁?」,而授权回答「你能做什么?」。Bouncer 提供了一种结构化的方式,来定义并检查 AdonisJS 应用中的权限。

而不是将授权检查分散在代码库的各个角落,Bouncer 鼓励你将它们提取到专用的位置。这使得你的授权逻辑集中化、可复用且可测试。

安装

使用以下命令安装并配置 Bouncer。

node ace add @adonisjs/bouncer
add 命令执行的步骤
  1. adonisrc.ts 文件中注册 @adonisjs/bouncer/bouncer_provider 服务提供者和 @adonisjs/bouncer/commands
  2. 创建 app/abilities/main.ts 文件以定义并导出 abilities。
  3. 在中间件目录中创建 initialize_bouncer_middleware.ts 文件,并在 start/kernel.ts 文件中注册它。

定义 abilities

ability 是一个检查用户是否有权执行特定操作的函数。当你只有少量简单的授权检查时,abilities 轻量且适用。

abilities 在 app/abilities/main.ts 文件中使用 Bouncer.ability() 方法定义。每个 ability 接收用户作为第一个参数,随后是做出授权决策所需的任何资源,然后返回一个布尔值,表示该操作是否被允许。

app/abilities/main.ts
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 的某个方法。

app/controllers/posts_controller.ts
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 方法检查用户是否被授权,如果是则返回 truedenies 方法则相反,当用户未被授权时返回 true

app/controllers/posts_controller.ts
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 响应。

app/controllers/posts_controller.ts
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 对象,其中包含关于授权检查的详细信息。当你需要在简单的布尔值之外检查授权结果时,这很有用。

app/controllers/posts_controller.ts
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 对象来指定自定义的错误消息和状态码。

app/abilities/main.ts
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 类。

app/policies/post_policy.ts
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 对象)。

app/policies/post_policy.ts
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
  }
}
Tip

即使多个操作拥有相同的逻辑(如本例中的 editdelete),也要为每个操作创建独立的方法。这样在需求变化时,更容易独立地演进各逻辑。

在控制器中使用 policies

你可以通过调用 bouncer.with() 来选择 policy,然后使用与 abilities 相同的授权方法,从而在控制器中使用 policies。

app/controllers/posts_controller.ts
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 类,并返回一个拥有相同 allowsdeniesauthorizeexecute 方法的对象。TypeScript 会针对来自你 policy 的可用操作提供自动补全。

基于字符串的 policy 引用

与其导入 policy 类,你也可以用字符串按名称引用它。这是可行的,因为 Bouncer 在 .adonisjs/server/policies.ts 处维护了一个包含所有 policies 的 barrel 文件。

app/controllers/posts_controller.ts
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 支持 beforeafter 钩子,它们会在授权检查前后运行。这些钩子提供了创建可复用授权逻辑的强大方式。

before 钩子

before 钩子在真正的授权方法被调用之前运行。这对于实现适用于 policy 中所有操作的逻辑很有用,例如向管理员授予完全访问权限。

app/policies/post_policy.ts
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 ValueBehavior
true授权立即成功。操作方法不会被调用。
false授权立即失败。操作方法不会被调用。
undefined继续调用操作方法以执行授权检查。

在此示例中,任何拥有 role = 'admin' 属性的用户都会绕过所有授权检查。普通用户则会经过正常的 editdelete 方法。

after 钩子

after 钩子在操作方法完成后运行。这允许你检查或覆盖授权响应。

app/policies/post_policy.ts
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 ValueBehavior
true授权成功。原始响应被丢弃。
false授权失败。原始响应被丢弃。
undefined使用来自操作方法的原始响应。

after 钩子对于应用会覆盖资源特定检查的组织级策略很有用。例如,你可能希望允许管理员或支持人员访问所有资源,而不受单独的授权规则限制。

处理访客用户

默认情况下,当没有已身份验证的用户时,授权检查会自动返回 false。这意味着访客会被拒绝访问,除非你显式地允许他们。

在 abilities 中允许访客

要在 ability 中允许访客用户,请将 allowGuest 选项作为第一个参数传给 Bouncer.ability()。用户参数会被类型化为 User | null

app/abilities/main.ts
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 装饰器。

app/policies/post_policy.ts
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 会自动解析并导入它们。

resources/views/posts/show.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 操作。

resources/views/posts/show.edge
@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 实例,例如在发送通知或执行后台任务时。

app/services/notification_service.ts
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 容器实例化的,这意味着你可以向构造函数中注入依赖。

app/policies/post_policy.ts
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 中计算权限。

app/transformers/post_transformer.ts
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 中间件中作为共享数据计算一次。

app/middleware/inertia_middleware.ts
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 类。

tests/unit/post_policy.spec.ts
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 请求并验证未授权用户收到适当的错误响应,来端到端测试授权。

tests/functional/posts/update.spec.ts
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)
  })
})