授权
在上一章中,我们改善了 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
打开生成的文件并添加针对编辑和删除帖子的权限检查。
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 相匹配。
你可能会注意到,edit 和 delete 现在的逻辑完全相同。即便如此,将它们分开能给你灵活性。以后你可能会决定帖子在 24 小时后不可编辑,或者管理员可以删除任何帖子但不能编辑它们。拥有独立的方法会让这类改动更容易。
创建 CommentPolicy
现在为评论创建一个策略。
node ace make:policy comment
添加删除权限检查。
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
}
}
完美!现在让我们让这些策略发挥作用。
添加编辑功能
-
创建更新验证器
我们将为更新帖子添加一个验证器。由于我们已经有一个用于创建帖子的
validators/post.ts文件,我们也会把更新验证器加在那里。一个验证器文件可以导出多个验证器(这样能将相关的验证逻辑组织在一起)。打开你已有的帖子验证器文件并添加更新验证器。
app/validators/post.tsimport 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,需求是相同的。 -
添加控制器方法
我们将添加两个控制器方法:
edit用于显示编辑表单,update用于处理表单提交。这两个方法都会在执行任何操作之前,使用 Bouncer 检查当前用户是否被允许修改该帖子。app/controllers/posts_controller.tsimport 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 请求。在执行敏感操作之前,务必验证权限。 - 调用我们
-
理解闪存消息
你会注意到
update方法中的session.flash('success', 'Post updated successfully')。这是我们在 DevShow 中首次使用闪存消息(flash messages),所以让我们理解一下它们的作用。闪存消息 是临时存储在会话中的消息。它们仅在下一个请求中可用,之后会自动移除。这使得它们非常适合在表单提交后显示成功或错误消息。
你无需添加任何代码来显示这些消息——入门套件的布局已经包含一个会自动渲染闪存消息的组件。当设置了闪存消息时,它会在下一个请求中作为页面顶部的通知出现。
在本章的其余部分,每当我们要确认某个操作(如更新或删除)成功时,都会使用闪存消息。
-
注册路由
现在让我们为编辑帖子注册路由。打开你的路由文件。
start/routes.tsimport 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())我们需要两条路由(一条显示编辑表单,另一条处理表单提交)。两者都需要身份验证。
-
创建编辑表单模板
创建编辑表单模板。
node ace make:view posts/edit现在添加表单标记。
resources/views/posts/edit.edge@layout() <div class="form-container"> @!link({ route: 'posts.show', routeParams: post, text: '‹ 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.title、value: post.url、text: post.summary),这样用户能看到他们正在编辑的内容 - 路由:提交到
posts.update路由,帖子 ID 包含在 URL 中
- HTTP 方法:使用
-
在帖子详情页添加编辑按钮
现在在帖子详情页添加"编辑"按钮。打开你的
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 错误。
- 第一个参数(
访问你创建的帖子,你会看到"编辑"按钮。点击它并尝试更新你的帖子!
添加删除功能
-
添加控制器方法
删除帖子比编辑更简单,因为没有需要显示的表单(只是一个提交 DELETE 请求的按钮)。让我们添加处理删除的控制器方法。
app/controllers/posts_controller.tsimport 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') } }这个模式现在你已经熟悉了:找到帖子,使用策略授权操作,执行删除,闪存成功消息,然后重定向。删除帖子后,我们重定向到帖子索引页,因为帖子详情页已不存在。
-
注册路由
现在注册删除路由。
start/routes.tsimport 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()) -
在帖子详情页添加删除按钮
在帖子详情模板中的"编辑"按钮旁添加一个"删除"按钮。
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)标签确保只有帖子所有者才能看到该按钮
试一试!访问你创建的帖子,你会看到"编辑"和"删除"两个按钮。访问他人创建的帖子,则不会出现任何按钮。
- DELETE 方法 - 我们在表单中使用了
添加评论删除
-
添加控制器方法
让我们添加用于删除评论的控制器方法。
app/controllers/comments_controller.tsimport 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 错误 - 从数据库删除评论
- 重定向回 显示该评论的帖子详情页
-
注册路由
现在为评论注册删除路由。
start/routes.tsimport 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()) -
为评论添加删除按钮
最后,在帖子详情模板的评论列表中添加删除按钮。
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 添加了授权。以下是你达成的成果:
- 创建了
PostPolicy和CommentPolicy,将所有权限逻辑集中到一处 - 在控制器中使用
bouncer.with(Policy).authorize(),在允许操作前强制执行权限 - 实现了包含表单、验证与授权的完整帖子编辑功能
- 为帖子和评论添加了带有适当权限检查的删除功能
- 在模板中使用
@can标签,仅向已授权用户有条件地显示操作按钮
这种方法的主要好处是你的授权逻辑可复用且易于维护。当你需要更改某条权限规则时,你只需在一个地方(策略中)更新它,它就会自动应用到所有使用该策略的地方(控制器、模板以及应用中的任何其他位置)。