跳到主要内容

构建多租户 SaaS 应用:从设计到实现的完整指南

像 Notion、Slack 或 Figma 这样的应用是如何构建的?这些多租户 SaaS 应用看起来很容易用,但要自己动手做一个?那又是另一回事了。

当我第一次考虑要构建这样一个复杂的系统时,我的脑袋都炸了:

  • 用户需要多种登录选项(邮箱、Google、GitHub)
  • 每个用户可以创建和属于多个组织 (Organizations)
  • 每个组织 (Organization) 内有不同的权限 (Permission) 等级
  • 企业组织 (Organization) 需要特定邮箱域名自动加入
  • 敏感操作需要 MFA
  • 还有更多……

“老板,两周后再聊产品设计吧,我现在已经陷进泥潭了。”

但当我真正开始动手时,我发现其实并没有想象中那么难。

我只用了很少的精力就搭建了一个拥有所有这些功能的系统!

documind-home-page.png

Documind 仪表盘Documind 组织页面

我会向你展示如何从零开始设计和实现这样一个系统——你会惊讶于 2025 年用现代工具和正确的架构方法其实有多简单。

完整源码已在这个 Github 仓库 开源。让我们开始吧!

我们以一个名为 DocuMind 的 AI 文档 SaaS 产品为例。

DocuMind 是一个采用多租户模型设计的 AI 文档 SaaS 产品,支持个人用户、小型企业和大型企业。

该平台为文档管理提供强大的 AI 能力,包括自动摘要生成、关键点提取和组织内智能内容推荐。

SaaS 认证 (Authentication) 和授权 (Authorization) 需要哪些功能?​

首先,让我们回顾一下必要的需求。你需要哪些功能?

多租户架构​

要实现多租户架构,你需要一个名为组织 (Organization) 的实体层。想象一下,有一个用户池,用户可以访问多个工作区。每个组织 (Organization) 代表一个工作区,用户在访问不同工作区(组织)时保持单一身份,并根据分配的角色 (Role) 获得不同权限。

multi-tenant-app-architecture.svg

这是认证 (Authentication) 提供商中广泛使用的功能。身份管理系统中的组织 (Organization) 对应于你的 SaaS 应用的工作区、项目或租户。

organization-examples.png

成员关系​

成员是一个临时概念,用于表示某个身份在组织 (Organization) 中的成员状态。

例如,Sarah 用她的邮箱 sarah@gmail.com 注册了你的应用。她可以属于不同的工作区。如果 Sarah 属于 Workspace A,但不属于 Workspace B,那么她就是 Workspace A 的成员,而不是 Workspace B 的成员。

角色 (Role) 和权限 (Permission) 设计​

在多租户架构中,用户需要拥有带有特定权限 (Permission) 的角色 (Role),以访问其租户资源。 权限 (Permission) 是详细的访问控制,定义了具体的操作,比如 read: order 或 write: order。它们决定了可以对特定资源执行哪些操作。

角色 (Role) 是在多租户环境下分配给成员的一组权限 (Permission)。

你需要定义这些角色 (Role) 和权限 (Permission),然后将角色 (Role) 分配给用户,有时还包括自动化流程。例如:

  1. 加入组织 (Organization) 的用户自动获得 member 角色 (Role)。
  2. 第一个创建工作区的用户自动分配为 admin 角色 (Role)。

注册和登录流程​

确保用户友好且安全的注册和认证 (Authentication) 流程,包括基础的登录和注册选项:

  1. 邮箱和密码登录:传统的邮箱和密码登录方式。
  2. 无密码登录:通过邮箱验证码实现便捷安全的访问。
  3. 账户管理:用户可以在账户中心更新邮箱、密码等信息。
  4. 社交登录:如 Google、GitHub 等快速登录选项。
  5. 多因素认证 (MFA):通过如 Duo 等认证器应用增强安全性。

租户创建与邀请​

在多租户 SaaS 应用中,用户流程的一个关键区别是需要支持租户创建和成员邀请。这个流程需要精心设计和执行,因为它对产品激活和增长起着关键作用。

以下是你需要考虑的典型用户流程:

用户类型入口方式
新账户从登录 / 注册页面进入,创建新租户
已有账户在产品内创建另一个租户
已有账户收到新租户邀请从登录 / 注册页面进入
已有账户收到新租户邀请从邀请邮件进入
新账户收到新租户邀请从登录 / 注册页面进入
新账户收到新租户邀请从邀请邮件进入

