授权

在上一章中,我们改善了 DevShow 的导航与样式。现在让我们添加让用户编辑和删除自己帖子与评论的能力。目前,如果我们添加了这些功能,任何已登录用户都能修改他人的内容。我们需要添加授权检查来防止这种情况。

概述

为了妥善处理权限,我们将使用 AdonisJS 的 Bouncer 包 。Bouncer 让你将授权逻辑组织到**策略(policy)**中(每个方法代表一个权限检查的类)。例如,PostPolicy 可以有一个 edit 方法,用于检查某个用户能否编辑特定帖子。

你无需将权限检查散落在各个控制器中,只需在策略中定义一次规则,然后在任何地方使用它们。在本章中,我们将安装 Bouncer,为帖子和评论创建策略,并在适当的授权下实现编辑和删除功能。

安装 Bouncer

让我们使用以下命令安装并配置 Bouncer 包。

node ace add @adonisjs/bouncer

运行此命令会先安装包,然后执行以下操作:

  • 创建一个 app/abilities/main.ts 文件,你可以在其中定义授权能力(目前我们还用不到这个文件,所以不必担心)
  • 注册一个中间件,为每个 HTTP 请求初始化 Bouncer
  • bouncer 对象在 HttpContext 上可用,以便你在控制器中使用它

一切就绪!现在让我们创建第一个策略。

创建 PostPolicy

策略是一些类,其中每个方法代表一个权限检查。让我们为帖子创建一个策略。

node ace make:policy post

打开生成的文件并添加针对编辑和删除帖子的权限检查。

app/policies/post_policy.ts
import type User from '#models/user'
import type Post from '#models/post'
import { BasePolicy } from '@adonisjs/bouncer'

export default class PostPolicy extends BasePolicy {
  /**
   * 只有帖子所有者才能编辑自己的帖子
   */
  edit(user: User, post: Post) {
    return user.id === post.userId
  }

  /**
   * 只有帖子所有者才能删除自己的帖子
   */
  delete(user: User, post: Post) {
    return user.id === post.userId
  }
}

每个策略方法接收当前登录用户作为第一个参数,后面跟着被检查的资源(在本例中是 post)。如果用户被允许执行该操作,方法返回 true,否则返回 false。这里,我们只是简单地检查用户的 ID 是否与帖子的 userId 相匹配。

你可能会注意到,editdelete 现在的逻辑完全相同。即便如此,将它们分开能给你灵活性。以后你可能会决定帖子在 24 小时后不可编辑,或者管理员可以删除任何帖子但不能编辑它们。拥有独立的方法会让这类改动更容易。

创建 CommentPolicy

现在为评论创建一个策略。

node ace make:policy comment

添加删除权限检查。

app/policies/comment_policy.ts
import type User from '#models/user'
import type Comment from '#models/comment'
import { BasePolicy } from '@adonisjs/bouncer'

export default class CommentPolicy extends BasePolicy {
  /**
   * 只有评论所有者才能删除自己的评论
   */
  delete(user: User, comment: Comment) {
    return user.id === comment.userId
  }
}

完美!现在让我们让这些策略发挥作用。

