# LicenseManager Platform - SSO 单点登录对接指南

| 项目 | 内容 |
| --- | --- |
| 文档版本 | v1.1 |
| 文档类型 | 对接指南 (SSO Integration Guide) |
| 关联文档 | [Deployment Guide](./LicenseManager-Platform-Deployment-Guide-v1.0.md) · [API Spec](./LicenseManager-Platform-API-Spec-v1.0.md) · [Security Design](./LicenseManager-Platform-Security-Design-v1.0.md) · [Integration Guide](./LicenseManager-Platform-Integration-Guide-v1.0.md) |
| 目标读者 | 运维工程师、集成工程师、企业 IdP 管理员、安全工程师 |
| 文档日期 | 2026-10-06 |

---

## 0. 修订历史

| 版本 | 日期 | 修订内容 |
| --- | --- | --- |
| v1.0 | 2026-10-06 | 初稿：覆盖 OIDC PKCE 与 SAML 2.0 双协议、管理端/门户端双端接入、配置参考、安全机制与排错 FAQ |
| v1.1 | 2026-10-06 | 新增常用 IdP 模板自动填充说明；追加第 10 节：钉钉 / 飞书 / 企业微信扫码登录适配器规划 |

---

## 1. 概述

LicenseManager Platform 支持通过外部身份提供商（IdP）实现登录页 **SSO 免密直登**：

- **协议**：OIDC（Authorization Code Flow + PKCE S256 + nonce）与 SAML 2.0（SP 发起，Redirect/Post 绑定）。
- **登录端（surface）**：管理端（`/v1/auth/sso/*`）与用户门户端（`/v1/portal/auth/sso/*`）各自独立，state 会话按端隔离，不可跨端兑换。
- **JIT 开通**：可选。首次 SSO 登录时按邮箱自动创建本地用户并建立绑定；关闭时仅允许已绑定用户登录。
- **安全模型**：所有匿名端点 fail-closed——state 会话一次性消费、SAML 断言强验签、token 经一次性 ticket 回传，token 永不进入 IdP 回调 URL。

### 1.1 角色与端点矩阵

| 端点 | 方法 | 端 | 认证 | 用途 |
| --- | --- | --- | --- | --- |
| `/v1/auth/sso/providers` | GET | 管理端 | 匿名 | 登录页渲染 SSO 按钮列表（最小公开字段） |
| `/v1/auth/sso/initiate` | POST | 管理端 | 匿名 | 发起登录，返回 IdP 跳转信息 |
| `/v1/auth/sso/callback` | POST | 管理端 | 匿名 | OIDC 授权码回调（code + state） |
| `/v1/auth/sso/saml/acs` | POST | 管理端 | 匿名 | SAML ACS（IdP 浏览器表单直投） |
| `/v1/auth/sso/redeem` | POST | 管理端 | 匿名 | 兑换一次性 ticket（SAML） |
| `/v1/portal/auth/sso/*` | 同上 | 门户端 | 匿名 | 与管理端一一对应 |
| `/v1/admin/sso-providers` | CRUD | 管理端 | Bearer JWT（admin 角色） | SSO 提供商管理 |
| `/v1/admin/sso/link`、`/v1/admin/sso/callback` | POST | 管理端 | Bearer JWT | 用户绑定管理与绑定测试 |

> 匿名端点已加入 `PublicPathRegistry` 白名单；全局按 IP 限流（默认 100 次/分钟）对其生效。

---

## 2. 登录时序

### 2.1 OIDC（Authorization Code + PKCE + nonce）

```text
浏览器                     后端(SP)                      IdP
  │ POST /sso/initiate ───▶ │                            │
  │                         │ 生成 state/verifier/nonce   │
  │                         │ state 会话入缓存(TTL 5min)   │
  │ ◀── authorization_url ── │                            │
  │ ──────── 302 跳转 authorization_url ────────────────▶ │
  │                         │      ◀── 用户认证 ──        │
  │ ◀── 302 {redirect_uri}?code&state ──────────────────  │  (redirect_uri = 前端 /sso/callback)
  │ POST /sso/callback {code,state} ─▶ │                  │
  │                         │ 取走 state 会话(一次性)      │
  │                         │ ── code+verifier 换 token ▶ │
  │                         │ ◀─ id_token ──             │
  │                         │ 校验签名/iss/aud/exp/nonce  │
  │                         │ 绑定匹配或 JIT 开通          │
  │ ◀── { token, user, return_path } ──                   │
```

