构建白标 SaaS 最难的部分不是 AI、自定义域名或账单。而是一个登录表单。
我开发了 VoiceDash,这是一个白标平台,让代理机构以自己的品牌向客户转售 AI 语音代理。表面上这是一个语音产品。实际上,让我彻夜难眠的是身份验证,因为白标产品面临大多数应用从未遇到过的结构性问题:一个登录表单必须服务于两种完全不同的人,而且任何一方都永远不允许成为另一方。
这是我如何用单一身份验证提供程序和一个小的鉴别字段解决该问题,以及差点毁掉整个项目的边缘运行时问题的故事。
两个绝不能重叠的用户
层次结构如下。一个代理机构注册。该代理机构是一个 Workspace。在 workspace 内,代理机构自己的员工是 WorkspaceMembers。代理机构随后创建 Clients,每个 Client 有自己的 ClientMembers,即实际登录到看起来像是代理机构自己构建的门户网站的最终用户。
因此,有两种人会点击登录屏幕:
- 代理机构用户。我的付费客户。他们使用电子邮件和密码登录,并管理一切:客户、代理、账单、品牌。
- 客户成员。我的客户的客户。他们登录到一个带有 workspace 品牌的门户网站,他们只能看到自己客户的数据,并且不知道 VoiceDash 的存在。
比任何事情都重要的不变性:客户成员绝不能访问代理机构路由,也绝不能看到另一个客户的数据。如果这一点被破坏,一个代理机构的客户可以读取另一个代理机构的客户。那不是错误,那是业务的终结。
诱人的做法是构建两个身份验证系统。两个提供程序、两个会话形状、两套中间件、两个所有东西。我不想维护两个所有东西。所以我构建了一个。
一个提供程序,一个鉴别器
VoiceDash 运行在 NextAuth 上,使用单个 CredentialsProvider。诀窍是凭据上的 type 字段,可以是 "agency" 或 "client",而 authorize 函数会根据它进行分支:
async authorize(credentials) {
if (!credentials) return null;
const type = (credentials.type as string) || "agency";
if (type === "client") {
// client members log in by loginId OR email, password optional
const loginId = credentials.loginId as string;
if (!loginId) return null;
let clientMember = await prisma.clientMember.findUnique({
where: { loginId },
include: { user: true, client: { include: { workspace: true } }, role: true },
});
// ...fall back to email lookup, verify password only if one is set...
return {
id: clientMember.user.id,
clientId: clientMember.clientId,
workspaceId: clientMember.client.workspaceId,
role: clientMember.role?.name || "Member",
type: "client",
};
}
// agency users log in by email + password
const user = await prisma.user.findUnique({ /* ... */ });
// ...bcrypt compare...
return {
id: user.id,
workspaceId: member?.workspaceId || null,
role: member?.role || "MEMBER",
type: "agency",
};
}
Enter fullscreen mode Exit fullscreen mode
这两个分支确实不同。代理机构登录是普通的电子邮件加密码。客户登录接受 loginId 或电子邮件,密码是可选的,因为一些代理机构在没有密码的情况下让客户入驻,并通过登录 ID 让他们进入。两种流程、两种查找、两种返回的用户形状。但是一个提供程序,并且两者都返回一个携带 type 的对象。
这个 type 就是一个字的整个安全模型。
烘焙到令牌中,到处读取它
返回的用户对象本身还不够。它必须存活到未来的每个请求中。VoiceDash 使用 JWT 会话,因此鉴别器在登录时被写入令牌一次,并在每个请求上读回:
// jwt callback: user -> token, on sign in
token.type = (user as any).type;
token.workspaceId = (user as any).workspaceId;
token.clientId = (user as any).clientId;
// session callback: token -> session, on every request
(session as any).type = token.type;
(session as any).workspaceId = token.workspaceId;
(session as any).clientId = token.clientId;
Enter fullscreen mode Exit fullscreen mode
现在,应用程序的每一部分都可以问一个问题,session.type,并确切地知道它正在与谁交谈。数据查询根据令牌中的 workspaceId 或 clientId 进行范围限定,而不是来自客户端在请求中发送的任何内容。最后一部分是整个游戏:租户边界来自签名的令牌,而不是来自客户端可以篡改的 URL 参数或请求正文。
强制执行它的门
如果没有一个真正把人拒之门外的地方,所有这些都只是理论。在这个版本的 Next.js 中,中间件文件是 proxy.ts,它是每个请求都必须通过的单一门:
// agency routes require an agency session
if (pathname.startsWith("/agency")) {
if (!session) return NextResponse.redirect(new URL("/login", req.url));
if ((session as any).type !== "agency") {
return NextResponse.redirect(new URL("/client/agents", req.url));
}
return NextResponse.next();
}
Enter fullscreen mode Exit fullscreen mode
那个 type !== "agency" 检查是负载承载的。登录的客户成员在地址栏中输入 /agency/clients 不会得到 500 或空白页面,他们会被弹回到自己的门户网站。同一文件还拆分了根路径:在应用程序子域上,根目录会将您发送到仪表板或登录页面,而在营销域上,根目录会呈现公共登录页面。一个文件,四个决定,都由同一个令牌驱动。
花了我一个下午的陷阱:边缘无法看到您的数据库
这是我希望有人告诉我的部分。中间件在边缘运行时上运行。边缘运行时无法导入 bcrypt,也无法导入 Prisma。如果您尝试这样做,您的构建不会礼貌地失败,它会在中间件尝试加载的确切时刻失败,这感觉像是身份验证本身已损坏。
解决方案是将配置拆分为两个部分。有一个边缘安全的 auth.config.ts,它仅包含回调、页面、会话策略和一个空的 providers: [] 数组。它不导入任何 Node 内容。然后一个单独的 auth.ts 扩展该配置并添加带有 bcrypt 和 Prisma 的真实 CredentialsProvider,并且该文件仅在这些导入合法的 Node 运行时中运行。
// auth.config.ts, edge safe, imported by proxy.ts
export const authConfig: NextAuthConfig = {
session: { strategy: "jwt" },
providers: [], // real providers added in auth.ts
callbacks: { /* jwt + session, no Node imports */ },
};
Enter fullscreen mode Exit fullscreen mode
中间件导入边缘安全配置,以便它可以在不接触 bcrypt 或数据库的情况下读取和验证令牌。API 路由导入完整的 auth.ts。第一次看到它时感觉像是重复。事实并非如此。它是有意绘制的“可以在边缘运行的代码”与“需要数据库的代码”之间的边界。
在开始之前我会告诉自己什么
鉴别器字段是从一个身份验证系统服务两个受众的最便宜的方法,它也是代码库中最危险的一行。一个缺失的 type 检查是权限提升,而不是表面错误。所以我像对待任何不变性一样对待它:我假设它会在某处被遗忘,而 proxy.ts 中的门正是为了确保在页面组件中遗忘它不会成为漏洞。
如果您正在构建任何客户有客户的东西,这就是要采用的形状。一个提供程序、一个烘焙到令牌中的鉴别器、一个强制执行它的门,以及一个干净的边缘和 Node 拆分,以便您的中间件可以验证身份而无需将数据库拖到边缘。
如果您想查看它所驱动的成品,它位于 voice-dash.com。但上面的身份验证模型是我真正引以为豪的部分,也是花费最长时间才能正确完成的部分。
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.