本指南适用于需要使用自定义登录页面(而非跳转到认证中心标准页面),并通过后端 API 直接进行用户认证的场景。
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: 登录成功
在调用 API 之前,请确保您已在平台注册应用并获取以下信息:
app_id): 应用唯一标识。app_secret): 应用密钥(严禁泄露给前端)。access_token): 应用访问令牌,用于后端 M2M 同步接口鉴权(严禁泄露给前端)。⚠️ 安全警告:由于生成签名需要使用
App Secret,建议登录请求由您的应用后端发起,或者使用后端代理(BFF模式)。如果在前端(浏览器 JS)直接存储 Secret 并计算签名,极易导致密钥泄露。
所有涉及安全的接口都需要校验签名。
签名生成步骤:
sign 本身)。key1=value1&key2=value2... 格式的字符串。App Secret 作为密钥,对拼接字符串进行 HMAC-SHA256 计算。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
用户在界面输入账号密码后,调用此接口获取临时票据(Ticket)。
POST /api/v1/simple/loginapplication/json请求参数 (JSON Body):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
app_id |
string | 是 | 您的应用 ID |
identifier |
string | 是 | 用户标识(手机号、用户名或邮箱) |
password |
string | 是 | 用户明文密码 |
timestamp |
int | 是 | 当前时间戳(秒),有效期 300秒 |
sign |
string | 是 | 签名字符串 |
请求示例:
{
"app_id": "your_app_id",
"identifier": "13800138000",
"password": "user_password_123",
"timestamp": 1709876543,
"sign": "a1b2c3d4e5..."
}
响应成功 (200 OK):
{
"ticket": "TICKET-7f8e9d0a-..."
}
拿到 ticket 后,立即调用此接口解析出用户身份。
POST /api/v1/simple/validateapplication/json请求参数 (JSON Body):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
app_id |
string | 是 | 您的应用 ID |
ticket |
string | 是 | 上一步获取到的票据 |
timestamp |
int | 是 | 当前时间戳 |
sign |
string | 是 | 签名(注意参数变化,需重新计算) |
请求示例:
{
"app_id": "your_app_id",
"ticket": "TICKET-7f8e9d0a-...",
"timestamp": 1709876545,
"sign": "f9e8d7c6b5..."
}
响应成功 (200 OK):
此接口返回 valid: true 表示票据有效,并附带用户数据。
{
"valid": true,
"user_id": 1001,
"mobile": "13800138000",
"mapped_key": "user_zhangsan", // 第三方映射ID(如果有)
"mapped_email": "zhangsan@example.com" // 映射邮箱(如果有)
}
响应失败 (票据无效或过期):
{
"valid": false
}
当用户已在 源应用 (Source App) 登录,需要无缝跳转到 目标应用 (Target App) 且实现免登录时,使用此接口。
POST /api/v1/simple/exchangeapplication/json请求参数 (JSON Body):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
app_id |
string | 是 | 源应用 ID (发起方) |
target_app_id |
string | 是 | 目标应用 ID (跳转方) |
user_mobile |
string | 是 | 用户手机号 (作为用户身份标识) |
timestamp |
int | 是 | 当前时间戳 |
sign |
string | 是 | 签名 (使用源应用的 Secret 计算) |
请求示例:
{
"app_id": "source_app_A",
"target_app_id": "target_app_B",
"user_mobile": "13800138000",
"timestamp": 1709876545,
"sign": "generated_signature_hex"
}
响应成功 (200 OK):
{
"ticket": "TICKET-for-target-app-...",
"redirect_url": "http://target-app-b.com/sso/callback?ticket=TICKET-..."
}
ticket: 专门为目标应用生成的登录票据。redirect_url: 拼接了 ticket 的目标应用重定向地址(取自目标应用配置的 redirect_uris 的第一个地址)。后续流程:
redirect_url。redirect_url。ticket。validate 接口(注意:此时 app_id 为目标应用 ID,sign 使用目标应用 Secret)来验证票据。平台提供了两个登录接口,它们在功能上有一定重叠,但设计目的和使用场景不同。
/login 接口(通用登录)适用场景:
接口特点:
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 登录):
{
"app_id": "your_app_id",
"identifier": "13800138000",
"password": "user_password_123",
"timestamp": 1709876543,
"sign": "a1b2c3d4e5..."
}
响应示例(应用 SSO 登录):
{
"ticket": "TICKET-7f8e9d0a-..."
}
请求示例(平台登录):
{
"identifier": "13800138000",
"password": "admin_password_123"
}
响应示例(平台登录):
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "bearer",
"role": "SUPER_ADMIN"
}
/sso-login 接口(简易 SSO 登录)适用场景:
接口特点:
username 和 password 进行认证redirect_url,前端直接跳转即可SIMPLE_API 应用请求参数:
| 字段 | 必填 | 说明 |
|---|---|---|
app_id |
✅ 必填 | 应用ID(只支持 SIMPLE_API 类型) |
username |
⚠️ 条件必填 | 用户名(如果用户未登录UAP,则必须提供) |
password |
⚠️ 条件必填 | 密码(如果用户未登录UAP,则必须提供) |
使用场景1:用户已在 UAP 登录(会话认证)
前端携带平台 token(通过 Authorization 头或 Cookie)调用接口:
{
"app_id": "my_app_123"
}
响应:
{
"redirect_url": "https://your-app.com/callback?ticket=TICKET-xxx"
}
前端直接跳转:
// 用户已登录,自动 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:用户未登录(凭据认证)
前端提供用户名和密码:
{
"app_id": "my_app_123",
"username": "13800138000",
"password": "password123"
}
响应:
{
"redirect_url": "https://your-app.com/callback?ticket=TICKET-xxx"
}
前端直接跳转:
// 用户未登录,使用凭据登录
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; // 直接跳转
| 使用场景 | 推荐接口 | 原因 |
|---|---|---|
| 后端服务器调用 | /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) |
| 会话支持 | ❌ 不支持 | ✅ 支持(优先使用) |
| 适用端 | 后端服务器 | 前端浏览器 |
| 使用复杂度 | 中等(需要自己构建跳转) | 简单(直接跳转) |
此接口用于将外部业务系统(如 OA、CRM)的用户账号关系同步到本平台。支持批量调用,实现“本平台用户(手机号)”与“外部应用账号(ID/邮箱)”的绑定。
POST /api/v1/apps/mapping/syncX-App-Access-Token(可在应用详情页查看)。接口逻辑:
mobile 查找用户。如果用户不存在,自动创建新用户(生成随机密码,默认激活)。mapped_key 和 mapped_email)。请求参数 (JSON Body):
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
mobile |
string | 是 | 用户手机号(平台唯一标识) |
mapped_key |
string | 否 | 外部系统中的用户ID(在该应用下唯一) |
mapped_email |
string | 否 | 外部系统中的邮箱(在该应用下唯一) |
is_active |
boolean | 否 | 映射关系状态(true启用,false禁用) |
password |
string | 否 | (忽略)仅用于占位,不会更新用户密码 |
status |
string | 否 | (忽略)仅用于占位 |
请求示例:
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):
{
"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 无效或过期。这是一个模拟客户端的完整脚本,演示如何登录并获取数据:
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()