### 2.2 SAML 2.0（SP 发起）

```text
浏览器                     后端(SP)                      IdP
  │ POST /sso/initiate ───▶ │                            │
  │                         │ 构建 AuthnRequest，登记      │
  │                         │ RequestId(RelayState=state) │
  │ ◀── redirect URL 或 post 表单参数 ──                   │
  │ ──────── AuthnRequest(SAMLRequest) ─────────────────▶ │
  │                         │      ◀── 用户认证 ──        │
  │ ◀── 浏览器自动 POST SAMLResponse 至 ACS ────────────── │
  │                         │ RelayState 反查 RequestId   │
  │                         │ 验签/audience/InResponseTo/ │
  │                         │ 时间条件，全部 fail-closed   │
  │                         │ 绑定匹配或 JIT 开通，签发 JWT │
  │ ◀── 302 {前端}/sso/callback?ticket=xxx ──             │  (ticket 一次性, TTL 60s)
  │ POST /sso/redeem {ticket} ─▶ 即烧即毁 ─▶ { token, user } │
```

---

## 3. 部署前置

### 3.1 数据库迁移

SAML 支持依赖 `sso_providers` 表新增的 6 个可空列（迁移 `AddSsoSamlFields`，已覆盖全部 5 库方言）：

| 列 | 用途 |
| --- | --- |
| `idp_entity_id` | IdP 实体标识（Issuer） |
| `idp_sso_url` | IdP 单点登录端点 |
| `idp_certificate` | IdP 签名证书（PEM 或裸 base64） |
| `sp_entity_id` | 本系统 SP 实体标识 |
| `name_id_format` | NameID 策略格式 |
| `saml_binding` | 绑定方式：`Redirect` / `Post` |

执行方式与既有迁移一致（`dotnet ef database update` 或启动自动迁移），参见部署手册数据库迁移章节。**升级前请先备份 `sso_providers` 表。**

### 3.2 配置键

`appsettings.json`（或环境变量）中新增：

| 键 | 必填 | 说明 |
| --- | --- | --- |
| `Sso:AdminFrontendBaseUrl` | 生产必填 | 管理端前端基址，如 `https://admin.example.com`。SAML ACS 成功/失败后 302 回 `{基址}/sso/callback` |
| `Sso:PortalFrontendBaseUrl` | 生产必填 | 门户端前端基址，如 `https://portal.example.com` |
| `Sso:FrontendBaseUrl` | 可选 | 两端通用回退键；未配置端专属键时使用 |
| `Saml:SpCertificatePath` | 可选 | SP 签名证书（PFX）路径；配置后 AuthnRequest 将签名 |
| `Saml:SpCertificatePassword` | 可选 | SP 证书密码 |

回退链：`端专属键` → `Sso:FrontendBaseUrl` → **当前请求 Host**。生产环境必须显式配置前端基址，禁止依赖 Host 回退（防 Host 头注入导致的重定向不可控）。

### 3.3 部署形态约束

state 会话、SAML RequestId 登记与一次性 ticket 均存储在进程内 `IMemoryCache`。**多实例/负载均衡部署时**，同一用户的 initiate 与回调可能落到不同实例导致登录失败，需将缓存替换为分布式实现（Redis）后再启用 SAML 与 OIDC 直登。

---

## 4. OIDC 对接步骤

### 4.1 IdP 侧注册应用

在 IdP（Azure AD / Keycloak / Okta / Auth0 等）注册一个「Web」类型客户端：

| IdP 配置项 | 取值 |
| --- | --- |
| Redirect URI | `https://{管理端前端域名}/sso/callback`（门户端为 `https://{门户前端域名}/sso/callback`） |
| 授权类型 | Authorization Code；启用 PKCE（允许无 secret 的公共客户端亦可，服务端会回传 verifier） |
| Client Secret | 如 IdP 强制 confidential client 则创建并记录 |
| Scope | `openid profile email`（默认；可在提供商配置中覆盖） |

### 4.2 管理端创建提供商

