# 统一认证平台 - 简易认证 (Simple Auth) 集成指南 本指南适用于需要使用自定义登录页面(而非跳转到认证中心标准页面),并通过后端 API 直接进行用户认证的场景。 --- ## 1. 核心流程图 ```mermaid sequenceDiagram participant User as 用户 participant Client as 您的应用(前端/后端) participant UAP as 统一认证平台 User->>Client: 输入账号/密码 Client->>Client: 生成签名 (Sign) Client->>UAP: POST /api/v1/simple/login (账号+密码+签名) UAP-->>Client: 返回 Ticket (票据) Client->>Client: (内部逻辑处理) Client->>UAP: POST /api/v1/simple/validate (Ticket+签名) UAP-->>Client: 返回 用户信息 (ID, Mobile, etc.) Client->>User: 登录成功 ``` --- ## 2. 前置准备 在调用 API 之前,请确保您已在平台注册应用并获取以下信息: * **App ID (`app_id`)**: 应用唯一标识。 * **App Secret (`app_secret`)**: 应用密钥(**严禁泄露给前端**)。 * **Access Token (`access_token`)**: 应用访问令牌,用于后端 M2M 同步接口鉴权(**严禁泄露给前端**)。 > ⚠️ **安全警告**:由于生成签名需要使用 `App Secret`,建议登录请求由您的**应用后端**发起,或者使用后端代理(BFF模式)。如果在前端(浏览器 JS)直接存储 Secret 并计算签名,极易导致密钥泄露。 --- ## 3. 签名算法 (Signature) 所有涉及安全的接口都需要校验签名。 **签名生成步骤:** 1. **准备参数**:收集所有请求参数(不包括 `sign` 本身)。 2. **排序**:按照参数名(key)的 ASCII 码从小到大排序。 3. **拼接**:将排序后的参数拼接成 `key1=value1&key2=value2...` 格式的字符串。 4. **计算 HMAC**:使用 `App Secret` 作为密钥,对拼接字符串进行 **HMAC-SHA256** 计算。 5. **转十六进制**:将计算结果转换为 Hex 字符串即为签名。 **Python 示例代码:** ```python import hmac import hashlib import time def generate_signature(secret: str, params: dict) -> str: # 1. 过滤掉空值和 sign 字段 (如果有) data = {k: v for k, v in params.items() if k != "sign" and v is not None} # 2. 排序 key sorted_keys = sorted(data.keys()) # 3. 拼接字符串 query_string = "&".join([f"{k}={data[k]}" for k in sorted_keys]) # 4. 计算 HMAC-SHA256 signature = hmac.new( secret.encode('utf-8'), query_string.encode('utf-8'), hashlib.sha256 ).hexdigest() return signature ``` --- ## 4. 接口开发详解 ### 4.1 第一步:密码登录获取票据 (Login) 用户在界面输入账号密码后,调用此接口获取临时票据(Ticket)。 * **接口地址**: `POST /api/v1/simple/login` * **Content-Type**: `application/json` **请求参数 (JSON Body):** | 字段 | 类型 | 必填 | 说明 | | :--- | :--- | :--- | :--- | | `app_id` | string | 是 | 您的应用 ID | | `identifier` | string | 是 | 用户标识(手机号、用户名或邮箱) | | `password` | string | 是 | 用户明文密码 | | `timestamp` | int | 是 | 当前时间戳(秒),有效期 300秒 | | `sign` | string | 是 | 签名字符串 | **请求示例:** ```json { "app_id": "your_app_id", "identifier": "13800138000", "password": "user_password_123", "timestamp": 1709876543, "sign": "a1b2c3d4e5..." } ``` **响应成功 (200 OK):** ```json { "ticket": "TICKET-7f8e9d0a-..." } ``` --- ### 4.2 第二步:验证票据并获取用户信息 (Validate) 拿到 `ticket` 后,立即调用此接口解析出用户身份。 * **接口地址**: `POST /api/v1/simple/validate` * **Content-Type**: `application/json` **请求参数 (JSON Body):** | 字段 | 类型 | 必填 | 说明 | | :--- | :--- | :--- | :--- | | `app_id` | string | 是 | 您的应用 ID | | `ticket` | string | 是 | 上一步获取到的票据 | | `timestamp` | int | 是 | 当前时间戳 | | `sign` | string | 是 | 签名(注意参数变化,需重新计算) | **请求示例:** ```json { "app_id": "your_app_id", "ticket": "TICKET-7f8e9d0a-...", "timestamp": 1709876545, "sign": "f9e8d7c6b5..." } ``` **响应成功 (200 OK):** 此接口返回 `valid: true` 表示票据有效,并附带用户数据。 ```json { "valid": true, "user_id": 1001, "mobile": "13800138000", "mapped_key": "user_zhangsan", // 第三方映射ID(如果有) "mapped_email": "zhangsan@example.com" // 映射邮箱(如果有) } ``` **响应失败 (票据无效或过期):** ```json { "valid": false } ``` --- ### 4.3 票据交换 (Ticket Exchange) [跨应用免登] 当用户已在 **源应用 (Source App)** 登录,需要无缝跳转到 **目标应用 (Target App)** 且实现免登录时,使用此接口。 * **场景**: 用户在 App A 点击 "跳转到 App B",App A 后端调用此接口获取 App B 的票据,然后将用户重定向到 App B。 * **接口地址**: `POST /api/v1/simple/exchange` * **Content-Type**: `application/json` **请求参数 (JSON Body):** | 字段 | 类型 | 必填 | 说明 | | :--- | :--- | :--- | :--- | | `app_id` | string | 是 | **源应用** ID (发起方) | | `target_app_id` | string | 是 | **目标应用** ID (跳转方) | | `user_mobile` | string | 是 | 用户手机号 (作为用户身份标识) | | `timestamp` | int | 是 | 当前时间戳 | | `sign` | string | 是 | 签名 (**使用源应用的 Secret 计算**) | **请求示例:** ```json { "app_id": "source_app_A", "target_app_id": "target_app_B", "user_mobile": "13800138000", "timestamp": 1709876545, "sign": "generated_signature_hex" } ``` **响应成功 (200 OK):** ```json { "ticket": "TICKET-for-target-app-...", "redirect_url": "http://target-app-b.com/sso/callback?ticket=TICKET-..." } ``` * `ticket`: 专门为目标应用生成的登录票据。 * `redirect_url`: 拼接了 ticket 的目标应用重定向地址(取自目标应用配置的 `redirect_uris` 的第一个地址)。 **后续流程:** 1. 源应用前端接收到 `redirect_url`。 2. 浏览器跳转到 `redirect_url`。 3. 目标应用后端接收请求,提取 `ticket`。 4. 目标应用后端调用 `validate` 接口(注意:此时 `app_id` 为目标应用 ID,`sign` 使用目标应用 Secret)来验证票据。 --- ### 4.4 接口对比:/login vs /sso-login 平台提供了两个登录接口,它们在功能上有一定重叠,但**设计目的和使用场景不同**。 #### 4.4.1 `/login` 接口(通用登录) **适用场景:** - ✅ 后端服务器直接调用(推荐使用签名验证) - ✅ 需要签名验证保证安全性的场景 - ✅ 支持所有类型的应用(SIMPLE_API / OIDC) - ✅ 既支持平台登录,也支持应用 SSO 登录 **接口特点:** - **双模式设计**:根据是否提供 `app_id` 返回不同结果 - 不提供 `app_id`:返回平台 `access_token`(用于访问平台管理功能) - 提供 `app_id`:返回应用 `ticket`(用于应用 SSO 登录) - **支持签名验证**:`sign` 和 `timestamp` 可选,但推荐使用 - **返回原始票据**:只返回 `ticket`,需要调用方自己构建跳转 URL **请求参数:** | 字段 | 必填 | 说明 | |------|------|------| | `app_id` | ❌ 选填 | 不提供时为平台登录,提供时为应用 SSO 登录 | | `identifier` | ✅ **必填** | 用户标识:手机号、映射key或映射email | | `password` | ✅ **必填** | 用户密码 | | `sign` | ❌ 选填 | 签名(如果提供,必须同时提供timestamp) | | `timestamp` | ❌ 选填 | 时间戳(如果提供,必须同时提供sign) | **请求示例(应用 SSO 登录):** ```json { "app_id": "your_app_id", "identifier": "13800138000", "password": "user_password_123", "timestamp": 1709876543, "sign": "a1b2c3d4e5..." } ``` **响应示例(应用 SSO 登录):** ```json { "ticket": "TICKET-7f8e9d0a-..." } ``` **请求示例(平台登录):** ```json { "identifier": "13800138000", "password": "admin_password_123" } ``` **响应示例(平台登录):** ```json { "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "token_type": "bearer", "role": "SUPER_ADMIN" } ``` --- #### 4.4.2 `/sso-login` 接口(简易 SSO 登录) **适用场景:** - ✅ 前端浏览器调用(简化跳转流程) - ✅ 支持基于会话的单点登录(用户已在平台登录) - ✅ 快速实现跨应用免登录 - ⚠️ **仅支持 SIMPLE_API 类型的应用**(OIDC 应用请使用标准流程) **接口特点:** - **双认证模式**: 1. **会话认证优先**:如果用户已登录 UAP 平台(携带有效 token),自动使用当前用户身份 2. **凭据认证备用**:如果未登录,可以提供 `username` 和 `password` 进行认证 - **直接返回跳转 URL**:返回完整的 `redirect_url`,前端直接跳转即可 - **无需签名**:简化调用流程,适合前端使用 - **应用类型限制**:只支持 `SIMPLE_API` 应用 **请求参数:** | 字段 | 必填 | 说明 | |------|------|------| | `app_id` | ✅ **必填** | 应用ID(只支持 SIMPLE_API 类型) | | `username` | ⚠️ 条件必填 | 用户名(如果用户未登录UAP,则必须提供) | | `password` | ⚠️ 条件必填 | 密码(如果用户未登录UAP,则必须提供) | **使用场景1:用户已在 UAP 登录(会话认证)** 前端携带平台 token(通过 Authorization 头或 Cookie)调用接口: ```json { "app_id": "my_app_123" } ``` **响应:** ```json { "redirect_url": "https://your-app.com/callback?ticket=TICKET-xxx" } ``` 前端直接跳转: ```javascript // 用户已登录,自动 SSO const response = await fetch('/api/v1/simple/sso-login', { method: 'POST', headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + localStorage.getItem('access_token') }, body: JSON.stringify({ app_id: 'my_app_123' }) }); const { redirect_url } = await response.json(); window.location.href = redirect_url; // 直接跳转 ``` **使用场景2:用户未登录(凭据认证)** 前端提供用户名和密码: ```json { "app_id": "my_app_123", "username": "13800138000", "password": "password123" } ``` **响应:** ```json { "redirect_url": "https://your-app.com/callback?ticket=TICKET-xxx" } ``` 前端直接跳转: ```javascript // 用户未登录,使用凭据登录 const response = await fetch('/api/v1/simple/sso-login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ app_id: 'my_app_123', username: '13800138000', password: 'password123' }) }); const { redirect_url } = await response.json(); window.location.href = redirect_url; // 直接跳转 ``` --- #### 4.4.3 选择建议 | 使用场景 | 推荐接口 | 原因 | |---------|---------|------| | **后端服务器调用** | `/login` | 支持签名验证,安全性更高 | | **前端浏览器跳转** | `/sso-login` | 直接返回跳转 URL,简化流程 | | **单点登录(已登录用户)** | `/sso-login` | 支持会话认证,无需再次输入密码 | | **OIDC 应用** | `/login` 或标准 OIDC 流程 | `/sso-login` 不支持 OIDC | | **需要平台管理功能** | `/login`(不提供 app_id) | 获取平台 access_token | | **跨应用免登录** | `/sso-login` 或 `/exchange` | 取决于是否需要服务端签名 | **核心区别总结:** | 特性 | `/login` | `/sso-login` | |------|----------|--------------| | **认证方式** | 只支持用户名+密码 | 支持会话认证 OR 用户名+密码 | | **签名验证** | ✅ 支持(可选) | ❌ 不支持 | | **应用类型限制** | ❌ 无限制 | ✅ 只支持 SIMPLE_API | | **返回值** | `ticket` 或 `access_token` | `redirect_url`(含 ticket) | | **会话支持** | ❌ 不支持 | ✅ 支持(优先使用) | | **适用端** | 后端服务器 | 前端浏览器 | | **使用复杂度** | 中等(需要自己构建跳转) | 简单(直接跳转) | --- ### 4.5 账号同步 (M2M) 此接口用于将外部业务系统(如 OA、CRM)的用户账号关系同步到本平台。支持批量调用,实现“本平台用户(手机号)”与“外部应用账号(ID/邮箱)”的绑定。 * **接口地址**: `POST /api/v1/apps/mapping/sync` * **认证方式**: 请求头需包含 `X-App-Access-Token`(可在应用详情页查看)。 **接口逻辑:** 1. 根据 `mobile` 查找用户。如果用户不存在,**自动创建新用户**(生成随机密码,默认激活)。 2. 将该用户与当前应用建立映射关系(绑定 `mapped_key` 和 `mapped_email`)。 3. 如果映射已存在,则更新映射信息。 **请求参数 (JSON Body):** | 字段 | 类型 | 必填 | 说明 | | :--- | :--- | :--- | :--- | | `mobile` | string | **是** | 用户手机号(平台唯一标识) | | `mapped_key` | string | 否 | 外部系统中的用户ID(在该应用下唯一) | | `mapped_email` | string | 否 | 外部系统中的邮箱(在该应用下唯一) | | `is_active` | boolean | 否 | 映射关系状态(`true`启用,`false`禁用) | | `password` | string | 否 | (忽略)仅用于占位,不会更新用户密码 | | `status` | string | 否 | (忽略)仅用于占位 | **请求示例:** ```bash curl -X POST "http://your-uap-domain/api/v1/apps/mapping/sync" \ -H "Content-Type: application/json" \ -H "X-App-Access-Token: YOUR_APP_ACCESS_TOKEN" \ -d '{ "mobile": "13800138000", "mapped_key": "user_1001", "mapped_email": "zhangsan@example.com", "is_active": true }' ``` **响应成功 (200 OK):** ```json { "id": 123, "app_id": 1, "user_id": 456, "mapped_key": "user_1001", "mapped_email": "zhangsan@example.com", "user_mobile": "13800138000", "user_status": "ACTIVE", "is_active": true } ``` **响应失败:** * `400 Bad Request`: `mapped_key` 或 `mapped_email` 已被其他用户占用。 * `403 Forbidden`: Access Token 无效或过期。 --- ## 5. 完整调用示例 (Python) 这是一个模拟客户端的完整脚本,演示如何登录并获取数据: ```python import requests import time import hmac import hashlib import json # 配置信息 API_BASE = "http://localhost:8000/api/v1/simple" APP_ID = "test_app_001" APP_SECRET = "secret_key_abc123" # 务必保密 def get_sign(params): # 排除 sign 字段 data = {k: v for k, v in params.items() if k != "sign"} # 排序并拼接 query_string = "&".join([f"{k}={data[k]}" for k in sorted(data.keys())]) # HMAC-SHA256 return hmac.new(APP_SECRET.encode(), query_string.encode(), hashlib.sha256).hexdigest() def main(): # === 步骤 1: 登录获取 Ticket === login_ts = int(time.time()) login_payload = { "app_id": APP_ID, "identifier": "13800000001", "password": "password123", "timestamp": login_ts } # 计算签名 login_payload["sign"] = get_sign(login_payload) print(f"1. 正在尝试登录: {login_payload['identifier']} ...") resp = requests.post(f"{API_BASE}/login", json=login_payload) if resp.status_code != 200: print(f"登录失败: {resp.text}") return ticket = resp.json().get("ticket") print(f"登录成功! 获取到 Ticket: {ticket}") # === 步骤 2: 使用 Ticket 换取用户信息 === validate_ts = int(time.time()) validate_payload = { "app_id": APP_ID, "ticket": ticket, "timestamp": validate_ts } # 重新计算签名(参数变了,签名必须重算) validate_payload["sign"] = get_sign(validate_payload) print(f"\n2. 正在验证 Ticket...") v_resp = requests.post(f"{API_BASE}/validate", json=validate_payload) user_info = v_resp.json() if user_info.get("valid"): print("验证成功! 用户信息如下:") print(json.dumps(user_info, indent=2, ensure_ascii=False)) else: print("Ticket 无效或已过期") if __name__ == "__main__": main() ```