这些是几乎每个 SaaS 应用都会遇到的常见场景。你可以参考这些流程来启发你的产品和设计团队,也可以根据需要自定义自己的流程。

新账户创建租户已有用户创建另一个租户
已有用户登录已有用户通过邮件加入
新用户登录新用户通过邮件加入

技术架构与系统设计​

了解了所有产品需求后,让我们进入实现阶段。

定义认证 (Authentication) 策略​

认证 (Authentication) 看起来很吓人。用户需要:

  • 邮箱 & 密码注册 / 登录
  • 一键 Google / Github 登录
  • 忘记密码时重置
  • 企业客户团队统一登录
  • ...

仅仅实现这些基础功能就可能需要数周开发。

但现在,我们完全不用自己造轮子!

现代认证 (Authentication) 提供商(这次我选择 Logto)已经为我们打包好了所有这些功能。认证 (Authentication) 流程非常简单:

从数周开发到 15 分钟集成,Logto 为我们处理了所有复杂流程!集成步骤将在后续实现部分详细介绍。现在我们可以专注于构建 DocuMind 的核心功能!

搭建多租户架构​

组织 (Organization) 系统让用户可以创建和加入多个组织 (Organization)。让我们了解核心关系:

在这个系统中,每个用户可以属于多个组织 (Organization),每个组织 (Organization) 可以有多个成员。

在多租户应用中启用访问控制​

基于角色的访问控制 (RBAC) 对于确保多租户 SaaS 应用的安全性和可扩展性非常重要。

在多租户应用中,权限 (Permission) 和角色 (Role) 的设计通常是统一的,因为它源自产品设计。例如,在多个工作区中,通常会有管理员和成员两种角色 (Role)。Logto 作为认证 (Authentication) 提供商,拥有如下组织级基于角色的访问控制 (RBAC) 设计:

  1. 统一权限 (Permission) 定义:权限 (Permission) 在系统级别定义,并在所有组织 (Organization) 中一致应用,确保权限 (Permission) 管理的可维护性和一致性
  2. 组织模板:通过组织模板预设角色 (Role) 和权限 (Permission) 组合,简化组织 (Organization) 初始化

权限 (Permission) 关系如下:

由于每个用户在每个组织 (Organization) 内都需要自己的角色 (Role),所以角色 (Role) 与组织 (Organization) 的关系必须反映每个用户分配的角色 (Role):

我们已经设计好了组织 (Organization) 系统和访问控制系统,现在可以开始构建我们的产品了!

技术栈​

我选择了一个对初学者友好、易于移植的技术栈:

  1. 前端:React(也可轻松迁移到 Vue / Angular / Svelte)
  2. 后端:Express(简单直观的 API)

为什么要前后端分离?因为这样架构清晰,易于学习,也方便切换技术栈。认证 (Authentication) 提供商以 Logto 为例。

对于后续指南,这里的模式适用于:任何前端、任何后端和任何认证 (Authentication) 系统。

为你的应用添加基础认证 (Authentication) 流程​

这是最简单的一步。我们只需将 Logto 集成到项目中,然后可以在 Logto 控制台根据需求配置用户登录 / 注册方式。

安装 Logto 到你的应用​

首先,登录 Logto Cloud。如果没有账号可以免费注册。创建一个开发租户用于测试。

在租户控制台,点击左侧“应用”按钮。然后选择 React,开始构建我们的应用。

按照页面上的指引操作。你可以在大约 5 分钟内完成 Logto 集成!

以下是我的集成代码:

const config: LogtoConfig = {
endpoint: "<YOUR_LOGTO_ENDPOINT>",
appId: "<YOUR_LOGTO_APP_ID>",
};

function App() {
return (
<LogtoProvider config={config}>
<div className="min-h-screen bg-gradient-to-b from-gray-50 to-gray-100">
<Routes>
{/* 该回调处理来自 Logto 的用户登录重定向 */}
<Route path="/callback" element={<Callback />} />
<Route path="/*" element={<AppContent />} />
</Routes>
</div>
</LogtoProvider>
);
}

function AppContent() {
const { isAuthenticated } = useLogto();

if (!isAuthenticated) {
// 未认证 (Authentication) 用户显示落地页
return <Landing />;
}

// 已认证 (Authentication) 用户显示主应用
return (
<Routes>
{/* 仪表盘展示所有可用组织 (Organization) */}
<Route path="/" element={<Dashboard />} />

{/* 点击仪表盘中的组织 (Organization) 后进入组织页面 */}
<Route path="/:orgId" element={<Organization />} />
</Routes>
);
}