管理后台 → 集成认证 → SSO Providers → 新建。选择 **Azure AD / Okta / Auth0 / Keycloak / Google Workspace** 时，表单会按内置模板自动填充 Issuer URL 与授权范围，只需补齐对接参数（租户 ID / 域名 / Realm）与 Client ID/Secret 即可启用；回调地址自动建议为当前站点的 `/sso/callback`。手工填写 Issuer URL 后自动填充停止，Generic 与 SAML 保持全手填。

各字段说明：

| 字段 | 说明 | 示例 |
| --- | --- | --- |
| 名称 | 登录按钮展示名 | `Corp Azure AD` |
| 类型 | OIDC 类：AzureAD / Google / OIDC | `AzureAD` |
| Issuer URL | IdP 发现根地址（不含 `/.well-known/openid-configuration`） | `https://login.microsoftonline.com/{tenant}/v2.0` |
| Client ID | IdP 签发的客户端标识 | `xxxxxxxx` |
| Client Secret | IdP 签发的密钥（服务端 `SecretProtector` 加密存储；编辑留空表示不修改） | |
| Scopes | 空格分隔，缺省 `openid profile email` | |
| Redirect URI | 必须与 IdP 注册值一致（前端回调页地址） | `https://admin.example.com/sso/callback` |
| Domain Hint | 可选，登录按钮提示 | `@corp.com` |
| JIT 开通 | 开启后未绑定用户按邮箱自动建户 | |
| 启用 | 关闭后登录页不展示、回调拒绝 | |

保存后登录页即出现 SSO 按钮。

### 4.3 验证

1. 登录页点击 SSO 按钮 → 跳转 IdP 认证。
2. 认证成功回到 `/sso/callback`，页面短暂「处理中」后进入 Dashboard。
3. 若返回 `SSO_USER_NOT_LINKED` 且未开 JIT：先在 SSO Providers 页完成用户绑定（或开启 JIT）。

---

## 5. SAML 2.0 对接步骤

### 5.1 管理端创建提供商

| 字段 | 说明 | 示例 |
| --- | --- | --- |
| 类型 | `Saml` | |
| IdP Entity ID | IdP 的 Issuer/EntityID | `https://idp.corp.com/entity` |
| IdP SSO URL | IdP 单点登录端点 | `https://idp.corp.com/sso` |
| IdP Certificate | IdP 签名 X.509 证书，支持 PEM 或裸 base64 | `-----BEGIN CERTIFICATE-----...` |
| SP Entity ID | 本系统实体标识，**必须与 IdP 侧登记一致**（同时作为 audience 校验值） | `https://sp.example.com/metadata` |
| NameID Format | 可选 | `urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress` |
| SAML Binding | `Redirect`（默认）或 `Post` | `Post` |

### 5.2 IdP 侧登记 SP

| IdP 配置项 | 取值 |
| --- | --- |
| SP Entity ID / Audience | 管理端填写的 `SP Entity ID` |
| ACS URL（管理端） | `https://{API基址}/v1/auth/sso/saml/acs` |
| ACS URL（门户端） | `https://{API基址}/v1/portal/auth/sso/saml/acs` |
| 绑定 | HTTP-POST（ACS 仅接受 POST 表单） |
| NameID | 与提供商配置的 NameID Format 一致 |
| 断言属性（建议） | `email`/`mail`、`name`/`displayName`；Subject 优先取 NameIdentifier，回退 Email、Name |

签名要求：**响应或断言必须由登记的 IdP 证书签名**；证书轮换时同步更新提供商配置。

### 5.3 可选：SP 签名 AuthnRequest

若 IdP 要求 AuthnRequest 签名，配置 `Saml:SpCertificatePath/Password` 后重启服务，AuthnRequest 将自动签名。

### 5.4 验证

1. 登录页点击 SSO 按钮 → AuthnRequest 提交至 IdP。
2. 认证成功后浏览器携带 `ticket` 回到 `/sso/callback`，自动兑换登录。
3. 失败时回调页展示错误码（见第 8 节 FAQ）。

---

## 6. JIT 开通与角色

| 规则 | 管理端 | 门户端 |
| --- | --- | --- |
| 已绑定用户 | 按 `SsoUserBinding`（provider + subject）匹配登录 | 同左 |
| 未绑定 + JIT 关闭 | 拒绝：`SSO_USER_NOT_LINKED` | 同左 |
| 未绑定 + JIT 开启 | 按**邮箱**复用既有用户，否则新建用户并绑定 | 同左 |
| 无角色用户 | **拒绝登录**（`auth.no_role_assigned`），防越权 | 默认授予最小角色 `customer`；已有角色则沿用 |

