第三方对接指南
5 步完成 OAuth 2.1 + PKCE 对接,附可运行完整 demo。
统一接口标准:云集生态的官方标准 = OAuth 2.1 + OIDC(覆盖网站/APP/小程序,家族产品互联唯一固定协议)。本文是标准对接的实操指南。完整对比见 对接方式总览。
流程一览
你的应用 云集开放生态 用户
│ │ │
│── 1. 构造授权 URL ────────→│ │
│ (含 PKCE challenge) │ │
│ │── 2. 跳转授权页 ───────→│
│ │←── 3. 用户扫码/授权 ────│
│←── 4. 带 code 回调 ────────│ │
│── 5. code + verifier 换 token →│ │
│←── 返回 access_token ──────│ │
│ │ │
└─ 登录完成,用 access_token 调 userinfo
准备材料
在 um.yunjii.cn/console/apps 创建应用,获得:
| 材料 | 说明 | 示例 |
|---|---|---|
appid | 应用 ID(即 OAuth client_id) | 1001 |
appkey | 应用密钥(即 client_secret,切勿泄露) | a3f8b2c9d1e7... |
| 回调域名 | 你的回调地址域名 | yourapp.com |
接入域名:um 与 open 等价
OAuth 端点在两个域名上同时可用、完全等价,按品牌需要任选(本文统一写 um.yunjii.cn):
| 域名 | 定位 | Discovery |
|---|---|---|
um.yunjii.cn | 身份服务主站 | https://um.yunjii.cn/.well-known/openid-configuration |
open.yunjii.cn | 开放平台品牌化入口 | https://open.yunjii.cn/.well-known/openid-configuration |
Discovery 按访问域名自适应返回 issuer 与全部端点:在哪个域名读 discovery,返回的端点就是哪个域名。用通用 OAuth 库时,issuer 填哪个域名,redirect_uri 就用哪个域名的回调,保持一致即可。
5 步对接(OAuth 2.1 + PKCE)
构造 OAuth 2.1 授权 URL,引导用户跳转到 UM 授权页。公开客户端(SPA/小程序)必须启用 PKCE。
手动构造:
GET https://um.yunjii.cn/oauth/authorize
| 参数 | 必填 | 说明 |
|---|---|---|
response_type | ✅ | 固定 code |
client_id | ✅ | 应用 ID |
redirect_uri | ✅ | 回调地址(域名需在控制台配置的 domains 中) |
state | ✅ | 防 CSRF,随机字符串,原样返回 |
scope | ⬜ | openid profile(默认) |
code_challenge | ⬜ | PKCE challenge(推荐启用) |
code_challenge_method | ⬜ | S256 |
PHP SDK 一行生成:
$state = bin2hex(random_bytes(16));
$verifier = $um->generateCodeVerifier(); // PKCE
$authUrl = $um->authorizeUrl($state, $verifier); // 自动算 challenge
header("Location: $authUrl");
JS SDK:
const verifier = UM.generateCodeVerifier();
const challenge = await UM.generateCodeChallenge(verifier);
sessionStorage.setItem('um_pkce', verifier);
location.href = UM.authorizeUrl(state, challenge);
PKCE 流程:客户端生成 code_verifier(随机串)→ 计算 code_challenge = base64url(SHA256(verifier)) → 授权请求带 challenge → 换 token 时带 verifier → 服务端校验匹配。防止授权码被中间人劫持。
用户在 UM 授权页完成扫码(微信/支付宝)或登录(邮箱/密码)。
这一步你不需要做任何事,UM 会自动处理。
用户授权后,UM 会带 code 和 state 跳转回你的 redirect_uri:
https://yourapp.com/callback?code=CODE_xxx&state=你传的state
你需要:
- 验证
state与第一步生成的是否一致(防 CSRF) - 取出
code,进入下一步
code 5 分钟内只能用一次,过期或重复消费返回 errcode=40163。
接口:POST https://um.yunjii.cn/oauth/token.php
参数:
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | ✅ | authorization_code |
code | ✅ | 上一步拿到的 code |
client_id | ✅ | 应用 ID |
client_secret | ✅ | 应用密钥(appkey) |
redirect_uri | ✅ | 必须与授权时一致 |
code_verifier | ⬜ | PKCE verifier(启用 PKCE 时必传) |
响应:
{
"code": 0,
"msg": "succ",
"access_token": "eyJhbGciOiJIUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 604800,
"refresh_token": "rt_a1b2c3d4e5f6...",
"user": {
"id": 12345,
"openid": "um_a1b2c3d4e5f6",
"nickname": "张三",
"avatar": "https://thirdwx.qlogo.cn/...",
"login_type": "wx"
}
}
字段说明:
| 字段 | 说明 |
|---|---|
access_token | 访问令牌(7 天有效),后续 API 调用凭证 |
refresh_token | 刷新令牌(30 天,轮换制) |
user.openid | 应用内用户唯一 ID(同应用不变) |
user.id | 用户内部 ID |
user.login_type | 登录方式(wx / alipay / qq / douyin / email / self) |
OAuth 2.1 标准端点用 code: 0 表示成功(旧版 REST 用 code: 1)。迁移时注意判断条件。
接口:GET https://um.yunjii.cn/oauth/userinfo
Authorization: Bearer <access_token>
响应(标准 OIDC claims):
{
"sub": "um_a1b2c3d4e5f6",
"name": "张三",
"nickname": "张三",
"picture": "https://thirdwx.qlogo.cn/...",
"email": "zhangsan@example.com",
"email_verified": true,
"login_type": "wx",
"iss": "https://um.yunjii.cn"
}
| 字段 | 说明 |
|---|---|
sub | UMID,跨应用唯一(同一用户在不同应用 UMID 相同) |
name / nickname | 用户资料 |
picture | 头像 URL |
email | 邮箱(可能为空) |
login_type | 登录方式 |
Token 续期(Refresh Token 轮换)
access_token 过期后,用 refresh_token 换取新的 token:
接口:POST https://um.yunjii.cn/oauth/token.php
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | ✅ | refresh_token |
refresh_token | ✅ | 上一次获取的 refresh_token |
client_id | ✅ | 应用 ID |
client_secret | ✅ | 应用密钥 |
轮换制:每次刷新签发新的 refresh_token,旧的立即失效。客户端必须用最新的 refresh_token 进行下次刷新。
用户中心 / 个人资料页:载入登录态
登录完成后,用户中心、个人资料等页面加载登录态只有两种正确姿势,选一种即可。
模式 A:服务端 Session(推荐)
token 只存在服务端(session / httpOnly cookie),浏览器永远接触不到。页面渲染时服务端已带登录态:无闪烁、无额外前端请求、token 过期可在服务端静默续期。
Next.js(App Router)——用户中心页直接读 cookie:
// app/(user)/me/page.tsx (Server Component)
import { cookies } from 'next/headers';
import { redirect } from 'next/navigation';
export default async function MePage() {
const jar = await cookies();
const raw = jar.get('um_user')?.value; // 登录回调时写入的用户 JSON
if (!raw) redirect('/api/auth/um/login'); // 未登录 → 一键去登录
const u = JSON.parse(raw);
return (
<div>
<img src={u.picture} width={64} alt="" />
<h1>{u.nickname}</h1>
<p>UMID:{u.sub}</p>
<a href="/api/auth/um/logout">退出登录</a>
</div>
);
}
PHP——任意受保护页面头部加:
<?php
session_start();
if (empty($_SESSION['user'])) {
header('Location: /callback.php?act=login'); // 跳登录入口
exit;
}
$u = $_SESSION['user'];
?>
<img src="<?= htmlspecialchars($u['picture']) ?>" width="64">
<h1><?= htmlspecialchars($u['nickname']) ?></h1>
<p>UMID:<?= htmlspecialchars($u['sub']) ?></p>
模式 B:前端轻量载入(/api/me 代理)
纯 SPA 前端时,提供一个小端点代理 userinfo(token 不出服务端):
// app/api/me/route.ts
import { NextRequest, NextResponse } from 'next/server';
export async function GET(req: NextRequest) {
const token = req.cookies.get('um_at')?.value;
if (!token) return NextResponse.json({ logged_in: false });
const r = await fetch('https://um.yunjii.cn/oauth/userinfo', {
headers: { Authorization: `Bearer ${token}` },
cache: 'no-store',
});
if (r.status === 401) {
return NextResponse.json({ logged_in: false, reason: 'token_expired' });
// 前端收到后跳登录入口;或先走服务端 refresh 再重试(见下)
}
return NextResponse.json({ logged_in: true, user: await r.json() });
}
前端 fetch('/api/me') → {logged_in, user} → 渲染头像昵称。
不要把 access_token 下发到浏览器 localStorage——过期刷新、注销、XSS 风险全都难处理。前端只应该拿到"用户资料 JSON"。
Token 过期与静默续期
access_token7 天有效。服务端收到 UM 侧 401 时,用refresh_token静默续期一次并重放原请求。- 续期是轮换制:立即用响应里的新
refresh_token覆盖旧值。 refresh_token也失效(30 天)→ 清登录态,下次访问重新授权。切勿对 401 死循环刷新。
登出
// app/api/auth/um/logout/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function GET() {
const jar = await cookies();
['um_at', 'um_rt', 'um_user'].forEach((n) => jar.delete(n));
return NextResponse.redirect('https://um.yunjii.cn/oauth/end_session');
}
跳转 end_session 会同时清除 UM 侧联合会话,家族产品一起登出;只清本地 cookie 则用户在其它云集应用仍是登录态。
UMID 落库最佳实践
以 userinfo.sub(UMID)为唯一键 upsert 你的 users 表,不要自建映射表:
ALTER TABLE users ADD COLUMN um_sub TEXT UNIQUE;
-- 登录回调时(参数化 SQL):
INSERT INTO users (um_sub, nickname, avatar, last_login_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(um_sub) DO UPDATE
SET nickname = excluded.nickname, avatar = excluded.avatar, last_login_at = excluded.last_login_at;
同一用户在生态内任何应用 UMID 相同——未来打通家族互通(资料同步、积分互认)零迁移成本。
完整 PHP Demo
<?php
require_once 'UM.class.php';
session_start();
$um = new UM(
'你的_appid',
'你的_appkey',
'https://yourapp.com/callback.php',
'https://um.yunjii.cn/'
);
$act = $_GET['act'] ?? '';
if ($act === 'login') {
// 1. 生成授权 URL + PKCE
$state = bin2hex(random_bytes(16));
$verifier = $um->generateCodeVerifier();
$_SESSION['oauth_state'] = $state;
$_SESSION['oauth_verifier'] = $verifier;
header('Location: ' . $um->authorizeUrl($state, $verifier));
exit;
}
if (isset($_GET['code'])) {
// 3-4. 回调:校验 state + 换 token
if ($_GET['state'] !== $_SESSION['oauth_state']) {
die('State 校验失败');
}
$tokenRes = $um->token($_GET['code'], $_SESSION['oauth_verifier']);
if ($tokenRes['code'] !== 0) {
die('换取 token 失败:' . $tokenRes['msg']);
}
// 5. 获取用户信息
$user = $um->userinfo($tokenRes['access_token']);
// 清理 PKCE 临时数据
unset($_SESSION['oauth_state'], $_SESSION['oauth_verifier']);
// 存储登录态
$_SESSION['access_token'] = $tokenRes['access_token'];
$_SESSION['refresh_token'] = $tokenRes['refresh_token'];
$_SESSION['user'] = $user;
echo "登录成功!<br>";
echo "昵称:{$user['nickname']}<br>";
echo "UMID:{$user['sub']}<br>";
echo "头像:<img src='{$user['picture']}' width=64><br>";
}
Next.js(App Router)完整 Demo
零依赖(原生 fetch + Web Crypto),三个文件跑通:登录入口 → 回调换 token → 用户中心。
// app/api/auth/um/login/route.ts —— 登录入口:生成 PKCE + 跳转授权页
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const UM = 'https://um.yunjii.cn';
const REDIRECT_URI = 'https://yourapp.com/api/auth/um/callback';
export async function GET() {
const state = crypto.randomUUID();
const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url');
const challenge = Buffer.from(
await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)),
).toString('base64url');
const jar = await cookies();
jar.set('um_state', state, { httpOnly: true, maxAge: 600, path: '/' });
jar.set('um_verifier', verifier, { httpOnly: true, maxAge: 600, path: '/' });
const url = new URL(`${UM}/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', process.env.UM_CLIENT_ID!);
url.searchParams.set('redirect_uri', REDIRECT_URI);
url.searchParams.set('state', state);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
return NextResponse.redirect(url);
}
// app/api/auth/um/callback/route.ts —— 回调:校验 state → 换 token → 拉 userinfo → 写 session
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const UM = 'https://um.yunjii.cn';
const REDIRECT_URI = 'https://yourapp.com/api/auth/um/callback';
export async function GET(req: NextRequest) {
const jar = await cookies();
const code = req.nextUrl.searchParams.get('code');
if (!code || req.nextUrl.searchParams.get('state') !== jar.get('um_state')?.value) {
return NextResponse.redirect('/login?error=state_mismatch');
}
const tokenRes = await fetch(`${UM}/oauth/token.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
client_id: process.env.UM_CLIENT_ID!,
client_secret: process.env.UM_CLIENT_SECRET!,
redirect_uri: REDIRECT_URI,
code_verifier: jar.get('um_verifier')?.value ?? '',
}),
}).then((r) => r.json());
if (tokenRes.code !== 0) {
return NextResponse.redirect(`/login?error=${encodeURIComponent(tokenRes.msg ?? 'token_failed')}`);
}
const user = await fetch(`${UM}/oauth/userinfo`, {
headers: { Authorization: `Bearer ${tokenRes.access_token}` },
}).then((r) => r.json());
jar.delete('um_state');
jar.delete('um_verifier');
jar.set('um_at', tokenRes.access_token, { httpOnly: true, path: '/', maxAge: 604800 });
jar.set('um_rt', tokenRes.refresh_token, { httpOnly: true, path: '/', maxAge: 2592000 });
jar.set('um_user', JSON.stringify(user), { httpOnly: true, path: '/', maxAge: 604800 });
// TODO: 在这里用 user.sub(UMID)upsert 你的 users 表(见「UMID 落库最佳实践」)
return NextResponse.redirect('/me');
}
// app/(user)/me/page.tsx —— 用户中心页(见上文「模式 A:服务端 Session」)
接线清单:
- 在 um.yunjii.cn/console/apps 创建应用,回调域名填
yourapp.com - 环境变量:
UM_CLIENT_ID(appid)、UM_CLIENT_SECRET(appkey) - 访问
/api/auth/um/login即可开始登录,登录后自动跳回/me
扫码登录(二维码图片 · 兼容方式)
适合 PC 端不想跳转授权页的场景,直接 <img> 渲染二维码:
$qrUrl = $um->qrcodeUrl('wx', 200, 'state-xyz');
echo '<img src="' . htmlspecialchars($qrUrl) . '" width="200" height="200" alt="登录二维码">';
用户扫码后 UM 按 redirect_uri 回调 code,后续走上面的「5 步对接」第 3 步起。
用通用 OAuth 库接入(推荐)
UM 是标准 OAuth 2.1 + OIDC 服务,可直接用通用库接入,无需安装 UM SDK:
- Next.js / Auth.js:配置
provider: um({ issuer: 'https://um.yunjii.cn' }) - Python / authlib:
oauth.register('um', server_metadata_url='https://um.yunjii.cn/.well-known/openid-configuration', ...) - Spring Security:
ClientRegistration.withIssuer('https://um.yunjii.cn')...
通用库自动从 OIDC Discovery 读取所有端点配置。
常见问题
| 问题 | 原因 | 解决 |
|---|---|---|
errcode=102 | appid 不存在或应用未审核 | 检查 appid,确认应用状态为"运行中" |
errcode=103 | client_secret 不正确 | 检查 appkey |
errcode=104 | code 不存在/未授权/已过期 | code 5 分钟过期,重新发起授权 |
errcode=105 | timestamp 过期(旧版 REST) | 保证服务器时间正确,5 分钟内有效 |
errcode=107 | PKCE code_verifier 校验失败 | 检查 verifier 与 challenge 是否匹配 |
errcode=40163 | code 重复消费 | code 只能用一次,5 分钟过期 |