documind-home-page.png

这里有个小技巧:我们的登录页面同时有登录和注册按钮。注册按钮会直接跳转到 Logto 的注册页面。这是通过 Logto 的 first screen 功能实现的。它决定了用户首先看到认证 (Authentication) 流程的哪一步。

当你的产品预期有大量新用户时,可以默认跳转到注册页面。

function LandingPage() {
const { signIn } = useLogto();

return (
<div className="landing-container">
<div className="auth-buttons">
<button
className="sign-in-button"
onClick={() => {
signIn({
redirectUri: '<YOUR_APP_CALLBACK_URL>',
});
}}
>
登录
</button>

<button
className="register-button"
onClick={() => {
signIn({
redirectUri: '<YOUR_APP_CALLBACK_URL>',
firstScreen: 'register',
});
}}
>
注册
</button>
</div>
</div>
);
}

点击登录后,你会进入 Logto 登录页面。登录(或注册)成功后,恭喜!你的应用已经有第一个用户(你自己)了!

当你需要登出用户时,调用 useLogto hook 的 signOut 方法即可。

function SignOutButton() {
const { signOut } = useLogto();

return <button onClick={() => signOut('<YOUR_POST_LOGOUT_REDIRECT_URL>')}>登出</button>;
}

自定义登录和注册方式​

在 Logto 控制台,点击左侧菜单的“登录与账户”。然后点击“注册与登录”标签页。 在这个页面,按照指引配置 Logto 的登录 / 注册方式。

sign-in-experience-settings.png

登录流程会是这样:

Logto 登录页面

启用多因素认证 (MFA)​

在 Logto 中启用 MFA 非常简单。只需在 Logto 控制台点击“多因素认证 (MFA)”按钮,然后在多因素认证 (MFA) 页面启用即可。

mfa-settings.png

MFA 流程如下:

Mfa 验证步骤在认证器应用中扫码

一切都如此简单!我们只用了几分钟就搭建好了复杂的用户认证 (Authentication) 系统!

添加多租户组织 (Organization) 体验​

现在我们有了第一个用户!但这个用户还没有属于任何组织 (Organization),我们也还没有创建任何组织 (Organization)。

Logto 内置支持多租户。你可以在 Logto 中创建任意数量的组织 (Organization)。每个组织 (Organization) 可以有多个成员。

每个用户都可以从 Logto 获取其组织 (Organization) 信息。这就实现了多租户支持。

获取用户的组织 (Organization) 信息​

要从 Logto 获取用户的组织 (Organization) 信息,需要两步:

在 Logto Config 中声明组织 (Organization) 信息访问权限。通过设置合适的 scopes 和 resources 实现。

import { UserScope, ReservedResource } from "@logto/react";
const config: LogtoConfig = {
endpoint: "<YOUR_LOGTO_ENDPOINT>",
appId: "<YOUR_LOGTO_APP_ID>",
scopes: [UserScope.Organizations], // 值: "urn:logto:scope:organizations"
resources: [ReservedResource.Organization], // 值: "urn:logto:resource:organizations"
};

使用 Logto 的 fetchUserInfo 方法获取用户信息,包括组织 (Organization) 数据。

function Dashboard() {
// 获取用户信息
const { fetchUserInfo } = useLogto();
const [organizations, setOrganizations] = useState<OrganizationData[]>([]);
const [loading, setLoading] = useState(false);

useEffect(() => {
const loadOrganizations = async () => {
try {
setLoading(true);
// 获取用户信息
const userInfo = await fetchUserInfo();
// 获取用户的组织 (Organization) 信息
const organizationData = userInfo?.organization_data || [];
setOrganizations(organizationData);
} catch (error) {
console.error('获取组织 (Organization) 失败:', error);
} finally {
setLoading(false);
}
};

loadOrganizations();
}, [fetchUserInfo]);

if (loading) {
return <div>加载中...</div>;
}

if (organizations.length === 0) {
return <div>你还不是任何组织 (Organization) 的成员</div>;
}

return <div>组织 (Organization):{organizations.map(org => org.name).join(', ')}</div>;
}

完成这些步骤后,你需要先登出再重新登录。因为我们修改了请求的 scope 和 resource。

此时你还没有创建任何组织 (Organization),用户也没有加入任何组织 (Organization)。仪表盘会显示“你还没有任何组织 (Organization)”。

dashboard-no-orgs.png

