ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redwood 实战:在 API 侧访问 currentUser,实现博客多作者与数据所有权隔离

Redwood 实战:在 API 侧访问 currentUser,实现博客多作者与数据所有权隔离 后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载导读本篇文章基于 Redwood 教程第 7 章讲解如何在 Redwood 的 Service 层通过context.currentUser获取当前登录用户并据此实现「文章与作者关联」「作者姓名展示」「管理后台仅显示本人文章」「更新/删除前的所有权校验」等一整套多作者场景下的数据隔离方案。读完本文你将掌握在 Prisma schema 中建立 User 与 Post 的一对多关系、编写 GraphQL relation resolver、利用skipAuth/requireAuth指令拆分公共接口与管理接口、通过服务组合Service Composition统一校验资源所有权以及context在 Redwood 框架中的底层实现原理。场景引入从单作者博客到多作者平台假设我们的博客已经成长为拥有多名作者的平台有人负责写技术稿有人负责写运营稿而作为开发者的我们已经忙得没时间亲自写文章了。此时需要改动三件事把文章和作者关联起来让每篇文章都能署名在阅读文章时展示作者姓名限制作者只能编辑自己的文章——Alice 不能篡改 Bob 的文章。这三个需求分别对应「数据模型关系」「GraphQL 查询与展示」「基于登录用户的权限控制」而贯穿其中的关键 API 就是 Redwood 的context.currentUser。一、建立 Post 与 User 的关联外键在 Redwood 中数据模型由 Prisma Schema 定义。我们引入Post与User之间的一对多关系一个User拥有多篇Post这与教程前面章节中Post与Comment的关系类似┌─────────────────────┐ ┌───────────┐ │ User │ │ Post │ ├─────────────────────┤ ├───────────┤ │ id │───┐ │ id │ │ name │ │ │ title │ │ email │ │ │ body │ │ hashedPassword │ └──│ userId │ │ ... │ │ createdAt │ └─────────────────────┘ └───────────┘这类数据变更操作会逐渐成为 Redwood 开发的日常标准三步流程为在schema.prisma中添加新关系迁移数据库生成/更新 SDL 与 Service。1.1 修改 Prisma Schema在api/db/schema.prisma中为Post增加userId字段与指向User的关系并在User侧补充反向关系postsmodel Post { id Int id default(autoincrement()) title String body String comments Comment[] user User relation(fields: [userId], references: [id]) userId Int createdAt DateTime default(now()) } model User { id Int id default(autoincrement()) name String? email String unique hashedPassword String salt String resetToken String? resetTokenExpiresAt DateTime? roles String default(moderator) posts Post[] }relation(fields: [userId], references: [id])表示Post.userId是外键引用User.idUser.posts是反向的列表字段。注意这里userId是必填字段这是后续迁移会遇到坑的根源。关于 User 的 SDL在第 4 章配置认证时setup auth dbAuth命令已经在api/src/lib/和api/src/functions/中生成了直接使用 PrismaClient 操作 User 模型的认证文件所以当时不需要为 User 单独建 SDL。但要让 GraphQL 能返回User类型例如Post.user还需要为 User 生成 SDL 与 Service可执行yarn rw g sdl User --no-crud并建议注释掉hashedPassword、salt、resetToken、resetTokenExpiresAt等敏感字段防止它们通过 GraphQL 泄露。教程第 7 章 RBAC 一节docs/versioned_docs/version-3.x/tutorial/chapter7/rbac.md与此紧密相关。1.2 迁移数据库必填字段带来的坑执行迁移命令yarn rw prisma migrate dev会立刻报错——这与之前给User添加roles字段时遇到的情况一致userId是必填字段但开发数据库中已经存在若干条Post记录且 schema 中又没有为userId提供默认值数据库无法在已有记录上补上这个非空列。为什么不能偷懒写default(1)这个快速方案能绕过当前报错但会在未来埋下难以排查的 bug一旦忘记给post关联userPrisma 不会报错而是默默地把userId设为1——而 id 为 1 的用户可能根本不存在。正确做法是花点时间按规范处理避免这些隐患。既然处于开发阶段最直接的办法是清空数据库重新开始yarn rw prisma migrate reset数据库种子Seed的连锁问题如果你是基于 Redwood 教程仓库开始的后半程开发重置数据库后种子脚本会报错——Prisma 会用一名用户和若干篇文章来填充数据库但这些种子文章没有新增的必填字段userId。需要打开scripts/seed.js为每篇文章补充userId: 1{ id: 1, name: John Doe, title: Welcome to the blog!, body: ..., userId: 1, },再次执行yarn rw prisma migrate reset会得到另一个错误因为reset并不会自动应用尚未执行的迁移种子尝试写入一个数据库里还不存在的userId列虽然 Prisma 生成的客户端已经包含该字段所以它认为应该存在。此时数据库实际已经重置成功只是种子失败。接下来依次执行yarn rw prisma migrate dev迁移命名如 add userId to post和yarn rw prisma db seed重新灌入种子数据即可。如果你的代码库不是从教程仓库而来重置后数据库是空的先访问http://localhost:8910/signup注册一个用户先不要创建任何文章然后把该用户的角色改为admin——既可以用上一节RBAC提到的 console 方式也可以打开 Prisma Studio 直接在数据库里修改。1.3 修改 SDL 与 Service现在思考新关系在哪里展示。目前只需要在首页和文章详情页展示作者因此 GraphQL 查询应能这样访问用户post { id title body createdAt user { name } }为此需要在 API 侧做两处修改在postsSDL 中加入user字段在postsService 中为user编写关系解析器relation resolver。给 Posts SDL 增加 user 字段type Post { id: Int! title: String! body: String! createdAt: DateTime! user: User! }注意这里用的是User!带感叹号因为每篇Post都必然关联一个用户该字段永远不会为null。为什么不动 Mutation我们故意不把user或userId加进CreatePostInput/UpdatePostInput。虽然创建文章时需要为它指定作者但不希望任何人都能通过 GraphQL 调用直接指定——否则可以轻松篡改请求载荷把文章挂到别人名下。作者分配逻辑只放在 Service 内部对外部世界不可操纵。添加 User 的关系解析器在api/src/services/posts/posts.js中Service 函数之外再导出一个与模型同名Post的对象其键名与需要被查询的字段user一致import { db } from src/lib/db export const posts () { return db.post.findMany() } export const post ({ id }) { return db.post.findUnique({ where: { id }, }) } export const createPost ({ input }) { return db.post.create({ data: input, }) } export const updatePost ({ id, input }) { return db.post.update({ data: input, where: { id }, }) } export const deletePost ({ id }) { return db.post.delete({ where: { id }, }) } export const Post { user: (_obj, { root }) db.post.findFirst({ where: { id: root.id } }).user(), }一步步拆解这段看似反直觉的代码声明一个与服务对应的、与模型同名的变量Post把它设为对象键名是需要被查询的字段名这里是userGraphQL 调用该函数时会传入若干参数其中root就是当前已解析出的父对象——在本例中即 GraphQL 查询里的postpost { - root id title body createdAt user { name } }这个post已经从数据库取出我们能拿到它的id于是调用root.id即可。接着用 Prisma 的findFirst()按这个 id 查到记录再链式调用.user()返回与它关联的用户记录而不是 post 本身。这段解析器也可以等价地写成export const Post { user: (_obj, { root }) db.user.findFirst({ where: { id: root.userId } }), }一个值得注意的 GraphQL 特性即使保留上述关系解析器同时在posts/post返回的结果里也带上了user属性字段解析器依然会被调用其返回值会覆盖已有属性。原因在于 GraphQL 的机制——只要某命名字段存在解析器它就会被执行并用返回值作为结果哪怕root上已经携带了该数据。Prisma 与 N1 问题熟悉数据库查询的同学可能已经发现这种方式并不完美每查到一篇 post都要额外执行一次查询去取关联的 user 数据——这正是 GraphQL 中典型的 N1 问题。其根源在于 GraphQL 查询的天然结构每个解析器只知道自己父对象的信息对潜在的子对象一无所知。一个无需引入额外依赖的简单替代方案是移除该字段解析器改为在查询 post 时直接include用户数据export const post ({ id }) { return db.post.findUnique({ where: { id }, include: { user: true, }, }) }但这种方式有利有弊无论 GraphQL 查询是否请求了 user 数据都会产生额外的数据加载开销更重要的是它破坏了更深层的嵌套查询。例如想同时返回这篇文章的用户、以及该用户创作的所有其他文章 idpost { id title body createdAt user { name posts { id } } }该查询会失败因为返回结果里只有post.user没有post.user.posts。Redwood 团队一直在探索更优雅的 N1 解决方案。二、在页面上展示作者要拿到作者信息需要更新 Cell 的查询在首页与文章详情页两处公开展示文章的地方把作者的name拉出来export const QUERY gql query ArticlesQuery { articles: posts { id title body createdAt user { name } } } export const QUERY gql query ArticleQuery($id: Int!) { article: post(id: $id) { id title body createdAt user { name } } } 再更新展示 Article 的组件在标题旁渲染 by 作者名import { Link, routes } from redwoodjs/router const Article ({ article }) { return ( article header h2 classNametext-xl text-blue-700 font-semibold Link to{routes.article({ id: article.id })}{article.title}/Link span classNameml-2 text-gray-400 font-normal by {article.user.name} /span /h2 /header div classNamemt-2 text-gray-900 font-light{article.body}/div /article ) } export default Article在真正通过 scaffold 管理后台创建文章之前还需要先解决创建时如何把作者关联到文章的问题。还记得我们刻意不允许通过 GraphQL 设置userId吗scaffold 正是通过 GraphQL 创建/编辑记录的所以这条路走不通——但没关系我们本来就希望作者分配只发生在 Service 内部这正是下一节要做的。三、在 API 侧访问 currentUserRedwood 中有一个「魔法变量」context它在任何 Service 函数内部都可用封装了该次 Service 调用所处的上下文。其中一个属性就是当前登录用户如果确实有人登录的话——它与 Web 侧可用的currentUser是同一个对象export const createPost ({ input }) { return db.post.create({ data: { ...input, userId: context.currentUser.id } }) }context.currentUser在需要访问发起这次请求的用户时始终可用。这里我们取用户的id把它与 scaffold 表单传来的其余数据合并作为新文章的userId。3.1 context 的底层实现异步存储代理context之所以「魔法」是因为它并非一个普通全局对象。在 Redwood 框架源码 packages/context/src/context.ts 中可以看到其实现export const createContextProxy (target: GlobalContext) { return new ProxyGlobalContext(target, { get: (_target, property: string) { const store getAsyncStoreInstance().getStore() const ctx store?.get(context) || {} return ctx[property] }, set: (_target, property: string, newVal) { const store getAsyncStoreInstance().getStore() const ctx store?.get(context) || {} ctx[property] newVal store?.set(context, ctx) return true }, }) } export let context: GlobalContext createContextProxy({}) export const setContext (newContext: GlobalContext): GlobalContext { context createContextProxy(newContext) const store getAsyncStoreInstance().getStore() store?.set(context, newContext) return context }context是基于AsyncLocalStorage的 Proxy 对象每次请求进入时框架把包含currentUser的上下文写入异步存储setContextService 里读取context.currentUser时Proxy 的get会从当前异步上下文中取真实值。这保证了并发请求之间上下文互不串扰——这正是context.currentUser在每次请求中始终正确的根本原因。在 packages/context/src/store.ts 中维护着这个 AsyncLocalStorage 实例。同时context是可扩展的除了currentUser你还可以往里面挂任何请求级数据例如context.magicNumber 1。3.2 currentUser 从哪来Web 侧的currentUser与 API 侧的context.currentUser内容一致其来源是 API 侧api/src/lib/auth.ts中的getCurrentUser()函数由认证框架调用返回值会作为currentUser注入上下文。测试夹具 packages/graphql-server/src/functions/tests/fixtures/auth.ts 展示了典型的实现与校验逻辑// 用户已认证 ⇔ context 中存在 currentUser export const isAuthenticated () { return !!context.currentUser } // 校验 currentUser 是否被授予了某个角色 export const hasRole ({ roles }) { const currentUserRoles context.currentUser?.roles if (typeof currentUserRoles string) { return currentUserRoles roles } // ... }完成上述改造后通过管理后台创建文章应该能正常工作了回到首页即可看到文章与作者的署名。四、管理后台只显示自己的文章目前任何 admin 访问/admin/posts都能看到所有文章。既然知道了context.currentUser的存在我们可以在postsservice 中铺开使用它把返回结果限定为当前登录用户拥有的文章import { db } from src/lib/db export const posts () { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const post ({ id }) { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost ({ input }) { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost ({ id, input }) { return db.post.update({ data: input, where: { id }, }) } export const deletePost ({ id }) { return db.post.delete({ where: { id }, }) } export const Post { user: (_obj, { root }) db.post.findFirst({ where: { id: root.id } }).user(), }4.1 findUnique() 与 findFirst() 的选择注意这里把findUnique()换成了findFirst()。Prisma 的findUnique()要求where子句中的字段具有唯一索引——id满足但userId不满足。findFirst()则允许在where里放任意条件可能返回多条记录但 Prisma 只取第一条。这里因为同时按id和userId筛选结果必然唯一所以用findFirst()是安全的。这些改动保证了用户只能看到自己的文章列表、或自己拥有的单篇文章详情。4.2 新问题的浮现但还有两个隐患updatePost和deletePost仍未受currentUser限制——任何人只要手工构造 GraphQL 调用就能更新或删除任意文章首页也使用postsservice 展示全部文章——上述改动会让首页只显示当前登录用户自己的文章而未登录用户访问首页时context.currentUser不存在直接报错。如何让管理后台返回一种列表首页返回另一种列表这是下一节要解决的核心问题。五、拆分出 AdminPosts Service一个直观的思路是在 GraphQL 查询里加变量并在现有postsservice 中做分支判断来区分首页/后台场景。但这种方式会显著增加测试面和脆弱性后人很容易在加新条件或取反既有条件时不小心把后台能力暴露给外部攻击者。更好的方案是为后台视图创建全新的 GraphQL 查询借助requireAuth自动获得安全检查无需任何自定义代码。具体步骤创建定义类型的新adminPostsSDL创建新的adminPostsservice更新后台文章页面的 GraphQL 查询改从adminPosts取数。5.1 创建 adminPosts SDL保留现有posts.sdl.js作为公共接口复制一份命名为adminPosts.sdl.js并修改export const schema gql type Query { adminPosts: [Post!]! requireAuth(roles: [admin]) adminPost(id: Int!): Post requireAuth(roles: [admin]) } input CreatePostInput { title: String! body: String! } input UpdatePostInput { title: String body: String } type Mutation { createPost(input: CreatePostInput!): Post! requireAuth(roles: [admin]) updatePost(id: Int!, input: UpdatePostInput!): Post! requireAuth(roles: [admin]) deletePost(id: Int!): Post! requireAuth(roles: [admin]) } export const schema gql type Post { id: Int! title: String! body: String! createdAt: DateTime! user: User! } type Query { posts: [Post!]! skipAuth post(id: Int!): Post skipAuth } 要点说明两个 SDL 共享同一个Post类型内部数据一致由任一 SDL 返回的都是同一类型从postsSDL 中移除 mutations公共接口不再需要它们把 create/update/delete 三个 mutation 移到新的adminPostsSDL两个查询由posts→adminPosts、post→adminPost改名。全应用中每个 query/mutation 的名字必须唯一adminPosts中的查询改用requireAuth带roles: [admin]即要求 admin 角色取代原来的skipAuth——现在有了专属后台查询可以放心锁定为仅登录用户可访问。5.2 创建 adminPosts Service接下来创建adminPostsservice把 create/update/delete 三个 mutation 迁移过去SDL 文件名必须与 service 名一致import { db } from src/lib/db export const adminPosts () { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const adminPost ({ id }) { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost ({ input }) { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost ({ id, input }) { return db.post.update({ data: input, where: { id }, }) } export const deletePost ({ id }) { return db.post.delete({ where: { id }, }) }同样别忘了findUnique()→findFirst()的改动。同时把posts精简回原来的公共职责import { db } from src/lib/db export const posts () { return db.post.findMany() } export const post ({ id }) { return db.post.findUnique({ where: { id } }) } export const Post { user: (_obj, { root }) db.post.findFirst({ where: { id: root.id } }).user(), }我们移除了postsservice 中的userId过滤恢复为返回全部文章posts或单篇文章post不区分归属。注意这里保留了posts中的关系解析器Post.user而adminPosts中没有由于两个 SDL 的查询与 mutation 返回的都是Post类型需要把关系解析器保留在与原始 SDL 同名的 service 中graphql/posts.sdl.js→services/posts/posts.js。5.3 更新后台的 GraphQL 查询最后更新 scaffold 生成的若干组件让它们使用新的adminPosts/adminPost查询下面只展示改动部分export const QUERY gql query FindPostById($id: Int!) { post: adminPost(id: $id) { id title body createdAt } } export const QUERY gql query FindPostById($id: Int!) { post: adminPost(id: $id) { id title body createdAt } } export const QUERY gql query POSTS { posts: adminPosts { id title body createdAt } } posts: adminPosts这种 GraphQL 别名语法把查询结果重命名为posts这样下方Success组件接收的 prop 名无需任何改动如果不用别名就得把Success组件的入参从posts改成adminPosts。公共视图如ArticleCell、ArticlesCell无需改动它们继续使用原来的posts/post查询及其对应解析器。六、Update 与 Delete 的所有权校验现在解决updatePost和deletePost。为什么不能直接这样写export const updatePost ({ id, input }) { return db.post.update({ data: input, where: { id, userId: context.currentUser.id }, }) }原因与findUnique()类似Prisma 只允许基于唯一索引字段更新记录这里只有id满足。where里必须只用id。那如何验证用户只能更新/删除自己拥有的记录方案是先查询记录确认归属再执行更新import { ForbiddenError } from redwoodjs/graphql-server export const updatePost async ({ id, input }) { if (await adminPost({ id })) { return db.post.update({ data: input, where: { id }, }) } else { throw new ForbiddenError(You dont have access to this post) } }这里复用的是adminPost()这个 service 函数而不是再写一遍数据库查询注意需要async/await先确认文章存在。服务组合service composition正是 Redwood 刻意鼓励的写法service 函数既是 GraphQL 的解析器也是普通的 JavaScript 函数可以在任何需要的地方被调用。其价值在此时体现得淋漓尽致——adminPost()已经内置了仅返回当前登录用户拥有的文章的逻辑任何针对单篇文章的管理操作都经由这段代码、复用同一套校验逻辑。由于deletePost也需要同样的检查把归属校验抽取为独立函数const verifyOwnership async ({ id }) { if (await adminPost({ id })) { return true } else { throw new ForbiddenError(You dont have access to this post) } }最终的adminPostsservice 完整形态import { ForbiddenError } from redwoodjs/graphql-server import { db } from src/lib/db const verifyOwnership async ({ id }) { if (await adminPost({ id })) { return true } else { throw new ForbiddenError(You dont have access to this post) } } export const adminPosts () { return db.post.findMany({ where: { userId: context.currentUser.id } }) } export const adminPost ({ id }) { return db.post.findFirst({ where: { id, userId: context.currentUser.id }, }) } export const createPost ({ input }) { return db.post.create({ data: { ...input, userId: context.currentUser.id }, }) } export const updatePost async ({ id, input }) { await verifyOwnership({ id }) return db.post.update({ data: input, where: { id }, }) } export const deletePost async ({ id }) { await verifyOwnership({ id }) return db.post.delete({ where: { id }, }) }6.1 ForbiddenError 在框架中的实现ForbiddenError由redwoodjs/graphql-server提供定义于 packages/graphql-server/src/errors.tsexport class RedwoodGraphQLError extends GraphQLError { // 默认错误码 REDWOODJS_ERROR } export class ForbiddenError extends RedwoodGraphQLError { constructor(message: string) { super(message, { code: FORBIDDEN }) Object.setPrototypeOf(this, ForbiddenError.prototype) } }它继承自 Apollo 风格的GraphQLError抛出后 GraphQL 响应中会带有FORBIDDEN错误码前端可据此统一处理无权访问的提示。同模块还提供了AuthenticationError错误码UNAUTHENTICATED用于未登录场景。七、验证清单与测试建议改完这些务必逐条验证以下场景这正是 QA 团队最爱做的事未登录用户可以看首页全部文章未登录用户可以查看文章详情未登录用户不能访问/admin/posts未登录用户不能看到评论旁的审核控制已登录 admin 用户可以在首页看到所有文章而非只有自己的已登录 admin 用户可以进入/admin/posts已登录 admin 用户可以创建新文章已登录 admin 用户在/admin/posts中看不到别人的文章已登录 admin 用户默认不能看到评论审核控制除非你在上一页末尾修改过该行为已登录 moderator 用户能看到评论审核控制已登录 moderator 用户不能访问/admin/posts。还可以为新功能补上自动化测试防止将来被意外改动。最快的做法是为新 service 创建adminPosts.scenarios.js和adminPosts.test.js验证只返回给定用户拥有的文章。在测试中可以通过 mockcurrentUser来模拟不同角色/未登录状态——Redwood 测试文档docs/docs/testing.md中介绍了 API 侧的mockCurrentUser。Cell 层也可以补测试但其数据完全依赖 service 的返回因此 service 层覆盖到位后基本可以放心。如果哪里表现不对——有人看到太多或太少内容——请复查所有 GraphQL 查询是否都已更新、所有打开的文件是否都已保存。结语本篇文章完整走过了 Redwood 多作者场景的落地路径从 Prisma 数据模型建立外键关系到 GraphQL SDL/Service 的 relation resolver再到context.currentUser在 API 侧的读取与底层异步存储实现最后通过拆分adminPosts公共/管理双接口与服务组合统一校验所有权。这一整套模式——「公共查询用skipAuth、管理查询用requireAuth(roles: [...])、写操作在 Service 内做归属校验」——可以直接复用到评论、分类、标签等任何需要按用户隔离数据的业务模块中。赞分享后端前端Web框架开发工具【免费下载链接】redwoodRedwoodGraphQL项目地址https://gitcode.com/gh_mirrors/re/redwood点击查看免费下载相关推荐Redwood 教程在 API 侧安全访问 currentUser —— 从数据关联到按作者隔离的后台管理Redwood 教程在 API 侧安全访问 currentUser —— 从数据关联到按作者隔离的后台管理 本篇技术指南以 Redwood 教程第七章为骨架后端前端Web框架开发工具RedwoodJS 在 API 侧访问 currentUser从数据关联到按用户隔离的完整实战RedwoodJS 在 API 侧访问 currentUser从数据关联到按用户隔离的完整实战 本文基于 RedwoodJS 教程第 7 章内容完整讲解如何后端前端Web框架开发工具3步轻松实现VR视频转2D播放的终极指南3步轻松实现VR视频转2D播放的终极指南 想要在普通电脑上观看沉浸式VR视频吗 VR Reversal 为您提供最简单高效的 VR视频转换 方案。这款基于MP后端前端Web框架开发工具创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表