网页(Web)接入
网页应用有两种:有自己后端的站点(机密客户端,密钥只放服务端)和
纯前端 SPA(公开客户端,无密钥,必须用 PKCE)。两者都用标准授权码模式,用户在
open.jintuzi.com 的授权页上登录并同意。
1. 两种模式怎么选
| 你的形态 | 客户端类型 | 换 token 的位置 | PKCE |
|---|---|---|---|
| 有后端(PHP / Java / Node / Go…) | confidential 机密 | 后端(带 client_secret) | 可选,建议开 |
| 纯前端(Vue / React / 静态站点) | public 公开 | 浏览器(无密钥) | 强制 |
2. 回调地址要求
- 必须是
https://,且与后台登记的回调地址逐字符一致(不会做前缀或通配匹配); - 本地联调可用
http://localhost:端口/oauth/callback,上线前替换成正式域名; - 回调只用来接
code,不要在里面做业务写入。
3. 授权(跳转,不要用 iframe)
平台页面禁止被第三方 iframe 嵌入(frame-ancestors 'self'),请用整页跳转或弹窗:
const params = new URLSearchParams({
response_type: 'code',
client_id: 'YOUR_CLIENT_ID',
redirect_uri: 'https://your-site.com/oauth/callback',
scope: 'user:avatar user:nickname',
state: crypto.randomUUID(),
});
// 机密客户端:无需 PKCE;SPA:再带上 code_challenge / code_challenge_method=S256
location.href = `https://open.jintuzi.com/oauth/authorize?${params}`;
4. 用 code 换 token
有后端(机密客户端,示例 Node/Express)
// 服务端:code 只在这里出现,密钥不下发到浏览器
const r = await fetch('https://open.jintuzi.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: req.query.code,
redirect_uri: 'https://your-site.com/oauth/callback',
client_id: process.env.JZ_CLIENT_ID,
client_secret: process.env.JZ_CLIENT_SECRET,
}),
});
const { access_token, refresh_token } = await r.json();
纯前端 SPA(公开客户端,PKCE)
// 1) 生成 verifier,存 sessionStorage(不要 localStorage)
const verifier = [...crypto.getRandomValues(new Uint8Array(32))]
.map(b => b.toString(16).padStart(2, '0')).join('');
sessionStorage.setItem('pkce_verifier', verifier);
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = btoa(String.fromCharCode(...new Uint8Array(digest)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// 2) 授权时带上 code_challenge / code_challenge_method=S256
// 回调页取出 code 后:
const r = await fetch('https://open.jintuzi.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://your-site.com/oauth/callback',
client_id: 'YOUR_CLIENT_ID',
code_verifier: sessionStorage.getItem('pkce_verifier'),
}),
});
const { access_token } = await r.json(); // 该接口已开 CORS,可直接跨域调用
5. 取用户资料
const me = await fetch('https://open.jintuzi.com/oauth/userinfo', {
headers: { Authorization: `Bearer ${access_token}` },
}).then(r => r.json());
// { openid, nickname, avatar, jintuzi_no, ... } —— 只返回用户授权过的字段
6. 跨域(CORS)
以下接口已开放跨域调用(Access-Control-Allow-Origin: *,不使用 Cookie,凭证走
Authorization 头),第三方网页可直接 fetch:
/oauth/token、/oauth/revoke、/oauth/introspect/oauth/userinfo/.well-known/oauth-authorization-server
授权页 /oauth/authorize 不开跨域(它依赖平台登录 Cookie),只能整页跳转打开。
7. 前端安全清单
- SPA 只放
client_id,绝不能在前端出现client_secret; code_verifier放sessionStorage,用完即删;access_token优先放内存,页面刷新后用 refresh_token 换新;- 回调页校验
state与发起时一致,防 CSRF; - 不要把 token 拼进 URL、不要通过
postMessage发给任意来源。
8. 服务端校验(可选)
curl -X POST https://open.jintuzi.com/oauth/introspect \
-d "token=ACCESS_TOKEN" \
-d "client_id=YOUR_CLIENT_ID" \
-d "client_secret=YOUR_CLIENT_SECRET"