接下来,我们为用户创建一个组织 (Organization) 并将其加入。

多亏了 Logto,我们无需自己构建复杂的组织 (Organization) 关系。只需在 Logto 中创建组织 (Organization) 并添加用户即可。Logto 会为我们处理所有复杂性。创建组织 (Organization) 有两种方式:

  1. 通过 Logto 控制台手动创建组织 (Organization)
  2. 使用 Logto Management API 创建组织 (Organization),尤其是在设计允许用户自助创建组织 (Organization)(工作区)的 SaaS 流程时。

在 Logto 控制台创建组织 (Organization)​

点击 Logto 控制台左侧的“组织 (Organizations)”菜单按钮。创建一个组织 (Organization)。

现在你有了第一个组织 (Organization)。

console-organizations.png

接下来,我们将用户添加到该组织 (Organization)。

进入组织 (Organization) 详情页,切换到成员标签页,点击“+ 添加成员”按钮。从左侧列表选择你的登录用户,点击右下角“添加成员”按钮。现在你已成功将用户添加到该组织 (Organization)。

console-add-member-to-orgs.png

刷新你的 APP 页面,你会看到用户现在属于一个组织 (Organization) 了!

dashboard-has-orgs.png

实现自助式组织 (Organization) 创建体验​

在控制台创建组织 (Organization) 还不够。你的 SaaS 应用需要一个流程,让终端用户可以轻松创建和管理自己的工作区。要实现这个功能,请使用 Logto Management API。

参考 与 Management API 交互 文档,设置与 Logto 的 API 通信。

理解组织 (Organization) 认证 (Authentication) 交互流程​

以组织 (Organization) 创建流程为例。组织 (Organization) 创建过程如下:

该流程有两个关键认证 (Authentication) 要求:

  1. 保护后端服务 API:
    • 前端访问后端服务 API 需要认证 (Authentication)
    • API 接口通过校验用户的 Logto 访问令牌 (Access token) 保护
    • 确保只有认证 (Authentication) 用户才能访问服务
  2. 访问 Logto Management API:
    • 后端服务需要安全地调用 Logto Management API
    • 按照 与 Management API 交互 指南进行设置
    • 使用机器对机器 (Machine-to-machine) 认证 (Authentication) 获取访问凭证

保护你的后端 API​

首先,在后端服务中创建一个用于创建组织 (Organization) 的 API 接口。

app.post('/organizations', async (req, res) => {
// 使用 Logto Management API 实现
// ...
});

我们的后端服务 API 只允许认证 (Authentication) 用户访问。我们需要用 Logto 保护 API,并获取当前用户信息(如用户 ID)。

在 Logto(和 OAuth 2.0)的概念中,后端服务充当资源服务器。用户通过前端携带 Access token 访问 DocuMind 资源服务器,资源服务器校验该令牌,校验通过后返回请求的资源。

我们需要创建一个 API Resource 来代表后端服务。

进入 Logto 控制台。

  1. 点击右侧“API 资源”按钮。
  2. 点击“创建 API 资源”,弹窗中选择 Express。
  3. 填写 API 名称为“DocuMind API”,API 标识符为 "https://api.documind.com"。
  4. 点击创建。

不用担心这个 API 标识符 URL,它只是 Logto 中 API 的唯一标识,与实际后端服务 URL 无关。

你会看到一个关于如何使用 API 资源的教程。你可以按照该教程或下面的步骤操作。

我们创建一个 requireAuth 中间件来保护 POST /organizations 接口。

const { createRemoteJWKSet, jwtVerify } = require('jose');

const getTokenFromHeader = (headers) => {
const { authorization } = headers;
const bearerTokenIdentifier = 'Bearer';

if (!authorization) {
throw new Error('Authorization header missing');
}

if (!authorization.startsWith(bearerTokenIdentifier)) {
throw new Error('Authorization token type not supported');
}

return authorization.slice(bearerTokenIdentifier.length + 1);
};

const requireAuth = (resource) => {
if (!resource) {
throw new Error('Resource parameter is required for authentication');
}

return async (req, res, next) => {
try {
// 提取令牌
const token = getTokenFromHeader(req.headers);

const { payload } = await jwtVerify(
token,
createRemoteJWKSet(new URL(process.env.LOGTO_JWKS_URL)),
{
issuer: process.env.LOGTO_ISSUER,
audience: resource,
}
);

// 将用户信息添加到请求
req.user = {
id: payload.sub,
};

next();
} catch (error) {
console.error('认证 (Authentication) 错误:', error);
res.status(401).json({ error: '未授权' });
}
};
};

