跨租户 SSO
跨租户 SSO 允许已在 公司 A 注册的用户,使用同一套凭证关联账号并登录 公司 B。该能力适用于不同租户之间,也适用于同一租户内母公司与其子公司之间。身份通过 GlobalIdentity 层用邮箱或手机号统一,各门户的业务数据(代币、礼券等)仍严格隔离。所有已关联门户共享同一个链上钱包。
概览
| 项 | 说明 |
|---|---|
| 范围 | 仅会员端 |
| 身份键 | 邮箱或手机号 |
| 会话载体 | HttpOnly Cookie(vio_sso_token) |
| 令牌模型 | JWT access/refresh 仍按门户划分(每个「租户 + 子公司」组合一套) |
| 钱包 | 所有已关联门户共用同一链上钱包地址 |
| 数据隔离 | 每个门户(租户 + 子公司)有各自的 User 与业务数据 |
| 门户键 | 门户由复合键 (tenantId, subCompanyId) 标识。母公司为 subCompanyId = null。 |
架构
┌──────────────┐ cookie ┌─────────────────┐ cookie ┌──────────────┐
│ Company A │ ──────────► │ SSO Session │ ◄────────── │ Company B │
│ Member App │ │ (Global) │ │ Member App │
└──────┬───────┘ └────────┬────────┘ └──────┬───────┘
│ │ │
│ JWT (portal-a) │ GlobalIdentity │ JWT (portal-b)
│ │ │
▼ ▼ ▼
User (portal-a) Email / Phone User (portal-b)
│ Password (hashed) │
└────────────────► Shared Wallet Address ◄─────────────┘「门户」指租户与子公司的唯一组合。例如租户 A 的母公司与租户 A 的子公司 X 在同一租户下是两个不同门户。用户可在不同租户间 SSO,也可在同一租户的母公司与子公司间 SSO。
核心模型
| 模型 | 作用 |
|---|---|
GlobalIdentity | 保存统一邮箱/手机 + 哈希密码。通过 linkedTenants[] 关联各门户的 User,每项含 tenantId、subCompanyId、userId。 |
SSOSession | 表示活跃的全局登录会话,令牌存在 HttpOnly Cookie 中。 |
User.globalIdentityId | 门户内用户指向其全局身份的引用。 |
User.walletAddress | 共享的链上钱包地址,所有已关联用户指向同一地址。 |
按门户区分的身份
每个门户由其租户及子公司(如有)唯一标识。同一个人可在以下场景拥有不同的会员资料:
- 不同租户(如公司 A 与公司 B)
- 同一租户的母公司与子公司(如公司 A 母公司与公司 A / 分店 X)
- 同一租户内不同子公司(如公司 A / 分店 X 与公司 A / 分店 Y)
用户流程
1. 首次注册(公司 A)
- 用户在公司 A 注册。
- 后端创建
GlobalIdentity(若为新)并关联公司 A 的User。 - 创建
SSOSession,令牌写入HttpOnlyCookie。 - 用户获得公司 A 的租户级 JWT。
- 创建托管链上钱包,地址写入
User。
2. 跨租户与跨子公司场景
以下情形同时适用于跨租户(不同租户)与跨子公司(同租户不同子公司)。流程一致 — 系统检测到门户不一致后提示用户关联。
用户关联新公司或新子公司的路径有三类主干场景(外加母公司门户与 E/D 补充):
场景 A — 在公司 B 注册(无 Cookie)
- 用户打开公司 B 会员端,进入 Sign Up。
- 输入的邮箱或手机号已在公司 A 注册。
- 前端调用
POST /api/auth/sso/check-identifier检测跨租户账号。 - 弹出提示:「您已使用此账号在公司 A 注册。是否将账号关联到公司 B,并用同一凭证登录?」
- 用户点 Yes 则跳转到公司 B 的 Login。
- 用户输入相同邮箱与密码,点 Log In。
- 后端检测到租户不一致,且用户已确认关联,则在公司 B 新建
User、关联GlobalIdentity,并共用已有钱包地址。 - 登录成功,确认弹窗:「已成功将账号关联到公司 B。」
- 用户可开始使用公司 B 会员端。
场景 B — 访问公司 B(仍有 Cookie)
- 用户在公司 A 已建立 SSO Cookie 的前提下打开公司 B 的 URL。
SSOProvider自动调用GET /api/auth/sso/check,发现已有全局身份。- 若已关联且有效:通过换票自动登录(无弹窗)。
- 若尚未关联或需新资料:自动出现与场景 A 相同的弹窗(无需先输入邮箱)。
- 用户点 Yes 后跳转公司 B 登录页,后续从场景 A 第 6 步起相同。
场景 C — 在公司 B 直接登录(不走注册)
- 用户进入公司 B Login,输入公司 A 的凭证。
- 后端在公司 A 找到用户、校验密码,发现租户不一致。
- 返回含
crossTenantRequired的响应及来源公司名称。 - 弹窗:「您已使用此账号在公司 A 注册。是否将账号关联到公司 B?」
- 用户点 Yes,客户端用
crossTenantLink: true重发登录请求。 - 后端在公司 B 创建用户、关联 GlobalIdentity、共享钱包。
- 登录成功,确认弹窗:「已成功将账号关联到公司 B。」
- 用户可使用公司 B 会员端。
场景 E — 仅从子公司 URL 注册用户访问母公司 URL(同租户)
- 用户仅在 公司 A / 分店 X 注册,访问公司 A 母公司会员端登录 URL(路径中无子公司段)。
- 后端可能返回
parentPortalLinkRequired及sourceSubCompanyName,或在已存在关联的母公司资料时直接解析。 - 用户确认关联后,客户端以
crossParentPortalLink: true重试。 - 后端创建或关联租户级用户(
subCompanyId为 null)、关联GlobalIdentity,并返回母公司门户 JWT。
场景 D — 在子公司 URL 直接登录(同租户)
- 用户在公司 A 母公司门户注册,进入 公司 A / 分店 X 登录页并输入凭证。
- 后端在母公司找到用户、校验密码,发现为同租户不同子公司(门户不一致)。
- 返回
crossSubCompanyRequired及来源子公司名称。 - 弹窗:「您已在公司 A 注册。是否将账号关联到分店 X?」
- 用户点 Yes,请求带上
crossSubCompanyLink: true重发。 - 后端在公司 A 下新建
subCompanyId为分店 X 的User,关联同一GlobalIdentity,共享钱包。 - 登录成功。
- 用户可使用分店 X 会员端。
flowchart TD
subgraph scenarioA ["场景A:注册(无Cookie)"]
A1["用户在公司B注册页输入邮箱"] --> A2["API: POST /auth/sso/check-identifier"]
A2 --> A3{"其他租户已存在?"}
A3 -->|是| A4["弹窗:已在公司A注册"]
A4 -->|是| A5["跳转公司B登录"]
A5 --> A6["输入凭证并登录"]
A6 --> A7["API: POST /auth/login crossTenantLink=true"]
A7 --> A8["后端建User、关联身份、共用钱包"]
A8 --> A9["确认:已关联"]
A3 -->|否| A10["正常注册"]
end
subgraph scenarioB ["场景B:有Cookie"]
B1["用户访问公司B"] --> B2["SSOProvider: GET /auth/sso/check"]
B2 --> B3{"发现SSO会话?"}
B3 -->|"已关联"| B7["自动SSO登录"]
B3 -->|"未关联/需新"| B4["自动弹窗"]
B4 -->|是| B5["跳转公司B登录"]
B5 --> B6["与场景A第6步起相同"]
end
subgraph scenarioC ["场景C:直接登录"]
C1["在公司B登录用公司A凭证"] --> C2["API: POST /auth/login"]
C2 --> C3{"租户不一致?"}
C3 -->|是| C4["返回 crossTenantRequired"]
C4 --> C5["弹窗:已在公司A注册"]
C5 -->|是| C6["重发登录 crossTenantLink=true"]
C6 --> C7["同场景A第8步后"]
end
subgraph scenarioD ["场景D:跨子公司登录"]
D1["在分店X登录用母公司凭证"] --> D2["API: POST /auth/login"]
D2 --> D3{"同租户子公司不一致?"}
D3 -->|是| D4["返回 crossSubCompanyRequired"]
D4 --> D5["弹窗:已在母公司注册"]
D5 -->|是| D6["重发登录 crossSubCompanyLink=true"]
D6 --> D7["后端在分店X建User并关联"]
D7 --> D8["登录成功"]
end3. 切换账号
用户已将账号关联到多个公司或子公司后,有两种切换方式:
首页快捷切换
- 在 Home 页点击左上角 头像。
- 底部弹层列出所有已关联门户(公司与子公司)。
- 子公司条目显示为 「公司名称 — 子公司名称」。
- 当前门户带 “Current” 标记。
- 点击其他门户即切换。
- 应用导航到该门户 URL,经 SSO 自动登录。
切换提示
多个账号关联时,头像上会显示小型切换图标,表示可使用快捷切换。
「关联账号」完整页
- 进入会员端 Account。
- 点击 Linked Accounts 查看全部已关联门户。
- 每条显示公司名称(及适用的子公司名)。
- 在非当前门户上点击 Switch。
- 应用跳转到该门户 URL(如
/{company-slug}/或/{company-slug}/{sub-company-slug}/)。 SSOProvider读取 Cookie,发现已关联账号并自动登录。- 用户进入另一门户的应用界面(品牌与数据均为该门户)。
在此页也可对不再需要的门户执行 unlink(解绑)。
钱包共享
用户在不同门户(跨租户或跨子公司)关联后,所有关联的 User 记录共享同一链上钱包地址。即:
- 任一门户获得的代币进入同一钱包。
- 钱包仅在首次注册时创建一次。
- 钱包仅在首次注册时创建一次。
- 关联到额外门户时,系统会复用已有钱包地址,而不会新建钱包。
数据隔离
SSO 在门户间共享钱包地址与登录凭证,其余业务数据按门户严格隔离:
- 会员资料(各公司或子公司独立)
- 代币余额、礼券、交易与活动
- 推送订阅与偏好设置
- 门店关联与会员等级
即使账号已关联,各门户的数据仍彼此独立。
面向集成方
SSO 相关 API 的完整请求/响应说明见 对外 API 参考。
编程式跨租户关联(External API)
除了上述会员端交互式 SSO 流程外,External API 还提供编程式用户创建及自动跨租户关联:
POST /api/external/v1/users/find-or-create该幂等端点处理三种场景:
- 用户已存在于当前门户 → 返回已有用户(
created: false, linked: false) - 用户存在于其他租户 → 在当前门户创建关联 profile,共享显示名称、头像和钱包(
created: true, linked: true) - 全新用户 → 创建新用户和 GlobalIdentity(
created: true, linked: false)
从外部系统(CRM、POS 等)同步用户时,推荐使用此端点 — 安全地处理跨租户身份关联,无需交互式 SSO 确认流程。
详见 对外 API 参考 的完整请求/响应说明。
安全说明
- 关联新门户前,会员必须主动确认,系统才会共享凭证。
- 修改密码后,所有共享同一全局身份的已关联门户均会生效。
- 会员不能解绑最后一个仍保留的关联门户。