添加编辑功能

  1. 创建更新验证器

    我们将为更新帖子添加一个验证器。由于我们已经有一个用于创建帖子的 validators/post.ts 文件,我们也会把更新验证器加在那里。一个验证器文件可以导出多个验证器(这样能将相关的验证逻辑组织在一起)。

    打开你已有的帖子验证器文件并添加更新验证器。

    app/validators/post.ts
    import vine from '@vinejs/vine'
    
    export const createPostValidator = vine.create({
      title: vine.string().minLength(3).maxLength(255),
      url: vine.string().url(),
      summary: vine.string().minLength(80).maxLength(500),
    })
    
    /**
     * 与创建帖子相同的验证规则
     */
    export const updatePostValidator = vine.create(
      createPostValidator.schema.clone()
    )

    我们正在克隆 createPostValidator 的 schema,以复用相同的验证规则。这种方式让我们的验证逻辑保持 DRY(Don't Repeat Yourself,不要重复自己)。如果你以后需要更改某条规则,只需在一个地方更新。在许多应用中,你可能会希望创建和更新使用不同的规则,但对于 DevShow,需求是相同的。

  2. 添加控制器方法

    我们将添加两个控制器方法:edit 用于显示编辑表单,update 用于处理表单提交。这两个方法都会在执行任何操作之前,使用 Bouncer 检查当前用户是否被允许修改该帖子。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import { createPostValidator, updatePostValidator } from '#validators/post'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostsController {
      // ... 已有方法(index、create、store、show)
    
      /**
       * 显示编辑表单
       */
      async edit({ bouncer, params, view }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // 检查当前用户能否编辑这篇帖子
        await bouncer.with(PostPolicy).authorize('edit', post)
    
        return view.render('posts/edit', { post })
      }
    
      /**
       * 更新帖子
       */
      async update({ bouncer, params, request, response, session }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // 再次检查授权。有人可能会直接发送 PUT 请求
        await bouncer.with(PostPolicy).authorize('edit', post)
    
        // 验证并更新帖子
        const data = await request.validateUsing(updatePostValidator)
        await post.merge(data).save()
    
        session.flash('success', 'Post updated successfully')
        return response.redirect().toRoute('posts.show', { id: post.id })
      }
    }

    这里的关键部分是 bouncer.with(PostPolicy).authorize('edit', post)。这一行:

    • 调用我们 PostPolicy 中的 edit 方法
    • 将帖子传递给该策略方法
    • 如果策略返回 false,Bouncer 会自动抛出 403 Forbidden 错误
    • 如果策略返回 true,代码继续执行

    注意我们在两个方法都进行了授权检查。即使 edit 检查了权限,有人也可能绕过表单直接对 update 路由发送 PUT 请求。在执行敏感操作之前,务必验证权限。

  3. 理解闪存消息

    你会注意到 update 方法中的 session.flash('success', 'Post updated successfully')。这是我们在 DevShow 中首次使用闪存消息(flash messages),所以让我们理解一下它们的作用。

    闪存消息 是临时存储在会话中的消息。它们仅在下一个请求中可用,之后会自动移除。这使得它们非常适合在表单提交后显示成功或错误消息。

    你无需添加任何代码来显示这些消息——入门套件的布局已经包含一个会自动渲染闪存消息的组件。当设置了闪存消息时,它会在下一个请求中作为页面顶部的通知出现。

    在本章的其余部分,每当我们要确认某个操作(如更新或删除)成功时,都会使用闪存消息。

  4. 注册路由

    现在让我们为编辑帖子注册路由。打开你的路由文件。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts', [controllers.Posts, 'index'])
    router.get('/posts/create', [controllers.Posts, 'create']).use(middleware.auth())
    router.post('/posts', [controllers.Posts, 'store']).use(middleware.auth())
    router.get('/posts/:id', [controllers.Posts, 'show'])
    
    router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())

    我们需要两条路由(一条显示编辑表单,另一条处理表单提交)。两者都需要身份验证。

  5. 创建编辑表单模板

    创建编辑表单模板。

    node ace make:view posts/edit

    现在添加表单标记。

    resources/views/posts/edit.edge
    @layout()
      <div class="form-container">
        @!link({
          route: 'posts.show',
          routeParams: post,
          text: '&lsaquo; Back to post'
        })
    
        <h1>Edit Post</h1>
    
        @form({ route: 'posts.update', routeParams: post, method: 'PUT' })
          <div>
            @field.root({ name: 'title' })
              @!field.label({ text: 'Post title' })
              @!input.control({ value: post.title })
              @!field.error()
            @end
          </div>
          
          <div>
            @field.root({ name: 'url' })
              @!field.label({ text: 'URL' })
              @!input.control({ type: 'url', value: post.url })
              @!field.error()
            @end
          </div>
          
          <div>
            @field.root({ name: 'summary' })
              @!field.label({ text: 'Short summary' })
              @!textarea.control({ rows: 4, value: post.summary })
              @!field.error()
            @end
          </div>
          
          <div>
            @!button({ text: 'Update Post', type: 'submit' })
          </div>
        @end
      </div>
    @end

    这个表单与创建表单相似,但有几点关键区别:

    • HTTP 方法:使用 method: 'PUT' 表示这是一个更新请求,而不是用于创建新数据的 POST
    • 预填值:每个字段都显示帖子当前的数据(value: post.titlevalue: post.urltext: post.summary),这样用户能看到他们正在编辑的内容
    • 路由:提交到 posts.update 路由,帖子 ID 包含在 URL 中
  6. 在帖子详情页添加编辑按钮

    现在在帖子详情页添加"编辑"按钮。打开你的 posts/show.edge 模板。

    resources/views/posts/show.edge
    @layout()
      <div class="container">
        <div class="post">
          <div class="post-meta">...</div>
    
          <div class="post-summary">...</div>
    
          <div class="post-actions">
            {{-- 仅向帖子所有者显示编辑按钮 --}}
            @can('PostPolicy.edit', post)
              @!link({
                route: 'posts.edit',
                routeParams: post,
                text: 'Edit post',
              })
            @end
          </div>
        </div>
      </div>
    @end

    @can 标签在你的模板中检查策略方法,类似于控制器中 bouncer.authorize() 的工作方式:

    • 第一个参数'PostPolicy.edit')- 指定要使用哪个策略和哪个方法
    • 第二个参数post)- 被检查的资源,传递给策略方法
    • 当检查失败时 - @can@end 之间的所有内容都会从 HTML 输出中隐藏

    非所有者甚至不会在页面源码中看到"编辑"按钮。如果有人尝试直接访问编辑 URL,他们仍会从控制器的授权检查处收到 403 错误。

