跨租户 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 | 共享的链上钱包地址,所有已关联用户指向同一地址。 |
按门户区分的身份
GlobalIdentity.linkedTenants 用 (tenantId, subCompanyId) 复合键标识每个门户,因此同一用户可在以下场景拥有不同资料:
- 不同租户(如公司 A 与公司 B)
- 同一租户的母公司与子公司(如公司 A 母公司与公司 A / 分店 X)
- 同一租户内不同子公司(如公司 A / 分店 X 与公司 A / 分店 Y)
User 模型在 (email, tenantId, subCompanyId) 上有复合唯一索引。MongoDB 将 null 视为独立值,因此同一租户下同邮箱的母公司用户(subCompanyId: null)与子公司用户(subCompanyId: X)可同时存在。
用户流程
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 记录共享同一链上钱包地址。即:
- 任一门户获得的代币进入同一钱包。
- 钱包仅在首次注册时创建一次。
- 后续门户调用
createProfileForTenant时会在已关联用户中查找已有钱包并复用地址,而非新建。 - 每个
User的walletAddress字段存同一地址,数据库中通常仅一份Wallet文档。
数据隔离
SSO 在门户间共享钱包地址与凭证,其余业务数据按门户严格隔离:
User记录(不同_id、tenantId、subCompanyId)- 代币余额、礼券、交易、活动
- 推送订阅与偏好
- 门店关联与会员等级等
隔离保障
| 层 | 机制 |
|---|---|
| JWT | 每个令牌含目标门户的 tenantId 与 subCompanyId。 |
| 认证中间件 | authenticate() 从 JWT 用户解析 req.tenantId,覆盖请求头等。 |
| 请求拦截 | 前端在跨租户/子公司导航时不发送过期门户的认证头。 |
| 数据库索引 | User 上 email + tenantId + subCompanyId 复合唯一保证每门户唯一。 |
| SSO 换票 | 仅为目标门户生成新的 JWT。 |
API 接口
所有 SSO 接口均在 /api/auth/sso/ 下。
检测跨租户标识
POST /api/auth/sso/check-identifier鉴权:无(公开,应限流)
在向目标租户发送 OTP 前,注册页用此接口检测邮箱/手机号是否已在其他租户注册。
Body:
{
"identifier": "user@example.com",
"identifierType": "email",
"targetTenantId": "current-tenant-id"
}响应(已存在账号):
{
"success": true,
"data": {
"exists": true,
"sourceTenantName": "Company A",
"sourceTenantSlug": "company-a",
"sourceSubCompanyName": "Branch X"
}
}当匹配账号在来源租户内属于子公司门户时才会返回 sourceSubCompanyName(若仅有母公司层级资料则省略)。
响应(未找到):
{
"success": true,
"data": {
"exists": false
}
}登录并跨租户 / 跨子公司关联
POST /api/auth/login标准登录接口支持关联参数 crossTenantLink、crossSubCompanyLink。
Body:
{
"identifier": "user@example.com",
"identifierType": "email",
"password": "...",
"rememberMe": false,
"crossTenantLink": true,
"crossSubCompanyLink": false,
"crossParentPortalLink": false
}当 crossTenantLink 为 false(或未传)且检测到租户不一致时:
{
"success": true,
"data": {
"crossTenantRequired": true,
"sourceTenantName": "Company A",
"sourceTenantSlug": "company-a",
"sourceSubCompanyName": "Branch X"
}
}若匹配用户属于子公司门户,会包含 sourceSubCompanyName,便于客户端文案显示为 「您已在分店 X 注册」 等。
当 crossSubCompanyLink 为 false(或未传)且同租户子公司不一致时:
{
"success": true,
"data": {
"crossSubCompanyRequired": true,
"sourceSubCompanyName": "Parent Company"
}
}在母公司 URL(路径无子公司)登录,而唯一匹配的是子公司成员(同租户)时,接口可能返回:
{
"success": true,
"data": {
"parentPortalLinkRequired": true,
"sourceSubCompanyName": "Branch X"
}
}用户确认后,客户端以 crossParentPortalLink: true 重试登录,创建或关联 租户级(subCompanyId null)资料。
当 crossTenantLink、crossSubCompanyLink 或 crossParentPortalLink 为 true 时,后端在目标门户创建或关联用户、挂接 GlobalIdentity、共享钱包并返回:
{
"success": true,
"data": {
"user": { "...": "..." },
"accessToken": "...",
"refreshToken": "...",
"accountLinked": true,
"linkedTenantName": "Company B",
"linkedSubCompanyName": "Branch X"
}
}查询 SSO 状态
GET /api/auth/sso/check?tenantId={targetTenantId}&subCompanyId={optionalSubCompanyId}鉴权:无(读取 SSO Cookie)
subCompanyId 可选。传入时在精确门户键(租户 + 子公司组合)下查询是否存在资料。
响应示例(canSSO: true):
{
"success": true,
"data": {
"canSSO": true,
"requiresLink": false,
"newProfile": true,
"globalIdentityId": "...",
"tenantName": "Company B"
}
}用 SSO 令牌换票
POST /api/auth/sso/exchange鉴权:无(读取 SSO Cookie)
Body:
{
"tenantId": "target-tenant-id",
"createProfile": false,
"subCompanyId": "optional",
"rememberMe": false
}响应:{ user, accessToken, refreshToken }
关联已有账号
POST /api/auth/sso/link鉴权:无(读取 SSO Cookie)
Body:
{
"tenantId": "target-tenant-id",
"password": "user-password-for-confirmation",
"subCompanyId": "optional-sub-company-id"
}响应:{ user, accessToken, refreshToken }
获取已关联租户列表
GET /api/auth/me/linked-tenants鉴权:JWT Bearer
响应:已关联门户数组,含租户名、slug、logo、子公司名/slug、用户资料等。
[
{
"tenantId": "...",
"tenantName": "Company A",
"tenantSlug": "company-a",
"tenantLogo": "...",
"subCompanyId": null,
"subCompanyName": null,
"subCompanySlug": null,
"userId": "...",
"userDisplayName": "John",
"linkedAt": "2025-01-01T00:00:00Z"
},
{
"tenantId": "...",
"tenantName": "Company A",
"tenantSlug": "company-a",
"tenantLogo": "...",
"subCompanyId": "...",
"subCompanyName": "Branch X",
"subCompanySlug": "branch-x",
"userId": "...",
"userDisplayName": "John",
"linkedAt": "2025-03-15T00:00:00Z"
}
]解绑租户
POST /api/auth/sso/unlink鉴权:JWT Bearer
Body:
{
"tenantId": "tenant-to-unlink",
"subCompanyId": "optional-sub-company-to-unlink"
}不可解绑当前门户,也不可解绑最后一个剩余门户。
撤销 SSO 会话(全局登出)
POST /api/auth/sso/logout鉴权:无(读取 SSO Cookie)
撤销全局 SSO 会话并清除 Cookie。不会单独作废各租户 JWT。
配置
后端 .env
# 用于在子域名间共享 SSO Cookie 的 Cookie 域
# localhost / 路径式路由可留空
# 生产示例:.yourdomain.com
SSO_COOKIE_DOMAIN=
# Cookie 名(默认 vio_sso_token)
SSO_COOKIE_NAME=vio_sso_token
# 会话天数
SSO_SESSION_EXPIRY_DAYS=7
# 「记住我」会话天数
SSO_REMEMBER_ME_EXPIRY_DAYS=30Cookie 行为
| 环境 | secure | sameSite | httpOnly |
|---|---|---|---|
NODE_ENV=development | false | lax | true |
NODE_ENV=production | true | lax | true |
localhost 且租户用路径区分(/tenant-a/、/tenant-b/)时,SSO_COOKIE_DOMAIN 留空即可,Cookie 在同一主机所有路径共享。
子域名 租户(tenant-a.app.com、tenant-b.app.com)时,设置 SSO_COOKIE_DOMAIN=.app.com。
前端架构
核心组件 / 模块
| 组件 / 模块 | 作用 |
|---|---|
SSOProvider | 包裹应用。未登录时调用 GET /auth/sso/check;在包含 /login 在内的所有路由执行静默 ssoLogin(换票),以便切换账号时无需重复输密码。Account Detected 弹窗在 /login、/forgot-password 不展示(由 LoginPage 接口驱动),避免重复弹窗。 |
CrossTenantDetectedModal | 各类场景的统一弹窗(跨租户、跨子公司、母公司关联)。加粗的来源行在有 sourceSubCompanyName 且账号建在子公司时优先显示子公司名。 |
AccountLinkedConfirmationModal | 跨租户/子公司登录成功后的确认弹窗。 |
authStore._tenantSlug | 持久化字段,标记当前令牌所属租户 slug。 |
authStore.crossTenantInfo | 暂存登录响应中的跨租户提示(含可选 sourceSubCompanyName)。 |
authStore.crossSubCompanyInfo | 暂存跨子公司提示。 |
authStore.crossParentPortalInfo | parentPortalLinkRequired(子公司用户在母公司 URL 登录)时的暂存。 |
authStore.accountLinkedInfo | 关联成功后的暂存,驱动确认弹窗。 |
authStore.checkCrossTenantIdentifier() | 注册时调用 POST /auth/sso/check-identifier。 |
RegisterPage | 提交时即便 checkSSO 会返回 canSSO(如他司 Cookie),也不得中止;仍需跑跨租户检测与 requestOTP。 |
authStore.clearAuthState() | 同步清空状态(不调 API),用于跨租户瞬时清理。 |
api.js 请求拦截 | 比对 URL 租户 slug 与 _tenantSlug,不一致则不发送陈旧令牌。 |
api.js 响应拦截 | 跨租户过渡中遇 401 不跳转登录,交给 SSOProvider 处理。 |
后端登录解析(摘要)
- 邮箱标识在查找与关联时会统一小写,与存储的
User/GlobalIdentity一致。 - 母公司请求(
subCompanyIdnull)若同时存在租户级与子级用户行,服务优先使用租户级User(subCompanyIdnull)发 JWT。 - 母公司门户关联(
parentPortalLinkRequired/crossParentPortalLink):子公司用户在母公司 URL 登录时,可为同一全局身份创建或关联母公司层级资料。
跨租户导航顺序
场景 B(有 Cookie)
1. URL 变为 /company-b/
2. 请求拦截发现 _tenantSlug 不一致 → 跳过鉴权头
3. SSOProvider 发现 user.tenantId ≠ brandingData.tenantId → clearAuthState()
4. SSOProvider 调用 GET /api/auth/sso/check(Cookie 自动附带)
5. 已关联 → 换票自动登录
6. 未关联 → 显示 CrossTenantDetectedModal
7. 用户确认 → 带 crossTenantConfirmed 状态进入 /company-b/login
8. 登录页 POST /auth/login 且 crossTenantLink=true
9. 成功后显示确认弹窗,再进入首页场景 A(注册检测)
1. 用户在 /company-b/register 输入邮箱
2. 提交时 RegisterPage 调用 POST /auth/sso/check-identifier
3. 若其他租户已存在 → CrossTenantDetectedModal
4. 确认 → crossTenantConfirmed 状态进入 /company-b/login
5. POST /auth/login crossTenantLink=true
6. 成功后确认弹窗,再进首页场景 C(直接登录 — 跨租户)
1. 用户在 /company-b/login 输入公司 A 凭证并登录
2. POST /auth/login 返回 crossTenantRequired 与 sourceTenantName
3. LoginPage 显示 CrossTenantDetectedModal
4. 确认 → POST /auth/login crossTenantLink=true
5. 成功后确认弹窗,再进首页场景 D(直接登录 — 跨子公司)
1. 用户在 /company-a/branch-x/login 输入母公司凭证并登录
2. POST /auth/login 返回 crossSubCompanyRequired 与 sourceSubCompanyName
3. LoginPage 复用 CrossTenantDetectedModal(子公司文案)
4. 确认 → POST /auth/login crossSubCompanyLink=true
5. 成功后进入首页账号切换
用户可通过两种方式在公司间切换:
快捷切换(首页头像)
- 用户在首页点头像。
HomePage展示账号切换底部列表。- 子公司显示为 「公司名称 — 子公司名称」。
- 数据来自
authStore.fetchLinkedTenants()(GET /api/auth/me/linked-tenants)。 - 点击非当前门户:
window.location.href = /{company-slug}/或含子公司路径。 - 新门户上
SSOProvider读 Cookie 并自动登录。
完整页(Account → Linked Accounts)
- 进入 Account → Linked Accounts。
- 列表展示 Logo、公司及子公司名。
- 在非当前门户点 Switch(箭头)。
- 浏览器跳转到
/{company-slug}/或含子公司 slug。 SSOProvider检测 Cookie,找到已关联账号并自动登录。
迁移
初次启用 SSO
对已有部署,运行迁移脚本为存量用户创建 GlobalIdentity:
cd vio-v4-api
node scripts/migrate-sso-global-identity.js脚本会:
- 找出无
globalIdentityId的用户 - 按邮箱/手机分组
- 创建
GlobalIdentity并关联现有用户 - 幂等,可重复执行
按门户键的索引迁移
启用租户内跨子公司 SSO 后需更新索引。旧的 (email, tenantId)、(phone, tenantId) 双字段唯一索引需替换为 (email, tenantId, subCompanyId)、(phone, tenantId, subCompanyId)。
cd vio-v4-api
# 仅预览变更(不写库)
node scripts/migrate-user-indexes-portal-keyed.js --dry-run
# 执行迁移
node scripts/migrate-user-indexes-portal-keyed.js脚本会:
- 删除
users集合上旧的 2 字段复合唯一索引 - 删除
globalidentities上旧的单字段linkedTenants.tenantId索引 - 新的 3 字段索引在下次应用启动时由 Mongoose 自动创建
- 无需迁移数据,与现有数据兼容
GlobalIdentity.linkedTenants中无subCompanyId的项视为subCompanyId: null(母公司)
QA 测试用例(跨租户 SSO)
至少准备两个租户(如 Tenant A、Tenant B),以及某一租户内的两个子公司(Sub X、Sub Y)加母公司 URL(subCompanyId null)。准备邮箱/手机测试账号,需要时在用例之间清除 Cookie。
跨租户(不同公司)
| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| CT-1 | 先在 A 母公司注册再在 B 登录 | 在 A 母公司 URL 注册;打开 B 登录;同邮箱/密码 | 出现 crossTenantRequired 或注册流;弹窗显示 Tenant A;选 Yes 关联成功;确认弹窗;B 的 /login 上不要出现 SSO+登录两个重叠弹窗。 |
| CT-2 | 仅在 A 子公司注册再在 B 登录 | 仅在 A / Sub X 注册;打开 B 登录;同凭证 | 弹窗加粗行显示 Sub X(sourceSubCompanyName),不单是 Tenant A;关联成功。 |
| CT-3 | 用已在 A 的邮箱在 B 注册 | B Register 填 A 已有邮箱 | check-identifier 返回 exists;弹窗含子公司或租户名;Yes 后进登录;Send OTP 正常执行(不能静默无操作)。 |
| CT-4 | 带 A 的 SSO Cookie 在 B 注册 | 先在 A 登录;同浏览器打开 B /register 并提交同邮箱 | Send OTP 仍执行(不能仅因 canSSO 就阻断注册);跨租户或 OTP 流程正常。 |
同租户 — 子公司 vs 母公司
| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| SC-1 | 母公司用户 → Sub X 登录 | 母公司注册;打开 A / Sub X 登录;同凭证 | crossSubCompanyRequired;弹窗;Yes + crossSubCompanyLink → Sub X 资料;钱包共享。 |
| SC-2 | Sub X 用户 → 母公司登录 | 仅在 A / Sub X 注册;打开 A 母公司 登录 | parentPortalLinkRequired;或若母公司资料已有关联则直接匹配;确认后母公司级资料;合法关联用户不出现误报 “Access denied”。 |
| SC-3 | Sub X → Sub Y 登录 | 在 Sub X 关联或注册;打开 A / Sub Y 登录 | 出现合适跨子提示或已有资料;确认时不应错误 409 重复邮箱。 |
SSO 会话、切换与 UI
| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| SS-1 | 在 B 上做 SSO check | 带有效 SSO Cookie 访问 B;尚未关联 | 执行 GET /auth/sso/check;按规则弹窗或静默换票;/login 上不要叠加两个 Account Detected 弹窗。 |
| SS-2 | 母公司 ↔ 子公司切换 | 已关联母公司 + Sub X;首页切到另一门户 | 按 URL 导航;会话清空时在 /login 静默 ssoLogin;换票成功则不要求重复输密码。 |
| SS-3 | 关联账号列表 | Account → Linked Accounts | 列出租户 + 子公司名;切换可用。 |
边界 / 回归
| # | 用例 | 步骤 | 预期 |
|---|---|---|---|
| EG-1 | 邮箱大小写 | 混用大小写注册;换一种大小写登录 | 登录与关联成功(邮箱规范化)。 |
| EG-2 | 解绑非最后一个 | 解绑某一已关联门户 | 成功;不能解绑最后一个剩余门户。 |
安全说明
POST /api/auth/sso/check-identifier会泄露邮箱/手机是否已注册,应限流以防枚举攻击。- 密码同步:用户任一门户改密码时,也应更新
GlobalIdentity密码;由现有changePassword流程处理。 crossTenantLink仅在真实检测到租户不一致时生效,不能单靠该参数绕过正常登录限制。