1. 概述
- 协议:OAuth 2.0 授权码模式 + OpenID Connect(可选)。
- 登录凭据就是本站账号(手机号 / 邮箱 / 密码 / 短信 / 邮箱链接 / 第三方登录 / MFA)。
- 支持 PKCE:纯前端、移动 App 可以不使用 client_secret。
- 支持
refresh_token续期。 - 所有令牌都是不透明字符串(服务端存哈希),不能用 JWT 库自解析 access_token。
2. 三步接入
第一步:创建应用(账号本人在个人中心操作)
登录后打开 /pages/user/profile.html#apps →「我的应用」→「+ 新建应用」:
| 字段 | 说明 |
|---|---|
| 应用名称 | 会显示在用户授权页上。 |
| 客户端类型 | 机密(有 secret,服务端应用)/ 公开(无 secret,必须用 PKCE) |
| 回调地址 | 每行一个,必须完全一致(协议、域名、端口、路径、结尾斜杠都要一致)。 |
| 授权信息范围(scope) | 勾选 openid profile email phone realname realname_info 中需要的(realname 只给「是否已实名」,realname_info 才给姓名与脱敏证件号)。这里勾什么是固定的:用户在授权页只能「同意 / 拒绝」,不能增减任何一项。 |
保存后拿到 client_id 与 client_secret(secret 只显示一次)。
第二步:把用户送到授权页
https://zcarrot.cn/pages/user/oauth-authorize.html
?response_type=code
&client_id=zc_xxxxxxxxxxxxxxxx
&redirect_uri=https%3A%2F%2Fyour-app%2Fcallback
&scope=openid%20profile%20email
&state=随机串
&nonce=随机串(OIDC 建议)
&code_challenge=...&code_challenge_method=S256 (公开客户端必填)
第三步:回调里换令牌、取用户信息
POST /api/user/oauth/token.php (服务端调用)
POST /api/user/oauth/userinfo.php Authorization: Bearer <access_token>
3. 授权流程
① 浏览器 → /pages/user/oauth-authorize.html?...
(授权页 JS 会先 GET /api/user/oauth/authorize.php 取应用信息)
② 未登录 → 401 login_required → 跳登录页(登录后回到授权页)
③ 用户看固定范围,点「同意授权」(不能增减 scope)
POST /api/user/oauth/authorize.php
→ { redirect_to: "https://your-app/callback?code=..&state=.." }
④ 应用服务端 → POST /api/user/oauth/token.php
grant_type=authorization_code & code & redirect_uri & client_id & client_secret
→ { access_token, refresh_token, id_token, expires_in, scope }
⑤ 应用服务端 → GET /api/user/oauth/userinfo.php(Bearer)
→ { sub, preferred_username, email, ... }
⑥ 令牌过期 → POST /api/user/oauth/token.php
grant_type=refresh_token & refresh_token
4. 端点
| 方法 | 地址 | 用途 |
|---|---|---|
| GET | /pages/user/oauth-authorize.html | 授权页(用户可见) |
| GET | /api/user/oauth/authorize.php | 授权页元数据 |
| POST | /api/user/oauth/authorize.php | 确认授权,签发 code |
| POST | /api/user/oauth/token.php | code / refresh_token 换令牌 |
| GET | /api/user/oauth/userinfo.php | OIDC UserInfo |
| POST | /api/user/oauth/revoke.php | 吊销令牌(RFC 7009) |
5. 接口详解
5.1 授权元数据 GET
GET /api/user/oauth/authorize.php?response_type=code&client_id=zc_xxx
&redirect_uri=https%3A%2F%2Fyour-app%2Fcallback&scope=openid%20profile&state=abc
{ "code": 0, "data": {
"app_name": "成绩查询助手",
"scopes": [ { "key": "openid", "label": "登录身份(openid)" }, ... ],
"current_user": { "id": 12, "username": "zhao", "avatar": "" },
"state": "abc"
} }
未登录:HTTP 401 + {"code":4031,"login_required":true,"login_url":"..."}。
5.2 确认授权 POST
POST /api/user/oauth/authorize.php
{
"client_id": "zc_xxx", "redirect_uri": "https://your-app/callback",
"scope": "openid profile", "state": "abc", "nonce": "n-1",
"code_challenge": "...", "code_challenge_method": "S256"
}
→ { "code": 0, "data": { "redirect_to": "https://your-app/callback?code=..&state=abc" } }
授权范围固定等于后台给这个应用配置的 scope 全集:请求里带的 scope 只用于校验越权(超出配置会返回 invalid_scope),不会缩小最终授权结果;前端也不再传 approved。用户拒绝时传 "deny": true,会回跳 ?error=access_denied&state=..。
5.3 换令牌 POST
POST /api/user/oauth/token.php
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&code=zc_....&redirect_uri=...
&client_id=zc_xxx&client_secret=***&code_verifier=***
{
"access_token": "at_....", "token_type": "Bearer", "expires_in": 3600,
"refresh_token": "rt_....", "scope": "openid profile",
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}
也支持 refresh_token:
grant_type=refresh_token&refresh_token=rt_....&client_id=zc_xxx&client_secret=***
公开客户端可以用 Authorization: Basic base64(client_id:) 或直接传 client_id,并配合 PKCE。
5.4 用户信息 GET
GET /api/user/oauth/userinfo.php
Authorization: Bearer at_....
{
"sub": "Q7m2Kp9Vx4Rt8Lw3Nz6Bc1Hd5Jf0Gs7Y",
"preferred_username": "zhao", "name": "zhao", "picture": "https://zcarrot.cn/cdn/user/avatar/...",
"email": "zhao@example.com", "email_verified": true,
"phone_number": "13800000000", "phone_number_verified": true,
"realname_verified": true,
"realname_name": "张三", "realname_idno_masked": "3101**********1234"
}
5.5 吊销 POST
POST /api/user/oauth/revoke.php
token=at_....&client_id=zc_xxx&client_secret=*** → { "code": 0, "revoked": true }
6. scope 与 claims
| scope | 返回的 claims |
|---|---|
openid | sub(必需,用户唯一标识:32 位随机字符串) |
profile | preferred_username、name、picture、updated_at |
email | email、email_verified |
phone | phone_number、phone_number_verified |
realname | realname_verified(只回答「是否已完成实名认证」) |
realname_info | realname_name、realname_idno_masked(实名信息本身;永远不返回完整身份证号) |
sub 是随机生成的 32 位大小写字母 + 数字串(例如 Q7m2Kp9Vx4Rt8Lw3Nz6Bc1Hd5Jf0Gs7Y),永久不变、全局唯一,且与数据库自增 ID 无关;不要把 email/phone
当主键 —— 用户可以在资料页更换。
7. 令牌
access_token:1 小时,Bearer 方式使用,服务端只存 SHA-256 哈希。refresh_token:30 天,使用后会轮换(旧的同时作废)。id_token:HS256 签名的 JWT,密钥是该客户端的client_secret。 公开客户端(无 secret)不会拿到 id_token,请用userinfo。- 修改密码、被管理员封禁、解除授权时,该用户/该应用的令牌会立刻失效。
8. PKCE
公开客户端(纯前端 / App)必须使用 PKCE:
// 1. 生成
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)));
// 2. 授权时带 code_challenge / code_challenge_method=S256
// 3. 换令牌时带 code_verifier=verifier
9. 错误码
| HTTP | error | 说明 |
|---|---|---|
| 400 | invalid_request | 参数缺失/不合法、回调地址不匹配 |
| 400 | invalid_client | client_id 不存在或应用停用 |
| 400 | unsupported_response_type | 只支持 code |
| 400 | invalid_scope | 申请了后台未允许的 scope |
| 400 | invalid_grant | code/refresh_token 无效、过期、已用、PKCE 失败 |
| 400 | unsupported_grant_type | 只支持 authorization_code / refresh_token |
| 401 | invalid_client | client_secret 不正确 |
| 401 | invalid_token | access_token 无效或过期 |
错误响应同时给出 error、error_description 与本站统一的 message。
10. 代码示例
PHP
<?php
const IDP = 'https://zcarrot.cn';
const CLIENT_ID = 'zc_xxx';
const CLIENT_SECRET = '***';
const REDIRECT = 'https://your-app/callback';
// 1) 跳授权页
session_start();
$_SESSION['state'] = bin2hex(random_bytes(16));
$_SESSION['verifier'] = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$challenge = rtrim(strtr(base64_encode(hash('sha256', $_SESSION['verifier'], true)), '+/', '-_'), '=');
header('Location: ' . IDP . '/pages/user/oauth-authorize.html?' . http_build_query([
'response_type' => 'code',
'client_id' => CLIENT_ID,
'redirect_uri' => REDIRECT,
'scope' => 'openid profile email',
'state' => $_SESSION['state'],
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]));
// 2) 回调换令牌
$res = json_decode(file_get_contents(IDP . '/api/user/oauth/token.php', false, stream_context_create([
'http' => [
'method' => 'POST',
'header' => "Content-Type: application/x-www-form-urlencoded\r\n",
'content' => http_build_query([
'grant_type' => 'authorization_code',
'code' => $_GET['code'],
'redirect_uri' => REDIRECT,
'client_id' => CLIENT_ID,
'client_secret' => CLIENT_SECRET,
'code_verifier' => $_SESSION['verifier'],
]),
],
])), true);
// 3) 取用户信息
$ctx = stream_context_create(['http' => ['header' => 'Authorization: Bearer ' . $res['access_token']]]);
$me = json_decode(file_get_contents(IDP . '/api/user/oauth/userinfo.php', false, $ctx), true);
Node.js(原生 fetch)
const IDP = 'https://zcarrot.cn';
const CLIENT_ID = 'zc_xxx';
const CLIENT_SECRET = '***';
const REDIRECT = 'https://your-app/callback';
// 回调里
async function exchange(code) {
const body = new URLSearchParams({
grant_type: 'authorization_code', code,
redirect_uri: REDIRECT, client_id: CLIENT_ID, client_secret: CLIENT_SECRET
});
const r = await fetch(IDP + '/api/user/oauth/token.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body
});
const tk = await r.json();
const me = await (await fetch(IDP + '/api/user/oauth/userinfo.php', {
headers: { Authorization: 'Bearer ' + tk.access_token }
})).json();
return { tk, me };
}
11. 授权管理
- 用户可在
/pages/user/profile.html→「已授权的应用」查看并随时解除授权。 - 解除后该应用的所有 access/refresh 令牌立即吊销。
- 应用由用户在
/pages/user/profile.html→「我的应用」里自己创建和管理(编辑、停用、删除、重置 secret);应用归属创建者账号。
12. 常见问题
回调地址不匹配
后台配置是逐字符比对:协议、端口、路径、末尾斜杠都要一致。
id_token 验签失败
用该客户端的 client_secret 做 HS256 校验,并检查 iss=https://zcarrot.cn、
aud=client_id、exp。
公开客户端拿不到 id_token
HS256 需要 secret,公开客户端没有;请改用 /userinfo.php。
用户改绑邮箱后 sub 会变吗
不会。sub 是独立的随机标识,与邮箱、手机号、自增 ID 都无关。