module.exports = {
requireAuth,
};

使用该中间件时需要如下环境变量:

  • LOGTO_JWKS_URL
  • LOGTO_ISSUER

在你的 Logto 租户的 OpenID 配置端点获取这些变量。访问 https://<your-tenant-id>.logto.app/oidc/.well-known/openid-configuration,返回的 JSON 中包含所需信息:

{
"jwks_uri": "<https://tenant-id.logto.app/oidc/jwks>",
"issuer": "<https://tenant-id.logto.app/oidc>"
}

现在在 POST /organizations 接口中使用 requireAuth 中间件。

app.post('/organizations', requireAuth('<https://api.documind.com>'), async (req, res) => {
// 处理组织 (Organization) 创建逻辑
// ...
});

这样就保护了 POST /organizations 接口,只有持有有效 Logto 访问令牌 (Access token) 的用户才能访问。

我们现在可以在前端从 Logto 获取令牌。用户可以用该令牌通过后端服务创建组织 (Organization)。中间件也会给我们用户 ID,便于添加用户到组织 (Organization)。

在前端代码中,在 Logto config 的 resources 数组中声明该 API resource 标识符。

const config: LogtoConfig = {
endpoint: "<YOUR_LOGTO_ENDPOINT>",
appId: "<YOUR_LOGTO_APP_ID>",
scopes: [UserScope.Organizations],
resources: [ReservedResource.Organization, "<https://api.documind.com>"], // 新创建的 API resource 标识符
};

和之前一样,更新 Logto config 后用户需要重新登录。

在 Dashboard 中,创建组织 (Organization) 时获取 Logto 访问令牌 (Access token),用该令牌访问后端服务 API。

// 获取 "DocuMind API" 的 access token
const token = await getAccessToken('<https://api.documind.com>');

// 用该 token 访问后端服务 API
const response = await fetch('<http://localhost:3000/organizations>', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({
name: 'Organization A',
description: 'Organization A description',
}),
});

现在我们可以正确访问 DocuMind 后端服务 API 了。

调用 Logto Management API​

让我们用 Logto Management API 实现组织 (Organization) 创建。

和前端请求后端服务一样,后端服务请求 Logto 也需要 Access token。

在 Logto 中,我们用机器对机器 (Machine-to-machine) 认证 (Authentication) 获取 Access token。详见 与 Management API 交互。

进入 Logto 控制台的应用页面,创建一个机器对机器 (Machine-to-machine) 应用。分配“Logto Management API 访问”角色。复制 Token endpoint、App ID 和 App Secret。我们将用这些信息获取 Access token。

m2m-application.png

现在我们可以通过该 M2M 应用获取 Logto Management API 的 Access token。

async function fetchLogtoManagementApiAccessToken() {
const response = await fetch(process.env.LOGTO_MANAGEMENT_API_TOKEN_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${Buffer.from(
`${process.env.LOGTO_MANAGEMENT_API_APPLICATION_ID}:${process.env.LOGTO_MANAGEMENT_API_APPLICATION_SECRET}`
).toString('base64')}`,
},
body: new URLSearchParams({
grant_type: 'client_credentials',
resource: process.env.LOGTO_MANAGEMENT_API_RESOURCE,
scope: 'all',
}).toString(),
});
const data = await response.json();
return data.access_token;
}

用该 access token 调用 Logto Management API。

我们会用到这些 Management API:

app.post('/organizations', requireAuth('<https://api.documind.com>'), async (req, res) => {
const accessToken = await fetchLogtoManagementApiAccessToken();
// 在 Logto 创建组织 (Organization) 并添加用户
const response = await fetch(`${process.env.LOGTO_ENDPOINT}/api/organizations`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
name: req.body.name,
description: req.body.description,
}),
});

const createdOrganization = await response.json();

await fetch(`${process.env.LOGTO_ENDPOINT}/api/organizations/${createdOrganization.id}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
userIds: [req.user.id],
}),
});

res.json({ data: createdOrganization });
});

现在我们已经通过 Logto Management API 实现了组织 (Organization) 创建,并能添加用户到组织 (Organization)。

让我们在 Dashboard 测试这个功能。

dashboard-create-org.png

点击“创建组织 (Organization)”

dashboard-has-orgs.png

创建成功!

