Skip to content

跨租户 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,每项含 tenantIdsubCompanyIduserId
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)

  1. 用户在公司 A 注册。
  2. 后端创建 GlobalIdentity(若为新)并关联公司 A 的 User
  3. 创建 SSOSession,令牌写入 HttpOnly Cookie。
  4. 用户获得公司 A 的租户级 JWT。
  5. 创建托管链上钱包,地址写入 User

2. 跨租户与跨子公司场景

以下情形同时适用于跨租户(不同租户)与跨子公司(同租户不同子公司)。流程一致 — 系统检测到门户不一致后提示用户关联。

用户关联新公司或新子公司的路径有三类主干场景(外加母公司门户与 E/D 补充):

  1. 用户打开公司 B 会员端,进入 Sign Up
  2. 输入的邮箱或手机号已在公司 A 注册。
  3. 前端调用 POST /api/auth/sso/check-identifier 检测跨租户账号。
  4. 弹出提示:「您已使用此账号在公司 A 注册。是否将账号关联到公司 B,并用同一凭证登录?」
  5. 用户点 Yes 则跳转到公司 B 的 Login
  6. 用户输入相同邮箱与密码,点 Log In
  7. 后端检测到租户不一致,且用户已确认关联,则在公司 B 新建 User、关联 GlobalIdentity,并共用已有钱包地址。
  8. 登录成功,确认弹窗:「已成功将账号关联到公司 B。」
  9. 用户可开始使用公司 B 会员端。
  1. 用户在公司 A 已建立 SSO Cookie 的前提下打开公司 B 的 URL。
  2. SSOProvider 自动调用 GET /api/auth/sso/check,发现已有全局身份。
  3. 若已关联且有效:通过换票自动登录(无弹窗)。
  4. 若尚未关联或需新资料:自动出现与场景 A 相同的弹窗(无需先输入邮箱)。
  5. 用户点 Yes 后跳转公司 B 登录页,后续从场景 A 第 6 步起相同。

场景 C — 在公司 B 直接登录(不走注册)

  1. 用户进入公司 B Login,输入公司 A 的凭证。
  2. 后端在公司 A 找到用户、校验密码,发现租户不一致。
  3. 返回含 crossTenantRequired 的响应及来源公司名称。
  4. 弹窗:「您已使用此账号在公司 A 注册。是否将账号关联到公司 B?」
  5. 用户点 Yes,客户端用 crossTenantLink: true 重发登录请求。
  6. 后端在公司 B 创建用户、关联 GlobalIdentity、共享钱包。
  7. 登录成功,确认弹窗:「已成功将账号关联到公司 B。」
  8. 用户可使用公司 B 会员端。

场景 E — 仅从子公司 URL 注册用户访问母公司 URL(同租户)

  1. 用户公司 A / 分店 X 注册,访问公司 A 母公司会员端登录 URL(路径中无子公司段)。
  2. 后端可能返回 parentPortalLinkRequiredsourceSubCompanyName,或在已存在关联的母公司资料时直接解析。
  3. 用户确认关联后,客户端以 crossParentPortalLink: true 重试。
  4. 后端创建或关联租户级用户(subCompanyId 为 null)、关联 GlobalIdentity,并返回母公司门户 JWT。

场景 D — 在子公司 URL 直接登录(同租户)

  1. 用户在公司 A 母公司门户注册,进入 公司 A / 分店 X 登录页并输入凭证。
  2. 后端在母公司找到用户、校验密码,发现为同租户不同子公司(门户不一致)。
  3. 返回 crossSubCompanyRequired 及来源子公司名称。
  4. 弹窗:「您已在公司 A 注册。是否将账号关联到分店 X?」
  5. 用户点 Yes,请求带上 crossSubCompanyLink: true 重发。
  6. 后端在公司 A 下新建 subCompanyId 为分店 X 的 User,关联同一 GlobalIdentity,共享钱包。
  7. 登录成功。
  8. 用户可使用分店 X 会员端。
mermaid
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["登录成功"]
    end

3. 切换账号

用户已将账号关联到多个公司或子公司后,有两种切换方式:

首页快捷切换

  1. Home 页点击左上角 头像
  2. 底部弹层列出所有已关联门户(公司与子公司)。
  3. 子公司条目显示为 「公司名称 — 子公司名称」
  4. 当前门户带 “Current” 标记。
  5. 点击其他门户即切换。
  6. 应用导航到该门户 URL,经 SSO 自动登录。

