赵萝卜统一身份认证 · OAuth2 / OIDC 接入文档

授权码模式 + PKCE · 服务地址 https://zcarrot.cn

1. 概述

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.phpcode / refresh_token 换令牌
GET/api/user/oauth/userinfo.phpOIDC 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
openidsub(必需,用户唯一标识:32 位随机字符串)
profilepreferred_username、name、picture、updated_at
emailemail、email_verified
phonephone_number、phone_number_verified
realnamerealname_verified(只回答「是否已完成实名认证」)
realname_inforealname_name、realname_idno_masked(实名信息本身;永远不返回完整身份证号)
sub 是随机生成的 32 位大小写字母 + 数字串(例如 Q7m2Kp9Vx4Rt8Lw3Nz6Bc1Hd5Jf0Gs7Y),永久不变、全局唯一,且与数据库自增 ID 无关;不要把 email/phone 当主键 —— 用户可以在资料页更换。

7. 令牌

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. 错误码

HTTPerror说明
400invalid_request参数缺失/不合法、回调地址不匹配
400invalid_clientclient_id 不存在或应用停用
400unsupported_response_type只支持 code
400invalid_scope申请了后台未允许的 scope
400invalid_grantcode/refresh_token 无效、过期、已用、PKCE 失败
400unsupported_grant_type只支持 authorization_code / refresh_token
401invalid_clientclient_secret 不正确
401invalid_tokenaccess_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. 授权管理

12. 常见问题

回调地址不匹配

后台配置是逐字符比对:协议、端口、路径、末尾斜杠都要一致。

id_token 验签失败

用该客户端的 client_secret 做 HS256 校验,并检查 iss=https://zcarrot.cn、 aud=client_id、exp。

公开客户端拿不到 id_token

HS256 需要 secret,公开客户端没有;请改用 /userinfo.php。

用户改绑邮箱后 sub 会变吗

不会。sub 是独立的随机标识,与邮箱、手机号、自增 ID 都无关。