> 安全提示：JIT 按邮箱匹配会自动接管同邮箱既有账号。请确保 IdP 对邮箱做过验证（如 Azure AD/Google 默认已验证）；不信任的 IdP 建议关闭 JIT，改为管理员显式绑定。

登录签发与邮箱密码登录完全一致：JWT（8 小时）、admin 角色全权限点、非 admin 取角色权限点并集；SSO 登录不计入密码错误锁定，但正常受全局 IP 限流约束。

---

## 7. 安全机制

| 机制 | 参数 | 说明 |
| --- | --- | --- |
| state 会话 | TTL 5 分钟、一次性、surface + provider 绑定 | 无 state / 过期 / 跨端 / 跨提供商一律 401（fail-closed） |
| PKCE | S256，verifier 32 字节随机 | 防授权码拦截重放 |
| OIDC nonce | 32 字节随机，随授权 URL 下发 | ID Token 回显校验，绑定授权请求与 token 响应 |
| SAML 校验 | InResponseTo 精确匹配、audience=SpEntityId、仅信任登记证书、时间条件 | 任一失败拒绝；不支持 IdP 发起的未授权（unsolicited）响应 |
| 一次性 ticket | TTL 60 秒、即烧即毁 | token 不进入浏览器 URL 历史/Referer |
| return_path | 仅接受「以 `/` 开头且非 `//`」的站内相对路径 | 前端回调页执行，防开放重定向 |
| Client Secret | `SecretProtector` 加密落库 | 管理接口永不下发明文与密文字段 |
| 公开字段白名单 | providers 列表仅返回 id/name/type/domainHint/buttonLabel/logoUrl | 不泄露 issuer/clientId/证书 |

---

## 8. 排错 FAQ

| 现象/错误码 | 可能原因与处理 |
| --- | --- |
| `SSO_STATE_EXPIRED`（401） | 发起与回调间隔超过 5 分钟；服务重启清空内存会话；多实例部署会话不共享（见 3.3）。重新发起即可 |
| `SSO_STATE_MISMATCH`（401） | state 与提供商/端不匹配：检查是否用 admin 链接兑 portal 回调、是否存在多个同名提供商串用 |
| `SSO_IDENTITY_UNRESOLVED`（401） | 授权码交换或 ID Token 校验失败：核对 Issuer URL、Client ID/Secret、Redirect URI 与 IdP 注册一致；查看服务端日志 `OIDC code exchange failed` / `ID Token validation failed` |
| `SSO_USER_NOT_LINKED`（401） | 无绑定且 JIT 关闭：在管理端完成用户绑定或开启 JIT |
| `auth.no_role_assigned`（401/302 error） | 管理端 SSO 用户无角色：为该用户分配角色 |
| `sso.saml_validation_failed`（302 error） | 验签失败：IdP 证书配置错误/轮换未同步；audience 与 SP Entity ID 不一致；时钟偏差过大（时间条件校验失败） |
| `sso.ticket_invalid`（400） | ticket 超过 60 秒、已兑换或服务重启：从登录页重新发起 |
| IdP 回调 404 | IdP 的 Redirect URI / ACS URL 与实际不一致；或网关未放行匿名端点 |
| 登录成功但被踢回登录页 | 前端域名与 `Sso:*FrontendBaseUrl` 不一致导致跨域 cookie/storage 问题；检查 401 拦截日志 |

服务端关键日志检索关键字：`sso_callback.state_*`、`sso_callback.surface_mismatch`、`SAML response validation failed`、`OIDC code exchange failed`。

---

## 9. 附录：核心请求/响应示例

```text
# 发起（OIDC 与 SAML 同一入口，按提供商类型返回不同结构）
POST /v1/auth/sso/initiate
{ "provider_id": "…", "return_path": "/products" }

200 OK（OIDC / SAML Redirect）
{ "mode": "redirect", "authorizationUrl": "https://idp…/authorize?…state=…&nonce=…&code_challenge=…", "state": "…" }

200 OK（SAML Post）
{ "mode": "post", "postUrl": "https://idp…/sso", "samlRequest": "<base64>", "relayState": "…" }

# OIDC 回调（前端回调页调用）
POST /v1/auth/sso/callback
{ "code": "…", "state": "…" }
→ 200 { "token": "…", "user": { … }, "return_path": "/products" }
→ 401 { "error": { "code": "SSO_STATE_EXPIRED", … } }

# ticket 兑换（SAML 回调页调用）
POST /v1/auth/sso/redeem
{ "ticket": "…" }
→ 200 { "token": "…", "user": { … } }
→ 400 { "error": { "code": "sso.ticket_invalid", … } }
```

