授权
在上一章中,我们改进了 DevShow 的导航与样式。现在让我们为用户添加编辑和删除自己文章与评论的功能。目前,如果我们添加这些功能,任何已登录用户都能修改他人的内容。我们需要添加授权检查来防止这种情况。
概述
为了正确处理权限,我们将使用
AdonisJS 的 Bouncer 包
。Bouncer 允许你将授权逻辑组织成策略(policy)(每个方法代表一项权限检查的class)。例如,PostPolicy 可以有一个 edit 方法,用于检查某用户能否编辑特定文章。
与其将权限检查分散在控制器的各处,不如在策略中定义一次规则,然后在任何地方复用。在本章中,我们将安装 Bouncer、为文章和评论创建策略,并实现带有正确授权的编辑与删除功能。
安装 Bouncer
让我们通过以下命令安装并配置 Bouncer 包。
node ace add @adonisjs/bouncer
运行该命令会先安装包,然后执行以下操作。
- 创建
app/abilities/main.ts文件,你可以在其中定义授权能力(目前我们用不到这个文件,无需担心) - 注册一个中间件,为每个 HTTP 请求初始化 Bouncer
- 使
bouncer对象在HttpContext上可用,方便在控制器中使用
一切就绪!现在让我们创建第一个策略。
创建 PostPolicy
策略是一些 class,其中每个方法代表一项权限检查。让我们为文章创建一个策略。
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 {
/**
* Only the post owner can edit their post
*/
edit(user: User, post: Post) {
return user.id === post.userId
}
/**
* Only the post owner can delete their post
*/
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 {
/**
* Only the comment owner can delete their comment
*/
delete(user: User, comment: Comment) {
return user.id === comment.userId
}
}
完美!现在让我们把这些策略用起来。
添加编辑功能
-
Create the update validator
我们将为更新文章添加一个验证器。由于我们已经有了一个用于创建文章的
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), }) /** * Same validation rules as creating a post */ export const updatePostValidator = vine.create( createPostValidator.schema.clone() )我们克隆了
createPostValidator的 schema 以复用相同的验证规则。这种方式使我们的验证逻辑保持 DRY(Don't Repeat Yourself,不重复自己)。如果你之后需要修改某条规则,只需在一处更新即可。在许多应用中,你可能会希望创建与更新的规则不同,但在 DevShow 中,两者的要求是相同的。 -
Add controller methods
我们将添加两个控制器方法:
edit用于显示编辑表单,update用于处理表单提交。这两个方法在执行任何操作之前,都会使用 Bouncer 检查当前用户是否有权修改该文章。app/controllers/posts_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import PostTransformer from '#transformers/post_transformer' import { createPostValidator, updatePostValidator } from '#validators/post' import PostPolicy from '#policies/post_policy' export default class PostsController { // ... existing methods (index, create, store, show) /** * Show the edit form */ async edit({ bouncer, params, inertia }: HttpContext) { const post = await Post.findOrFail(params.id) // Check if the current user can edit this post await bouncer.with(PostPolicy).authorize('edit', post) return inertia.render('posts/edit', { post: PostTransformer.transform(post), }) } /** * Update the post */ async update({ bouncer, params, request, response, session }: HttpContext) { const post = await Post.findOrFail(params.id) // Check authorization again. Someone could send a PUT request directly await bouncer.with(PostPolicy).authorize('edit', post) // Validate and update the 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')。 Flash 消息 是存储在会话中的临时消息,在下一个请求中可用,随后会自动移除。这非常适合在表单提交后显示成功或错误消息。你无需添加任何代码来显示这些消息——入门套件的布局已经包含了一个会自动渲染 flash 消息的组件。当设置了 flash 消息时,它会在下一个请求中以页面顶部的通知形式出现。
- 调用
-
Update the Post transformer to include authorization
既然我们已经了解如何在控制器中使用策略,现在让我们也在转换器(transformer)中使用它们,将授权标志发送给前端。
这里有一个重要的考量:Bouncer 策略运行在后端环境中,因此无法在 React 代码中直接导入或使用。 你的 React 组件无法访问后端的授权逻辑。
解决方案是在转换器内部预计算用户权限,并将它们作为标志发送给前端。转换器运行在后端,可以访问策略,并能在序列化数据中包含权限检查。
为此,我们将使用一个转换器变体(transformer variant)。变体允许你为同一资源定义多种输出形态。例如,列表视图可能只需要最少的数据,而详情视图(包含权限)则需要详细数据。在 Transformers 文档 中可以了解更多关于变体的内容。
让我们为
PostTransformer添加一个forDetailedView变体:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' import CommentTransformer from '#transformers/comment_transformer' import { inject } from '@adonisjs/core' import { HttpContext } from '@adonisjs/core/http' import PostPolicy from '#policies/post_policy' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)), } } /** * Include authorization data for the post detail view */ @inject() async forDetailedView({ bouncer }: HttpContext) { return { ...this.toObject(), can: { edit: await bouncer.with(PostPolicy).allows('edit', this.resource), delete: await bouncer.with(PostPolicy).allows('delete', this.resource), }, } } }注意我们使用的
bouncer.with(PostPolicy)模式与控制器中相同,但此处用的是.allows()(返回布尔值)而非.authorize()(会抛出错误)。@inject()装饰器使我们能够在转换器中访问 HTTP 上下文。当你的 React 组件收到这份数据时,它拥有了简单的布尔标志(
post.can.edit、post.can.delete),可以用于条件渲染——而无需了解任何授权逻辑本身。现在更新
show方法以使用这个变体:app/controllers/posts_controller.tsasync show({ inertia, params }: HttpContext) { const post = await Post.query() .where('id', params.id) .preload('user') .preload('comments', (query) => { query.preload('user').orderBy('createdAt', 'asc') }) .firstOrFail() return inertia.render('posts/show', { post: PostTransformer.transform(post), }) return inertia.render('posts/show', { post: PostTransformer.transform(post).useVariant('forDetailedView'), }) } -
Register the routes
现在让我们注册用于编辑文章的路由。打开你的路由文件。
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())我们需要两个路由(一个用于显示编辑表单,另一个用于处理表单提交)。两者都需要身份验证。
-
Create the edit form component
创建编辑表单组件。
node ace make:page posts/edit现在添加表单标记。
inertia/pages/posts/edit.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' import { Form, Link } from '@adonisjs/inertia/react' type PageProps = InertiaProps<{ post: Data.Post }> export default function PostsEdit(props: PageProps) { const { post } = props return ( <div className="form-container"> <Link route="posts.show" routeParams={{ id: post.id }}> ‹ Back to post </Link> <h1>Edit Post</h1> <Form route="posts.update" routeParams={{ id: post.id }}> {({ errors }) => ( <> <div> <label htmlFor="title">Post title</label> <input type="text" name="title" id="title" defaultValue={post.title} data-invalid={errors.title ? 'true' : undefined} /> {errors.title && <div>{errors.title}</div>} </div> <div> <label htmlFor="url">URL</label> <input type="url" name="url" id="url" defaultValue={post.url} data-invalid={errors.url ? 'true' : undefined} /> {errors.url && <div>{errors.url}</div>} </div> <div> <label htmlFor="summary">Short summary</label> <textarea name="summary" id="summary" rows={4} defaultValue={post.summary} data-invalid={errors.summary ? 'true' : undefined} /> {errors.summary && <div>{errors.summary}</div>} </div> <div> <button type="submit" className="button"> Update Post </button> </div> </> )} </Form> </div> ) }这个表单与创建表单相似,有几个关键区别:
- 预填值:每个字段都显示当前文章数据(
defaultValue={post.title}等),以便用户看到自己正在编辑的内容 - 路由:通过
routeParams将文章 ID 包含在内,提交到posts.update路由。HTTP 方法(PUT)会根据路由定义自动推断
- 预填值:每个字段都显示当前文章数据(
-
Add edit button to post detail page
现在为文章详情页添加一个 Edit 按钮。打开你的
posts/show.tsx组件。inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' import { Form, Link } from '@adonisjs/inertia/react' type PageProps = InertiaProps<{ post: Data.Post.Variants['forDetailedView'] }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <Link route="posts.index">‹ Go back to posts listing</Link> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> <div className="post-actions"> {post.can?.edit && ( <Link route="posts.edit" routeParams={{ id: post.id }}> Edit post </Link> )} </div> <div className="post-comments"> {/* ... comments ... */} </div> </div> </div> ) }我们使用
post.can?.edit进行条件渲染,使 Edit 按钮只对文章所有者可见。这个can对象来自我们转换器的forDetailedView变体,而该变体使用了与控制器中相同的PostPolicy。非所有者甚至不会在组件中看到 Edit 按钮。如果有人尝试直接访问编辑 URL,他们仍然会从控制器的授权检查中得到 403 错误。
访问你创建的文章,你就会看到 Edit 按钮。点击它并尝试更新你的文章!
添加删除功能
-
Add controller method
删除文章比编辑更简单,因为不需要显示表单(只需一个提交 DELETE 请求的按钮)。让我们添加处理删除的控制器方法。
app/controllers/posts_controller.tsimport type { HttpContext } from '@adonisjs/core/http' import Post from '#models/post' import PostTransformer from '#transformers/post_transformer' import { createPostValidator, updatePostValidator } from '#validators/post' import PostPolicy from '#policies/post_policy' export default class PostsController { // ... existing methods /** * Delete a post */ async destroy({ bouncer, params, response, session }: HttpContext) { const post = await Post.findOrFail(params.id) // Check if the user can delete this post await bouncer.with(PostPolicy).authorize('delete', post) await post.delete() session.flash('success', 'Post deleted successfully') return response.redirect().toRoute('posts.index') } }这种模式现在应该很熟悉了:找到文章,使用策略对操作进行授权,执行删除,flash 一条成功消息,然后重定向。删除文章后,我们重定向到文章列表页,因为文章详情页已经不存在了。
-
Register the route
现在注册删除路由。
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()) -
Add delete button to post detail page
在文章详情组件的 Edit 按钮旁边添加一个删除按钮。
inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' import { Form, Link } from '@adonisjs/inertia/react' type PageProps = InertiaProps<{ post: Data.Post.Variants['forDetailedView'] }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <Link route="posts.index">‹ Go back to posts listing</Link> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta"> <div>By {post.author.fullName}</div> <span>.</span> <div> <a href={post.url} target="_blank" rel="noreferrer"> {post.url} </a> </div> </div> <div className="post-summary">{post.summary}</div> <div className="post-actions"> {post.can?.edit && ( <Link route="posts.edit" routeParams={{ id: post.id }}> Edit post </Link> )} {post.can?.delete && ( <> <span>.</span> <Form route="posts.destroy" routeParams={{ id: post.id }}> {() => ( <button type="submit" className="destructive"> Delete </button> )} </Form> </> )} </div> <div className="post-comments"> {/* ... comments ... */} </div> </div> </div> ) }关于这个删除按钮,有几个要点:
- 授权检查 ——
post.can?.delete检查确保只有文章所有者能看到按钮 - Form 组件 —— 即使是一个简单的删除操作,我们也使用
Form组件来正确处理请求。HTTP 方法会根据路由定义自动推断
试试看!访问你创建的文章,你会看到 Edit 和 Delete 两个按钮。访问其他人创建的文章,则不会显示任何按钮。
- 授权检查 ——
添加评论删除功能
-
Update the Comment transformer to include authorization
与文章类似,我们需要为评论添加授权数据。我们将使用之前学到的相同策略模式。在
CommentTransformer中创建一个变体:app/transformers/comment_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Comment from '#models/comment' import UserTransformer from '#transformers/user_transformer' import { inject } from '@adonisjs/core' import { HttpContext } from '@adonisjs/core/http' import CommentPolicy from '#policies/comment_policy' export default class CommentTransformer extends BaseTransformer<Comment> { toObject() { return { ...this.pick(this.resource, ['id', 'content', 'createdAt']), author: UserTransformer.transform(this.resource.user), } } /** * Include authorization data for comments */ @inject() async withAuthorization({ bouncer }: HttpContext) { return { ...this.toObject(), can: { delete: await bouncer.with(CommentPolicy).allows('delete', this.resource), }, } } } -
Update Post transformer to use comment authorization variant
现在更新
PostTransformer以使用评论授权变体:app/transformers/post_transformer.tsimport { BaseTransformer } from '@adonisjs/core/transformers' import type Post from '#models/post' import UserTransformer from '#transformers/user_transformer' import CommentTransformer from '#transformers/comment_transformer' import { inject } from '@adonisjs/core' import { HttpContext } from '@adonisjs/core/http' import PostPolicy from '#policies/post_policy' export default class PostTransformer extends BaseTransformer<Post> { toObject() { return { ...this.pick(this.resource, ['id', 'title', 'url', 'summary', 'createdAt']), author: UserTransformer.transform(this.resource.user), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)), } } @inject() async forDetailedView({ bouncer }: HttpContext) { return { ...this.toObject(), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)), comments: CommentTransformer.transform(this.whenLoaded(this.resource.comments)) .useVariant('withAuthorization') .depth(2), can: { edit: await bouncer.with(PostPolicy).allows('edit', this.resource), delete: await bouncer.with(PostPolicy).allows('delete', this.resource), }, } } } -
Add controller method
让我们添加用于删除评论的控制器方法。
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 { // ... existing store method /** * Delete a comment */ async destroy({ bouncer, params, response, session }: HttpContext) { const comment = await Comment.findOrFail(params.id) // Load the post so we can redirect back to it await comment.load('post') // Check if the user can delete this comment 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 错误 - 从数据库删除评论
- 重定向回显示该评论的文章详情页
-
Register the route
现在注册评论的删除路由。
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()) -
Add delete button to comments
最后,在文章详情组件的评论列表中添加删除按钮。
inertia/pages/posts/show.tsximport { InertiaProps } from '~/types' import { Data } from '@generated/data' import { Form, Link } from '@adonisjs/inertia/react' type PageProps = InertiaProps<{ post: Data.Post.Variants['forDetailedView'] }> export default function PostsShow(props: PageProps) { const { post } = props return ( <div className="container"> <Link route="posts.index">‹ Go back to posts listing</Link> <div> <h1>{post.title}</h1> </div> <div className="post"> <div className="post-meta">{/* ... */}</div> <div className="post-summary">{post.summary}</div> <div className="post-actions">{/* ... */}</div> <div className="post-comments"> <h2>Comments</h2> <div className="post-comment-form">{/* ... */}</div> {post.comments && post.comments.length > 0 ? ( post.comments.map((comment) => ( <div key={comment.id} className="comment-item"> <p>{comment.content}</p> <div className="comment-meta"> By {comment.author.fullName} on{' '} {comment.createdAt && new Date(comment.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', })} </div> <div className="comment-actions"> {comment.can?.delete && ( <Form route="comments.destroy" routeParams={{ id: comment.id }}> {() => ( <button type="submit" className="destructive"> Delete </button> )} </Form> )} </div> </div> )) ) : ( <p>No comments yet.</p> )} </div> </div> </div> ) }评论删除按钮的工作方式如下:
- 策略检查 ——
comment.can?.delete检查当前用户能否删除每条评论 - 可见性 —— 只有评论的作者才会看到删除按钮
- 操作 —— 按钮向
comments.destroy路由提交 DELETE 请求
访问你创建的带有评论的文章,你会看到自己评论旁边出现删除按钮。尝试查看其他用户的评论,则不会出现删除按钮。
- 策略检查 ——
你构建了什么
你已经成功使用 Bouncer 的策略系统为 DevShow 添加了授权。以下是你完成的成果:
- 创建了
PostPolicy和CommentPolicy,将所有权限逻辑集中到一处 - 在控制器中使用
bouncer.with(Policy).authorize()来在执行操作前强制实施权限 - 学习了转换器变体 —— 同一资源可根据上下文输出多种形态
- 使用
@inject()装饰器在转换器变体中访问 HTTP 上下文 - 使用策略在转换器中预计算用户权限,并将它们作为
can标志发送给前端 - 实现了完整的编辑文章功能(含表单、验证与授权)
- 为文章和评论添加了带有正确权限检查的删除功能
- 在 React 组件中使用条件渲染,使操作按钮仅对有权用户显示
这种方法的核心优势在于:你的授权逻辑完全位于后端,无法被绕过。Bouncer 策略运行在安全的环境中,转换器将预计算好的权限标志发送给 React 组件以做 UI 决策。这在保持安全性的同时,让你的前端保持简洁。