访问你创建的帖子,你会看到"编辑"按钮。点击它并尝试更新你的帖子!

添加删除功能

  1. 添加控制器方法

    删除帖子比编辑更简单,因为没有需要显示的表单(只是一个提交 DELETE 请求的按钮)。让我们添加处理删除的控制器方法。

    app/controllers/posts_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Post from '#models/post'
    import { createPostValidator, updatePostValidator } from '#validators/post'
    import PostPolicy from '#policies/post_policy'
    
    export default class PostsController {
      // ... 已有方法
    
      /**
       * 删除帖子
       */
      async destroy({ bouncer, params, response, session }: HttpContext) {
        const post = await Post.findOrFail(params.id)
    
        // 检查用户能否删除这篇帖子
        await bouncer.with(PostPolicy).authorize('delete', post)
    
        await post.delete()
    
        session.flash('success', 'Post deleted successfully')
        return response.redirect().toRoute('posts.index')
      }
    }

    这个模式现在你已经熟悉了:找到帖子,使用策略授权操作,执行删除,闪存成功消息,然后重定向。删除帖子后,我们重定向到帖子索引页,因为帖子详情页已不存在。

  2. 注册路由

    现在注册删除路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
  3. 在帖子详情页添加删除按钮

    在帖子详情模板中的"编辑"按钮旁添加一个"删除"按钮。

    resources/views/posts/show.edge
    @layout()
    <div class="container">
        <div class="post">
          <div class="post-meta">...</div>
    
          <div class="post-summary">...</div>
    
          <div class="post-actions">
            {{-- 仅向帖子所有者显示编辑按钮 --}}
            @can('PostPolicy.edit', post)
              @!link({
                route: 'posts.edit',
                routeParams: post,
                text: 'Edit post',
              })
            @end
    
            {{-- 仅向帖子所有者显示删除按钮 --}}
            @can('PostPolicy.delete', post)
              <span>.</span>
              @form({ route: 'posts.destroy', routeParams: post, method: 'DELETE' })
                @!button({ text: 'Delete', class: 'destructive' })
              @end
            @end
          </div>
        </div>
      </div>
    @end

    关于这个删除按钮,有几点要点:

    • DELETE 方法 - 我们在表单中使用了 method: 'DELETE',但 HTML 表单原生只支持 GET 和 POST 方法
    • 表单方法欺骗(Form method spoofing) - 在底层,@form 组件会提交一个带 ?_method=DELETE 查询字符串的 POST 请求。AdonisJS 识别这种模式并将该请求视为 DELETE。这种技术称为 表单方法欺骗
    • 授权检查 - @can('PostPolicy.delete', post) 标签确保只有帖子所有者才能看到该按钮

    试一试!访问你创建的帖子,你会看到"编辑"和"删除"两个按钮。访问他人创建的帖子,则不会出现任何按钮。