> 前端已内置键名双向转换（snake_case ↔ camelCase），对接方按上述原文处理即可。

---

## 10. 后续规划：钉钉 / 飞书 / 企业微信扫码登录

> 本节为规划（未实现）。三家平台为自有 OAuth2 扫码授权协议，端点与参数以各平台官方最新文档为准。

### 10.1 协议差异与结论

钉钉、飞书、企业微信的扫码登录**不是标准 OIDC**：无 `/.well-known/openid-configuration` 发现端点、不返回可验签的 JWT ID Token。现有 `OidcClient`（discovery + JWKS 验签 + nonce 回显）不适用，需要新增专用适配器；而 state 会话（TTL 5 分钟、一次性、surface 绑定）、用户绑定、JIT、一次性 ticket 等**既有机制全部复用，前端登录页与回调页零改动**。

### 10.2 各平台对接要点

| 平台 | 授权入口 | 换取身份 | 所需凭据 | 身份标识 |
| --- | --- | --- | --- | --- |
| 钉钉 | `login.dingtalk.com/oauth2/auth`（authCode 模式） | authCode → userAccessToken（`/v1.0/oauth2/userAccessToken`）→ `GET /v1.0/contact/users/me` | AppKey + AppSecret（可复用 ClientId/ClientSecret） | openId / unionId、email、nickName |
| 飞书 | `open.feishu.cn/open-interfaces/authen/v1/authorize` | code → user_access_token（`/authen/v1/oidc/access_token`）→ `GET /authen/v1/user_info` | App ID + App Secret（可复用） | open_id / union_id、email、name |
| 企业微信（自建应用） | `login.work.qq.com/wwopen/sso/qrConnect` | code → `getuserinfo`（先 `gettoken` 拿应用 access_token） | CorpID + AgentID + Secret（**需新增 CorpId/AgentId 字段**） | userid、email（需通讯录读权限） |

### 10.3 架构方案

1. **身份解析分派**：新增平台抽象 `ISsoIdentityResolver`，按 `SsoProviderType` 分派——标准 OIDC 走 `IOidcClient`，扫码类走各适配器，统一输出 `Subject/Email/Name` 三元组。`SsoService` 的绑定匹配、JIT、审计逻辑零改动。
2. **枚举扩展**：`SsoProviderType` 追加 `DingTalk=7、Feishu=8、WeCom=9`（仅追加，向后兼容既有数据）。
3. **实体扩展**：参照 SAML 6 列的迁移模式（5 库 `AddSsoQrProviders`），新增可空列 `CorpId`、`AgentId`（企业微信必需）；钉钉/飞书凭据复用 ClientId/ClientSecret。
4. **发起与回调**：适配器生成授权 URL（含既有 state），回调仍统一 `code + state → POST /sso/callback`，state 会话机制不变。
5. **安全约定**：code → token → userinfo 全程服务端侧完成，secret 不出网；userinfo 响应校验企业/应用归属（tenantId 绑定）；扫码类提供商**建议默认关闭 JIT**（外部邮箱未必经过验证），由管理员显式绑定。

### 10.4 实施阶段

| 阶段 | 内容 | 改动面 |
| --- | --- | --- |
| 已完成（本期） | 5 家标准 OIDC 模板自动填充（管理端表单） | 仅前端 + i18n，零后端改动 |
| 阶段三 | `ISsoIdentityResolver` 抽象 + 钉钉适配器 + 实体迁移 + 单测（fake HTTP）与 E2E | 后端 + 管理端表单 |
| 阶段四 | 飞书、企业微信适配器（企微含 CorpId/AgentId 迁移） | 后端 + 管理端表单 |

每阶段交付需满足既有工程约定：i18n 中英成对并通过校验脚本、E2E（admin/portal）、全量 `dotnet test` 基线、CHANGELOG 更新。