下一步是邀请用户加入组织 (Organization)。本教程暂不实现该功能。你已经知道如何使用 Management API。你可以参考 租户创建与邀请 作为产品设计参考,并按照这篇博客 如何在多租户应用中实现用户协作 轻松实现该功能。

为你的多租户应用实现访问控制​

现在让我们进入组织 (Organization) 访问控制。

我们希望实现:

  • 用户只能访问属于自己组织 (Organization) 的资源:可以通过 Logto 的 组织令牌 (Organization token) 实现
  • 用户在组织 (Organization) 内有特定角色 (Role)(包含不同权限 (Permission)),以执行授权操作:可以通过 Logto 的组织模板功能实现

让我们看看如何实现这些功能。

使用 Logto 组织令牌 (Organization token)​

类似于前面提到的 Logto 访问令牌 (Access token),Logto 会针对特定资源颁发访问令牌 (Access token),用户用该令牌访问后端服务受保护资源。对应地,Logto 会针对特定组织 (Organization) 颁发组织令牌 (Organization token),用户用该令牌访问后端服务受保护的组织 (Organization) 资源。

在前端应用中,我们可以用 Logto 的 getOrganizationToken 方法获取访问特定组织 (Organization) 的令牌。

const { getOrganizationToken } = useLogto();
const organizationToken = await getOrganizationToken(organizationId);

这里的 organizationId 是用户所属组织 (Organization) 的 id。

在使用 getOrganization 或任何组织 (Organization) 相关功能前,需要确保 Logto config 中包含 urn:logto:scope:organizations scope 和 urn:logto:resource:organization resource。我们之前已经声明过,这里不再赘述。

在组织 (Organization) 页面中,我们用组织令牌 (Organization token) 获取组织 (Organization) 内的文档。