添加评论删除

  1. 添加控制器方法

    让我们添加用于删除评论的控制器方法。

    app/controllers/comments_controller.ts
    import type { HttpContext } from '@adonisjs/core/http'
    import Comment from '#models/comment'
    import { createCommentValidator } from '#validators/comment'
    import CommentPolicy from '#policies/comment_policy'
    
    export default class CommentsController {
      // ... 已有 store 方法
    
      /**
       * 删除评论
       */
      async destroy({ bouncer, params, response, session }: HttpContext) {
        const comment = await Comment.findOrFail(params.id)
    
        // 加载 post 关联,以便删除后重定向回它
        await comment.load('post')
    
        // 检查用户能否删除这条评论
        await bouncer.with(CommentPolicy).authorize('delete', comment)
    
        await comment.delete()
    
        session.flash('success', 'Comment deleted successfully')
        return response.redirect().toRoute('posts.show', { id: comment.post.id })
      }
    }

    这个方法做了以下事情:

    • 查找评论 —— 使用路由参数中的 ID
    • 加载 post 关联 —— 这样我们就能访问 comment.post.id 以便在删除后重定向
    • 检查授权 —— 使用 CommentPolicy。如果用户不属于这条评论,他们会收到 403 错误
    • 从数据库删除评论
    • 重定向回 显示该评论的帖子详情页
  2. 注册路由

    现在为评论注册删除路由。

    start/routes.ts
    import router from '@adonisjs/core/services/router'
    import { middleware } from '#start/kernel'
    import { controllers } from '#generated/controllers'
    
    router.get('/posts/:id/edit', [controllers.Posts, 'edit']).use(middleware.auth())
    router.put('/posts/:id', [controllers.Posts, 'update']).use(middleware.auth())
    router.delete('/posts/:id', [controllers.Posts, 'destroy']).use(middleware.auth())
    router.post('/posts/:id/comments', [controllers.Comments, 'store']).use(middleware.auth())
    
    router
      .delete('/comments/:id', [controllers.Comments, 'destroy'])
      .use(middleware.auth())
  3. 为评论添加删除按钮

    最后,在帖子详情模板的评论列表中添加删除按钮。

    resources/views/posts/show.edge
    @layout()
      <div class="container">
        <div class="post">
          {{-- ... 帖子内容 ... --}}
    
          <div class="post-comments">
            <h2>Comments</h2>
    
            @each(comment in post.comments)
              <div class="comment-item">
                {{-- ... 评论内容 ... --}}
    
                <div class="comment-actions">
                  @can('CommentPolicy.delete', comment)
                    @form({ route: 'comments.destroy', routeParams: [comment.id], method: 'DELETE' })
                      @!button({ type: 'submit', text: 'Delete', class: 'destructive' })
                    @end
                  @end
                </div>
              </div>
            @else
              <p> No comments yet. </p>
            @end
          </div>
        </div>
      </div>
    @end

    评论删除按钮的工作方式如下:

    • 策略检查 - @can 标签检查 CommentPolicy,以确定当前用户能否删除每条评论
    • 可见性 - 只有评论的作者才能看到删除按钮
    • 操作 - 按钮向 comments.destroy 路由提交一个 DELETE 请求

    访问你创建的带有评论的帖子,你会看到你的评论旁出现删除按钮。尝试查看其他用户的评论,则不会出现删除按钮。

你构建的内容

你已成功使用 Bouncer 的策略系统为 DevShow 添加了授权。以下是你达成的成果:

  • 创建了 PostPolicyCommentPolicy,将所有权限逻辑集中到一处
  • 在控制器中使用 bouncer.with(Policy).authorize(),在允许操作前强制执行权限
  • 实现了包含表单、验证与授权的完整帖子编辑功能
  • 为帖子和评论添加了带有适当权限检查的删除功能
  • 在模板中使用 @can 标签,仅向已授权用户有条件地显示操作按钮

这种方法的主要好处是你的授权逻辑可复用且易于维护。当你需要更改某条权限规则时,你只需在一个地方(策略中)更新它,它就会自动应用到所有使用该策略的地方(控制器、模板以及应用中的任何其他位置)。