切换提示

多个账号关联时,头像上会显示小型切换图标,表示可使用快捷切换。

「关联账号」完整页

  1. 进入会员端 Account
  2. 点击 Linked Accounts 查看全部已关联门户。
  3. 每条显示公司名称(及适用的子公司名)。
  4. 在非当前门户上点击 Switch
  5. 应用跳转到该门户 URL(如 /{company-slug}//{company-slug}/{sub-company-slug}/)。
  6. SSOProvider 读取 Cookie,发现已关联账号并自动登录。
  7. 用户进入另一门户的应用界面(品牌与数据均为该门户)。

在此页也可对不再需要的门户执行 unlink(解绑)。

钱包共享

用户在不同门户(跨租户或跨子公司)关联后,所有关联的 User 记录共享同一链上钱包地址。即:

  • 任一门户获得的代币进入同一钱包。
  • 钱包仅在首次注册时创建一次。
  • 后续门户调用 createProfileForTenant 时会在已关联用户中查找已有钱包并复用地址,而非新建。
  • 每个 UserwalletAddress 字段存同一地址,数据库中通常仅一份 Wallet 文档。

数据隔离

SSO 在门户间共享钱包地址凭证,其余业务数据按门户严格隔离:

  • User 记录(不同 _idtenantIdsubCompanyId
  • 代币余额、礼券、交易、活动
  • 推送订阅与偏好
  • 门店关联与会员等级等

隔离保障

机制
JWT每个令牌含目标门户的 tenantIdsubCompanyId
认证中间件authenticate() 从 JWT 用户解析 req.tenantId,覆盖请求头等。
请求拦截前端在跨租户/子公司导航时不发送过期门户的认证头。
数据库索引Useremail + tenantId + subCompanyId 复合唯一保证每门户唯一。
SSO 换票仅为目标门户生成新的 JWT。

API 接口

所有 SSO 接口均在 /api/auth/sso/ 下。

检测跨租户标识

POST /api/auth/sso/check-identifier

鉴权:无(公开,应限流)

在向目标租户发送 OTP 前,注册页用此接口检测邮箱/手机号是否已在其他租户注册。

Body

json
{
  "identifier": "user@example.com",
  "identifierType": "email",
  "targetTenantId": "current-tenant-id"
}

响应(已存在账号):

json
{
  "success": true,
  "data": {
    "exists": true,
    "sourceTenantName": "Company A",
    "sourceTenantSlug": "company-a",
    "sourceSubCompanyName": "Branch X"
  }
}

当匹配账号在来源租户内属于子公司门户时才会返回 sourceSubCompanyName(若仅有母公司层级资料则省略)。

响应(未找到):

json
{
  "success": true,
  "data": {
    "exists": false
  }
}

登录并跨租户 / 跨子公司关联

POST /api/auth/login

标准登录接口支持关联参数 crossTenantLinkcrossSubCompanyLink

Body

json
{
  "identifier": "user@example.com",
  "identifierType": "email",
  "password": "...",
  "rememberMe": false,
  "crossTenantLink": true,
  "crossSubCompanyLink": false,
  "crossParentPortalLink": false
}

crossTenantLinkfalse(或未传)且检测到租户不一致时:

json
{
  "success": true,
  "data": {
    "crossTenantRequired": true,
    "sourceTenantName": "Company A",
    "sourceTenantSlug": "company-a",
    "sourceSubCompanyName": "Branch X"
  }
}

若匹配用户属于子公司门户,会包含 sourceSubCompanyName,便于客户端文案显示为 「您已在分店 X 注册」 等。

crossSubCompanyLinkfalse(或未传)且同租户子公司不一致时:

json
{
  "success": true,
  "data": {
    "crossSubCompanyRequired": true,
    "sourceSubCompanyName": "Parent Company"
  }
}

母公司 URL(路径无子公司)登录,而唯一匹配的是子公司成员(同租户)时,接口可能返回:

json
{
  "success": true,
  "data": {
    "parentPortalLinkRequired": true,
    "sourceSubCompanyName": "Branch X"
  }
}

用户确认后,客户端以 crossParentPortalLink: true 重试登录,创建或关联 租户级subCompanyId null)资料。

crossTenantLinkcrossSubCompanyLinkcrossParentPortalLinktrue 时,后端在目标门户创建或关联用户、挂接 GlobalIdentity、共享钱包并返回:

json
{
  "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):

json
{
  "success": true,
  "data": {
    "canSSO": true,
    "requiresLink": false,
    "newProfile": true,
    "globalIdentityId": "...",
    "tenantName": "Company B"
  }
}

用 SSO 令牌换票

POST /api/auth/sso/exchange

鉴权:无(读取 SSO Cookie)

Body

json
{
  "tenantId": "target-tenant-id",
  "createProfile": false,
  "subCompanyId": "optional",
  "rememberMe": false
}

响应{ user, accessToken, refreshToken }

关联已有账号

POST /api/auth/sso/link

鉴权:无(读取 SSO Cookie)

Body

json
{
  "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、用户资料等。

json
[
  {
    "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

json
{
  "tenantId": "tenant-to-unlink",
  "subCompanyId": "optional-sub-company-to-unlink"
}

不可解绑当前门户,也不可解绑最后一个剩余门户。

撤销 SSO 会话(全局登出)

POST /api/auth/sso/logout

鉴权:无(读取 SSO Cookie)

撤销全局 SSO 会话并清除 Cookie。不会单独作废各租户 JWT。

配置

后端 .env

bash
# 用于在子域名间共享 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=30
环境securesameSitehttpOnly
NODE_ENV=developmentfalselaxtrue
NODE_ENV=productiontruelaxtrue

localhost 且租户用路径区分(/tenant-a//tenant-b/)时,SSO_COOKIE_DOMAIN 留空即可,Cookie 在同一主机所有路径共享。

子域名 租户(tenant-a.app.comtenant-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.crossParentPortalInfoparentPortalLinkRequired(子公司用户在母公司 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 一致。
  • 母公司请求(subCompanyId null)若同时存在租户级与子级用户行,服务优先使用租户级 UsersubCompanyId null)发 JWT。
  • 母公司门户关联parentPortalLinkRequired / crossParentPortalLink):子公司用户在母公司 URL 登录时,可为同一全局身份创建或关联母公司层级资料。

跨租户导航顺序

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. 成功后进入首页

账号切换

用户可通过两种方式在公司间切换:

快捷切换(首页头像)

  1. 用户在首页点头像。
  2. HomePage 展示账号切换底部列表。
  3. 子公司显示为 「公司名称 — 子公司名称」
  4. 数据来自 authStore.fetchLinkedTenants()GET /api/auth/me/linked-tenants)。
  5. 点击非当前门户:window.location.href = /{company-slug}/ 或含子公司路径。
  6. 新门户上 SSOProvider 读 Cookie 并自动登录。

完整页(Account → Linked Accounts)

  1. 进入 Account → Linked Accounts
  2. 列表展示 Logo、公司及子公司名。
  3. 在非当前门户点 Switch(箭头)。
  4. 浏览器跳转到 /{company-slug}/ 或含子公司 slug。
  5. SSOProvider 检测 Cookie,找到已关联账号并自动登录。

迁移

初次启用 SSO

对已有部署,运行迁移脚本为存量用户创建 GlobalIdentity

bash
cd vio-v4-api
node scripts/migrate-sso-global-identity.js

脚本会:

  • 找出无 globalIdentityId 的用户
  • 按邮箱/手机分组
  • 创建 GlobalIdentity 并关联现有用户
  • 幂等,可重复执行

按门户键的索引迁移

启用租户内跨子公司 SSO 后需更新索引。旧的 (email, tenantId)(phone, tenantId) 双字段唯一索引需替换为 (email, tenantId, subCompanyId)(phone, tenantId, subCompanyId)

bash
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 ATenant B),以及某一租户内的两个子公司(Sub XSub Y)加母公司 URLsubCompanyId 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 XsourceSubCompanyName),不单是 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-2Sub X 用户 → 母公司登录仅在 A / Sub X 注册;打开 A 母公司 登录parentPortalLinkRequired;或若母公司资料已有关联则直接匹配;确认后母公司级资料;合法关联用户不出现误报 “Access denied”。
SC-3Sub 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 仅在真实检测到租户不一致时生效,不能单靠该参数绕过正常登录限制。

VIO v4 平台文档