function OrganizationPage() {
const { organizationId } = useParams();
const navigate = useNavigate();
const { signOut, getOrganizationToken } = useLogto();
const [error, setError] = useState<Error | null>(null);
const [documents, setDocuments] = useState([]);

const fetchDocuments = useCallback(async () => {
if (!organizationId) return;

try {
const organizationToken = await getOrganizationToken(organizationId);
const response = await fetch(`http://localhost:3000/documents`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${organizationToken}`,
},
});
const documents = await response.json();
setDocuments(documents);
} catch (error: unknown) {
if (error instanceof Error) {
setError(error);
} else {
setError(new Error(String(error)));
}
}
},[getOrganizationToken, organizationId]);

useEffect(() => {
void fetchDocuments();
}, [fetchDocuments]);

if (error) {
return <div>错误: {error.message}</div>;
}

return <div>
<h1>组织 (Organization) 文档</h1>
<ul>
{documents.map((document) => (
<li key={document.id}>{document.name}</li>
))}
</ul>
</div>
}

该实现有两个重要注意点:

  1. 如果传给 getOrganizationToken 的 organizationId 不是当前用户所属的组织 (Organization) id,则无法获取令牌,从而保证用户只能访问自己的组织 (Organization)。
  2. 请求组织 (Organization) 资源时,我们用组织令牌 (Organization token) 而不是访问令牌 (Access token),因为对于属于组织 (Organization) 的资源,我们希望用组织 (Organization) 权限 (Permission) 控制,而不是用户权限 (Permission) 控制(在后面实现 GET /documents API 时你会更明白)。

接下来,我们在后端服务中创建 GET /documents API。类似于用 API resource 保护 POST /organizations API,我们用组织 (Organization) 专属资源指示器保护 GET /documents API。

首先,我们创建一个 requireOrganizationAccess 中间件来保护组织 (Organization) 资源。

const getTokenFromHeader = (headers) => {
const { authorization } = headers;
const bearerTokenIdentifier = 'Bearer';

if (!authorization) {
throw new Error('Authorization header missing');
}

if (!authorization.startsWith(bearerTokenIdentifier)) {
throw new Error('Authorization token type not supported');
}

return authorization.slice(bearerTokenIdentifier.length + 1);
};

const extractOrganizationId = (aud) => {
if (!aud || typeof aud !== 'string' || !aud.startsWith('urn:logto:organization:')) {
throw new Error('Invalid organization token');
}
return aud.replace('urn:logto:organization:', '');
};

const decodeJwtPayload = (token) => {
try {
const [, payloadBase64] = token.split('.');
if (!payloadBase64) {
throw new Error('Invalid token format');
}
const payloadJson = Buffer.from(payloadBase64, 'base64').toString('utf-8');
return JSON.parse(payloadJson);
} catch (error) {
throw new Error('Failed to decode token payload');
}
};

const requireOrganizationAccess = () => {
return async (req, res, next) => {
try {
// 提取令牌
const token = getTokenFromHeader(req.headers);

// 动态获取 token 中的 audience
const { aud } = decodeJwtPayload(token);
if (!aud) {
throw new Error('Missing audience in token');
}

// 校验令牌
const { payload } = await jwtVerify(
token,
createRemoteJWKSet(new URL(process.env.LOGTO_JWKS_URL)),
{
issuer: process.env.LOGTO_ISSUER,
audience: aud,
}
);

// 从 audience claim 提取组织 (Organization) ID
const organizationId = extractOrganizationId(payload.aud);

// 将组织 (Organization) 信息添加到请求
req.user = {
id: payload.sub,
organizationId,
};

next();
} catch (error) {
console.error('组织 (Organization) 认证 (Authentication) 错误:', error);
res.status(401).json({ error: '未授权 - 非法组织 (Organization) 访问' });
}
};
};

然后用 requireOrganizationAccess 中间件保护 GET /documents API。

app.get('/documents', requireOrganizationAccess(), async (req, res) => {
// 你可以通过 req.user 获取当前用户 id 和 organizationId
console.log('userId', req.user.id);
console.log('organizationId', req.user.organizationId);

// 根据 organizationId 从数据库获取文档
// ....
const documents = await getDocumentsByOrganizationId(req.user.organizationId);

res.json(documents);
});

这样,我们就实现了用组织令牌 (Organization token) 访问组织 (Organization) 资源。在后端服务中,你可以根据组织 (Organization) id 从数据库获取对应资源。

有些软件要求组织 (Organization) 之间数据隔离。更多讨论和实现可参考博客:用 PostgreSQL 实现多租户:通过简单真实案例学习。

实现组织级基于角色的访问控制 (RBAC) 设计​

我们已经实现了用组织令牌 (Organization token) 访问组织 (Organization) 资源。接下来,我们用 RBAC 实现组织 (Organization) 内用户权限 (Permission) 控制。

假设 DocuMind 有两个角色 (Role):Admin 和 Collaborator。

Admin 可以创建和访问文档,Collaborator 只能访问文档。

因此,我们的组织 (Organization) 需要有这两个角色 (Role):Admin 和 Collaborator。

Admin 拥有 read:documents 和 create:documents 权限 (Permission),Collaborator 只有 read:documents 权限 (Permission)。

  • Admin
    • read:documents
    • create:documents
  • Collaborator
    • read:documents

这时就用到了 Logto 的组织模板功能。

组织模板是每个组织 (Organization) 访问控制模型的蓝图:它定义了适用于所有组织 (Organization) 的角色 (Role) 和权限 (Permission)。

为什么要用组织模板?

因为可扩展性是 SaaS 产品最重要的需求之一。换句话说,对一个客户有效的模型应该对所有客户都有效。

进入 Logto 控制台 > 组织模板 > 组织权限,创建两个权限 (Permission):read:documents 和 create:documents。

org-template-permission.png

然后进入组织角色标签页,创建两个用户角色 (Role):Admin 和 Collaborator,并为其分配相应权限 (Permission)。

organization-details.png

这样,我们就为每个组织 (Organization) 创建了 RBAC 权限 (Permission) 模型。

接下来,进入组织 (Organization) 详情页,为成员分配合适的角色 (Role)。

org-template-role.png

现在我们的组织 (Organization) 用户有了角色 (Role)! 你也可以通过 Logto Management API 实现这些步骤:

// 为组织 (Organization) 创建者分配 'Admin' 角色 (Role)
app.post('/organizations', requireAuth('https://api.documind.com'), async (req, res) => {
const accessToken = await fetchLogtoManagementApiAccessToken();
// 在 Logto 创建组织 (Organization)
// 省略已有代码...

// 在 Logto 添加用户到组织 (Organization)
await fetch(`${process.env.LOGTO_ENDPOINT}/api/organizations/${createdOrganization.id}/users`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
userIds: [req.user.id],
}),
});

// 为第一个用户分配 `Admin` 角色 (Role)
const rolesResponse = await fetch(`${process.env.LOGTO_ENDPOINT}/api/organization-roles`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
});

const roles = await rolesResponse.json();

// 查找 `Admin` 角色 (Role)
const adminRole = roles.find((role) => role.name === 'Admin');

// 为第一个用户分配 `Admin` 角色 (Role)
await fetch(
`${process.env.LOGTO_ENDPOINT}/api/organizations/${createdOrganization.id}/users/${req.user.id}/roles`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({
organizationRoleIds: [adminRole.id],
}),
}
);

// 省略已有代码...
});

现在我们可以通过校验用户权限 (Permission) 实现用户权限 (Permission) 控制。

在代码中,我们需要让用户的组织令牌 (Organization token) 携带权限 (Permission) 信息,并在后端校验这些权限 (Permission)。

在前端代码的 Logto config 中,需要声明用户在组织 (Organization) 内需要请求的权限 (Permission)。我们将 read:documents 和 create:documents 权限 (Permission) 加入 scopes。

const config: LogtoConfig = {
endpoint: "<YOUR_LOGTO_ENDPOINT>",
appId: "<YOUR_LOGTO_APP_ID>",
scopes: [UserScope.Organizations, "read:documents", "create:documents"],
resources: [ReservedResource.Organization, "<https://api.documind.com>"], // 新创建的 API resource 标识符
};

像往常一样,用户需要重新登录以使这些配置生效。

然后在后端的 requireOrganizationAccess 中间件中,增加用户权限 (Permission) 校验。

const hasRequiredScopes = (tokenScopes, requiredScopes) => {
if (!requiredScopes || requiredScopes.length === 0) {
return true;
}
const scopeSet = new Set(tokenScopes);
return requiredScopes.every((scope) => scopeSet.has(scope));
};

const requireOrganizationAccess = ({ requiredScopes = [] } = {}) => {
return async (req, res, next) => {
try {
//...

// 校验令牌
const { payload } = await jwtVerify(
token,
createRemoteJWKSet(new URL(process.env.LOGTO_JWKS_URL)),
{
issuer: process.env.LOGTO_ISSUER,
audience: aud,
}
);

//...

// 从 token 获取 scopes
const scopes = payload.scope?.split(' ') || [];

// 校验所需 scopes
if (!hasRequiredScopes(scopes, requiredScopes)) {
throw new Error('权限不足');
}

//...

next();
} catch (error) {
//...
}
};
};

然后创建 POST /documents API,并用 requireOrganizationAccess 中间件及 requiredScopes 配置保护该 API 及之前的 GET /documents API。

// 创建文档的 API
app.post(
'/documents',
requireOrganizationAccess({ requiredScopes: ['create:documents'] }),
async (req, res) => {
//...
}
);

// 获取文档的 API
app.get(
'/documents',
requireOrganizationAccess({ requiredScopes: ['read:documents'] }),
async (req, res) => {
//...
}
);

这样,我们就通过校验用户权限 (Permission) 实现了用户权限 (Permission) 控制。

在前端,你可以通过解码组织令牌 (Organization token) 或调用 Logto 的 getOrganizationTokenClaims 方法获取用户权限 (Permission) 信息。

const [scopes, setScopes] = useState([]);
const { getOrganizationTokenClaims } = useLogto();

const loadScopes = async () => {
const claims = await getOrganizationTokenClaims(organizationId);
setScopes(claims.scope.split(' '));
};

// ...

根据 claims 中的 scopes 校验用户权限 (Permission),控制页面元素显示。

添加更多多租户应用功能​

到目前为止,我们已经实现了多租户 SaaS 系统的基础用户和组织 (Organization) 功能!但还有一些功能没有涉及,比如为每个组织 (Organization) 定制登录页面品牌、自动将特定域名邮箱用户加入指定组织 (Organization)、集成企业级 SSO 等。

这些都是开箱即用的功能,你可以在 Logto 文档中了解更多:

总结​

还记得一开始的压力吗?用户、组织 (Organization)、权限 (Permission)、企业功能……仿佛一座无尽高山。

但看看我们已经完成了什么:

  • 一个完整的认证 (Authentication) 系统,支持多种登录方式和 MFA
  • 一个灵活的组织 (Organization) 系统,支持多重成员关系
  • 组织 (Organization) 内基于角色的访问控制 (RBAC)

最棒的是?我们无需重复造轮子。借助 Logto 等现代工具,我们把原本可能需要数月开发的工作变成了几分钟搞定。

本教程完整源码见:多租户 SaaS 示例。

这就是 2025 年现代开发的力量——我们可以专注于打造独特的产品功能,而不是与基础设施死磕。现在轮到你去创造属于你的精彩了!

探索 Logto 的全部功能,从 Logto Cloud 到 Logto OSS,访问 Logto 官网 或立即注册 Logto cloud。