深入解析基于 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)