深入解析基于 tRPC 的权限控制设计
tRPC 是一种用于在类型安全环境中构建 API 的工具,基于 TypeScript。它与传统的 RESTful API 框架不同,因为它利用 TypeScript 的类型推断来减少前后端 API 的重复定义,达到了一个类型安全、一体化的编程方式。这使得开发者能够以较少的代码完成前后端通信,省去了 API 类型声明的一些繁琐步骤,并且在编译时就能发现错误,提升了开发效率和代码质量。
为了理解此代码,我们需要对 @trpc/server 和 Zod 这样的库有一定了解。@trpc/server 提供了一种用于构建类型安全后端 API 的方式,而 z 则是一个数据验证库,它确保了传入和传出数据符合设定的结构规范。tRPCError 是 tRPC 中用于处理错误的类型。
代码导入部分及基本类型定义
代码从 @trpc/server 和 zod 库中分别导入了 initTRPC 和 TRPCError,并用 z 来进行后续数据验证。以下是这段代码的导入部分和类型定义的初步解析。
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
上述导入的内容分别为:
initTRPC:用来初始化 tRPC,这样后续才能创建带有类型安全的 API 端点。TRPCError:用来抛出 tRPC 中的标准错误,以便在不同的地方进行错误处理。z:数据验证库,用于确保 API 请求的数据符合预期。
在导入部分之后,代码定义了一些基本类型,这些类型描述了我们在该代码中需要处理的业务实体和上下文。
type Organization = {
id: string;
name: string;
};
type Membership = {
role: 'ADMIN' | 'MEMBER';
Organization: Organization;
};
type User = {
id: string;
memberships: Membership[];
};
type Context = {
/**
* User is nullable
*/
user: User | null;
};
这些类型在代码中起到了基础定义的作用:
- Organization 类型:包含
id和name,代表一个组织实体,分别用字符串描述。 - Membership 类型:描述用户在组织中的角色,其中
role字段限制为ADMIN或MEMBER,同时通过Organization属性描述其所属于的组织。 - User 类型:包含一个
id和一个memberships数组,memberships数组中包含用户属于的多个组织及其角色。 - Context 类型:在 tRPC 中,
Context表示 API 端点执行时的上下文。这里的上下文包括一个可为空的user对象。
初始化 tRPC
接下来,代码调用了 initTRPC 来进行 tRPC 初始化。
const t = initTRPC.context<Context>().create();
通过调用 initTRPC.context<Context>().create(),我们做了以下几件事情:
- 上下文类型的指定:
context<Context>()确定了在此 tRPC 实例中,我们使用的上下文类型是之前定义的Context,其中的user字段是可以为空的。 - tRPC 的创建:
.create()方法创建了 tRPC 实例,用于构建 API 端点和中间件。
这段代码生成了一个 t 对象,后续的操作都围绕着该对象进行。
公开的程序定义 publicProcedure
在 tRPC 中,procedure 是代表一个 API 端点的操作步骤,可以理解为一个函数的定义,通过它来完成某些特定的任务。
export const publicProcedure = t.procedure;
这里定义了一个 publicProcedure,它基于 t.procedure。这个过程没有任何权限限制,意味着任何用户(无论是否登录)都可以访问该程序。它定义了一种公开的接口,类似于 RESTful API 中的“公共端点”,用于暴露一些通用功能。
认证用户的程序 authedProcedure
export const authedProcedure = t.procedure.use(async function isAuthed(opts) {
const { ctx } = opts;
// `ctx.user` is nullable
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
return opts.next({
ctx: {
// ✅ user value is known to be non-null now
user: ctx.user,
},
});
});
这个部分的代码相对更复杂,我们来分步骤进行拆解。
中间件的概念
中间件在 API 的调用过程中相当于一个过滤器,它可以在请求进入某个处理程序之前或之后做一些额外的操作。这里定义的 isAuthed 就是一个典型的中间件,用于对用户进行身份验证。
使用中间件定义 authedProcedure
t.procedure.use() 允许我们为过程附加一些中间件。这样,我们就可以在一个 API 请求到达处理程序之前对请求进行处理,例如,验证用户是否具有访问权限。
isAuthed 函数
isAuthed 函数被传递给 use(),它接收一个 opts 对象作为参数。这个 opts 对象包含了当前的上下文 ctx。对于 tRPC 来说,这个上下文是每次 API 调用都会提供的,所以我们可以在 ctx 中找到用户的相关信息。
代码中的逻辑如下:
- 通过
const { ctx } = opts;,我们提取出了opts中的上下文对象ctx。 - 接着,代码检查
ctx.user是否为空。因为Context类型中user被定义为可能为空 (User | null),因此在进行下一步操作之前必须检查用户是否存在。 - 如果
ctx.user为null,则抛出一个TRPCError,错误代码为'UNAUTHORIZED',这意味着用户未被授权访问此 API 端点。
错误处理机制
throw new TRPCError({ code: 'UNAUTHORIZED' });
此处代码通过 throw new TRPCError() 抛出一个 tRPC 特有的错误,并指定错误代码为 'UNAUTHORIZED'。这种错误机制在 tRPC 中用于明确标记不同类型的错误,从而方便客户端进行捕获和处理。在这里,错误代码 UNAUTHORIZED 用于告知用户没有适当的身份来访问该资源。
成功通过认证后的操作
如果用户通过认证,代码执行以下逻辑:
return opts.next({
ctx: {
// ✅ user value is known to be non-null now
user: ctx.user,
},
});
在这里,opts.next() 表示通过当前中间件的验证后继续往下执行下一个操作。
- 通过给
ctx传递一个更新后的对象(这里的user已经不再可能是null),后续的处理过程可以安全地假定user不会为空,从而减少对null值的检查。
publicProcedure 和 authedProcedure 的应用场景
基于上述内容,我们可以看出,publicProcedure 和 authedProcedure 代表了两种不同类型的 API 端点:
publicProcedure:适用于无需用户身份认证的公开资源。例如,获取系统公告、注册等操作。authedProcedure:用于需要用户身份认证的资源访问,例如用户个人资料、组织内的管理功能等。
下面我们来看一个具体的例子,以便更好理解这两者的区别和使用场景。
示例一:公开资源的获取
假设我们有一个公开的 API 端点用于获取系统的公告信息,可以这样定义:
export const getAnnouncements = publicProcedure.query(() => {
// 公开的系统公告信息
return [
{ id: '1', message: '系统将在明天进行维护' },
{ id: '2', message: '新功能上线,请查收' },
];
});
这里的 getAnnouncements 使用了 publicProcedure,任何人都可以访问这个接口以获取公告信息,不需要登录。
示例二:需要认证的用户资料获取
再来看一个用户个人资料的获取,假设我们只允许登录用户访问自己的资料:
export const getUserProfile = authedProcedure.query(({ ctx }) => {
// 因为 authedProcedure 保证了 user 不会为空
const user = ctx.user;
return {
id: user.id,
memberships: user.memberships,
};
});
在这个例子中,getUserProfile 使用了 authedProcedure,通过中间件确保只有登录用户可以访问自己的资料。在执行查询函数时,ctx.user 已经被保证为不为空,因此可以安全地访问用户的相关信息而无需额外的空值检查。
代码整体的逻辑分析
这段代码的核心目标在于建立一套权限控制体系,其中:
Context用于描述请求上下文的结构,其中的user可以为空,用于表示当前请求的用户可能未登录。t是通过initTRPC初始化得到的 tRPC 实例,后续的所有程序定义、权限控制和业务逻辑都围绕它展开。publicProcedure和authedProcedure则分别用于定义无需登录和需要登录的 API 端点,这样可以在设计接口时保证不同端点的安全性。
中间件的设计与实现
中间件在现代 Web 编程中是一种常见的设计模式,用于在请求到达处理函数之前执行一些共享的逻辑,比如日志记录、身份验证、输入数据的规范化等。isAuthed 中间件通过 use() 方法附加到 authedProcedure 上,使得每次请求该过程时都会首先经过身份验证的逻辑,从而确保安全性。
在这里,我们通过 ctx.user 的空值检查来判断用户是否登录,并基于结果来决定是否继续请求处理。这样一种方式确保了代码的健壮性,同时也简化了后续的逻辑处理。每当访问需要权限的 API 时,开发人员无需再去考虑用户是否已认证,因为中间件已经帮助完成了这一部分的工作。
代码潜在的优化与扩展
这段代码虽然在现有的场景中已经实现了基本的认证机制,但我们还可以做一些扩展以应对更加复杂的需求。
扩展中间件进行角色验证
目前的中间件只验证了用户是否登录,如果我们还需要进一步验证用户在组织中的角色(如 ADMIN 或 MEMBER),可以扩展 isAuthed 中间件,检查用户的 memberships 中是否包含特定组织的管理员角色。下面的代码示例展示了如何做到这一点:
const isAdmin = t.middleware(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: 'UNAUTHORIZED' });
}
const isAdmin = ctx.user.memberships.some(
(membership) => membership.role === 'ADMIN'
);
if (!isAdmin) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
return next({
ctx: {
user: ctx.user,
},
});
});
// 使用角色验证中间件定义新的过程
export const adminProcedure = t.procedure.use(isAdmin);
在这个例子中,isAdmin 中间件不仅验证了用户是否登录,还验证了用户是否具有管理员权限。如果没有管理员权限,则抛出 FORBIDDEN 错误。
细粒度的权限控制
在实际的开发中,不同的接口可能需要不同的权限。为了避免每次都重复编写身份验证逻辑,我们可以创建多个中间件,比如只需要登录验证的 isAuthed,需要管理员权限的 isAdmin,甚至还可以定义更细粒度的权限,例如只能查看自己组织内数据的验证逻辑。
结论
通过对这段代码的详细解析,我们可以看到它实现了一个基础的权限控制机制,以确保不同的 API 端点根据用户的身份和角色来决定是否可以被访问。代码中的每一个类型和函数设计都有其特定的用意,从用户类型的定义到上下文的构建,再到具体过程的权限控制,体现了较好的设计模式和代码复用性。
在这套体系中,公开的端点和受限的端点分别通过 publicProcedure 和 authedProcedure 来定义,通过 tRPC 的中间件机制,将身份认证逻辑进行了良好的封装,从而减少了代码重复,并增强了系统的安全性和可维护性。扩展这段代码,也可以在实现更复杂的权限管理、细化不同用户的访问权限等方面做进一步的优化。
- 点赞
- 收藏
- 关注作者
评论(0)