外观
OAuth 2.0 授权登录
概述
Kango 平台可作为身份提供者(IdP),对外提供标准 OAuth 2.0 授权码 登录。第三方网站 / App 可以用「Kango 账号登录」,获取用户的 openId 与公开资料。
三个角色:
| 角色 | 对应 |
|---|---|
| 资源拥有者 | 平台用户 |
| 授权 + 资源服务器 | Kango 平台(apisvc) |
| 客户端 | 第三方网站 / App(注册为 H5APP,client_id = AppKey,client_secret = AppSecret) |
身份选型(对标微信开放平台):
sub= openId:按 H5APP 配对生成,同一用户在不同应用中 openId 不同,第三方之间不可撞库- unionId:企业级唯一标识,同一开发者(企业)名下多个 App 共享,仅作为附加 claim(需
unionidscope)
两种接入方式,只是拿授权码 code 的方式不同,拿到 code 之后的流程(本页)完全相同:
| 方式 | 适用 | 用户操作 | 测试页 |
|---|---|---|---|
| 授权页跳转 | 网站 / App / SPA | 浏览器跳到 Kango 授权页,登录后点「同意」 | apisvc/jssdk/oauth-demo.html |
| 网站扫码登录(kglogin.js) | PC 网站(对标微信扫码) | 页面内嵌二维码,用 Kango App 扫码并在手机上确认 | apisvc/jssdk/kglogin-demo.html |
两个测试页都是纯前端、AppSecret 填在网页里,只为一个文件跑通。正式接入请参照服务端完整示例(Go / PHP),示例同时演示两种方式。
实现说明
自包含实现:授权码 / access_token / refresh_token 存 Redis;id_token 为 JWT,平台私钥 RS256 签名,公钥经 JWKS 下发,机密、公开客户端都能验签。端点形状与 Ory Hydra 对齐。
前置准备
- 在开发者中心创建 H5APP,获得
AppKey(即client_id)与AppSecret(即client_secret)。 - 登记 redirect_uris 回跳地址白名单(推荐,精确匹配)。登记后支持任意 scheme:native 客户端可用 custom scheme(如
oauthdemo://callback)或 loopback(http://127.0.0.1:port)。 - 未登记白名单时的回退规则:
- 配置了「网站域名」→ 回跳地址必须是绝对 http(s) 且与网站同域或子域
- 两者都未配置 → 拒绝授权(防开放重定向),接入前必须先登记
- 可选:登记允许的
scopes范围。为空表示不限制;openid始终允许。
客户端类型
- 机密客户端(默认):换 token 时必须校验
client_secret,secret 只能放服务端。 - 公开客户端(native App / SPA,AppType=1):无法安全保管 secret,仅靠 PKCE 校验,换 token 时不需要
client_secret。
scope 说明
| scope | 说明 |
|---|---|
openid | 确认用户身份(openId),始终允许;携带时返回 id_token |
profile | 获取昵称与头像 |
mobile | 获取手机号 |
unionid | 获取 unionId(跨应用唯一标识) |
多个 scope 用空格分隔,如 openid profile。请求的 scope 会被收敛到 client 已登记的范围内。
端点
拿 code 的入口见各方式页面:授权端点、kglogin.js。
1. 令牌端点
http
POST /api/oauth/token
Content-Type: application/x-www-form-urlencoded机器对机器调用(公开)。client 凭证支持两种传法:HTTP Basic Auth 或表单字段。
grant_type=authorization_code(授权码换 token)
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | authorization_code |
code | 是 | 回跳拿到的授权码 |
redirect_uri | 是 | 必须与拿 code 时完全一致 |
client_id | 是 | AppKey |
client_secret | 机密客户端必填 | AppSecret(公开客户端 AppType=1 可不传) |
code_verifier | 条件必填 | PKCE 原始随机串,服务端校验 base64url(sha256(verifier)) == challenge。拿 code 时带了 code_challenge 就必填;仅扫码登录的机密客户端可以不带 |
响应(OAuth 标准顶层 JSON,非业务包装):
json
{
"access_token": "xxxxxxxx",
"token_type": "Bearer",
"expires_in": 28800,
"refresh_token": "yyyyyyyy",
"scope": "openid profile",
"id_token": "eyJhbGciOiJIUzI1NiIs..."
}id_token 仅当 scope 含 openid 时返回。
grant_type=refresh_token(刷新令牌)
| 参数 | 必填 | 说明 |
|---|---|---|
grant_type | 是 | refresh_token |
refresh_token | 是 | 之前颁发的 refresh_token |
client_id | 是 | AppKey |
client_secret | 机密客户端必填 | AppSecret |
refresh_token 轮换 + 重放检测
每次刷新都会作废旧的 refresh_token 并颁发新的,请保存响应中的新 refresh_token。
已作废的旧 refresh_token 再次出现,平台视为泄露(合法客户端只会用最新那张),会吊销这次授权下的全部令牌:access_token、refresh_token 一起失效,返回 invalid_grant,用户需重新授权。所以:
- 同一张 refresh_token 只用一次,不要多个进程 / 标签页并发刷新同一张(并发时只有一个成功,其余按重放处理)。
- 刷新请求超时后不要拿旧票重试:服务端可能已经轮换成功、只是应答丢了,重试会触发吊销。超时就让用户重新授权。
令牌有效期
| 令牌 | 有效期 |
|---|---|
| 授权码 code | 15 分钟(单次使用) |
| access_token | 8 小时(28800 秒) |
| refresh_token | 机密客户端 30 天;公开客户端(AppType=1)7 天。轮换制,每次刷新重新计时 |
| id_token | 8 小时 |
2. UserInfo 端点
http
GET /api/oauth/userinfo
Authorization: Bearer {access_token}返回原始 OIDC claims(按 scope 过滤):
json
{
"sub": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"unionId": "a1b2c3d4...",
"name": "张三",
"nickname": "张三",
"picture": "https://cdn.example.com/avatar.jpg",
"gender": 1,
"mobile": "13800138000",
"phone_number": "+8613800138000"
}| claim | 所需 scope | 说明 |
|---|---|---|
sub | 始终返回 | 用户 openId |
unionId | unionid | 跨应用唯一标识 |
name / nickname | profile | 昵称 |
picture | profile | 头像 URL |
gender | profile | 性别:0-未知 1-男 2-女 |
mobile / phone_number | mobile | 手机号 / E.164 格式手机号 |
id_token 验签
id_token 为 JWT,RS256 签名,私钥只在平台,公钥经 JWKS 端点公开下发。机密客户端、公开客户端(SPA / App)都能本地验签,与 AppSecret 无关。
JWKS 端点
http
GET /api/oauth/jwksjson
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "Jx3o…",
"n": "xG5k…",
"e": "AQAB",
"x5c": ["MIIC…"]
}
]
}- 按 id_token header 里的
kid选公钥。平台轮换密钥时新旧公钥会同时出现一段时间。 n/e是标准 JWK;x5c是装着同一把公钥的自签证书(DER 的 base64),PHP 等环境可以直接openssl_verify,不必从n/e拼公钥。证书只是公钥载体,不用校验证书链。- 响应带
Cache-Control: max-age=3600,请缓存;遇到缓存里没有的kid再重新拉一次。
发现文档
http
GET /api/oauth/.well-known/openid-configuration标准 OIDC Discovery,列出各端点地址、jwks_uri、支持的算法与 scope。注意它不在 {iss}/.well-known/ 下,用 OIDC 库自动发现时请直接填这个完整地址。
Payload 示例:
json
{
"iss": "https://kg.citv.cc",
"sub": "3f2a9c8b7e6d5a4b3c2d1e0f9a8b7c6d",
"aud": "YOUR_APP_KEY",
"iat": 1780000000,
"exp": 1780028800,
"nonce": "abc123",
"unionId": "a1b2c3d4...",
"name": "张三",
"picture": "https://..."
}验签要点:
- header 的
alg必须是RS256(只认这一种,防alg=none/ HS256 混淆伪造),按kid从 JWKS 取公钥验签 - 校验
iss==https://kg.citv.cc - 校验
aud== 你的 AppKey - 校验
exp未过期 - 若请求时传了
nonce,校验一致(防重放)
服务端完整示例
下面是可以直接运行的最小网站(Go 只用标准库;PHP 单文件、只依赖 curl 与 openssl 扩展),演示机密应用的标准写法:浏览器全程只拿得到 code,AppSecret 只存在于服务端。一个登录页同时提供两种方式,共用同一个回调:
| 路由 | 作用 |
|---|---|
GET / | 未登录:服务端生成 state 存进会话,页面内嵌扫码二维码 + 「跳转授权页」链接;已登录:显示用户信息 |
GET /kango/authorize | 授权页跳转入口:生成 state + PKCE code_verifier 存进会话,302 到 Kango 授权端点 |
GET /kango/callback | 校验 state → code 换 token(有 verifier 就带上)→ 按 JWKS 验 id_token → 拉 userinfo → 建立本站会话 → 302 回首页 |
POST /logout | 退出本站登录 |
几个关键点
- state 绑在会话上:服务端生成、存进 HttpOnly Cookie / 会话,回调时与 URL 里的
state比对,用完即删。Cookie 必须用SameSite=Lax:Kango 回跳是跨站的顶层跳转,Strict不会带上这个 Cookie。 - 两种方式只差 PKCE:授权页跳转强制 PKCE,所以
/kango/authorize要额外存code_verifier,换 token 时带上;扫码方式不带code_challenge,换 token 时凭client_secret即可。 redirect_uri三处一致:拿 code 时传的、换 token 时传的、开发者中心登记的,三者必须一字不差。- 建立的是本站自己的登录态:Kango 的 access_token 只用来拉一次用户信息,之后即可丢弃;只有要长期代表用户调 Kango 接口时才需要保存 refresh_token。
Go
index.html(Go html/template 模板,与 main.go 放在同一目录):
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>登录</title>
</head>
<body>
{{if .User}}
<h1>已登录:{{.User.Nickname}}</h1>
<p>openId:{{.User.OpenId}}</p>
<form method="post" action="/logout"><button>退出登录</button></form>
{{else}}
<h1>使用 Kango 登录</h1>
{{if .Err}}
<p style="color:red">{{.Err}}</p>
{{end}}
<!-- 方式一:扫码登录 -->
<div id="login_container"></div>
<script src="https://kg.citv.cc/jssdk/kglogin.js"></script>
<script>
new KgLogin({
id: "login_container",
appid: {{.AppId}},
scope: "openid profile",
redirect_uri: {{.Redirect}},
state: {{.State}}
});
</script>
<!-- 方式二:授权页跳转 -->
<p><a href="/kango/authorize">跳转 Kango 授权页登录</a></p>
{{end}}
</body>
</html>main.go:
go
package main
import (
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/subtle"
_ "embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"html/template"
"log"
"math/big"
"net/http"
"net/url"
"os"
"strings"
"sync"
"time"
)
const (
kgApi = "https://kg.citv.cc/api" // Kango 接口地址
redirect = "https://your-site.com/kango/callback" // 须在开发者中心登记
cbPath = "/kango/callback" // redirect 的路径部分
scope = "openid profile"
stateCk = "kg_state"
pkceCk = "kg_pkce" // 授权页跳转方式的 code_verifier
sessCk = "sid"
)
var (
appId = os.Getenv("KG_APP_ID")
secret = os.Getenv("KG_APP_SECRET") // AppSecret 只放服务端:环境变量 / 配置文件,绝不下发到页面
//go:embed index.html
indexHTML string
tpl = template.Must(template.New("i").Parse(indexHTML))
// 本站会话。示例用内存,真实网站换成自己的会话存储 + 用户表
mu sync.Mutex
sessions = map[string]map[string]any{}
httpc = &http.Client{Timeout: 10 * time.Second}
)
func randHex(n int) string {
b := make([]byte, n)
rand.Read(b)
return hex.EncodeToString(b)
}
// 只发往回调路径的临时 Cookie。必须 SameSite=Lax,见上方说明
func setFlowCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name, Value: value, Path: cbPath, MaxAge: 600,
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode,
})
}
func clearFlowCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{Name: name, Path: cbPath, MaxAge: -1})
}
// GET / :未登录时生成 state,页面显示二维码 + 授权页入口
func index(w http.ResponseWriter, r *http.Request) {
data := map[string]any{"AppId": appId, "Redirect": redirect, "Err": r.URL.Query().Get("err")}
if c, err := r.Cookie(sessCk); err == nil {
mu.Lock()
u := sessions[c.Value]
mu.Unlock()
if u != nil {
data["User"] = map[string]any{"OpenId": u["sub"], "Nickname": u["nickname"]}
}
}
if data["User"] == nil {
state := randHex(16)
setFlowCookie(w, stateCk, state)
clearFlowCookie(w, pkceCk) // 扫码方式不带 PKCE,清掉上次跳授权页残留的 verifier
data["State"] = state
}
w.Header().Set("Cache-Control", "no-store")
tpl.Execute(w, data)
}
// GET /kango/authorize :授权页跳转方式,生成 state + PKCE 后 302 到 Kango 授权页
func authorize(w http.ResponseWriter, r *http.Request) {
state, verifier := randHex(16), randHex(32)
setFlowCookie(w, stateCk, state)
setFlowCookie(w, pkceCk, verifier)
sum := sha256.Sum256([]byte(verifier))
q := url.Values{
"response_type": {"code"},
"client_id": {appId},
"redirect_uri": {redirect},
"scope": {scope},
"state": {state},
"code_challenge": {base64.RawURLEncoding.EncodeToString(sum[:])},
"code_challenge_method": {"S256"},
}
http.Redirect(w, r, kgApi+"/oauth/authorize?"+q.Encode(), http.StatusFound)
}
// GET /kango/callback?code=&state= :两种方式共用
func callback(w http.ResponseWriter, r *http.Request) {
fail := func(msg string) { http.Redirect(w, r, "/?err="+url.QueryEscape(msg), http.StatusFound) }
q := r.URL.Query()
// 1. 校验 state,用完即删
c, err := r.Cookie(stateCk)
pk, _ := r.Cookie(pkceCk)
clearFlowCookie(w, stateCk)
clearFlowCookie(w, pkceCk)
if err != nil || subtle.ConstantTimeCompare([]byte(c.Value), []byte(q.Get("state"))) != 1 {
fail("state 校验失败,请重新登录")
return
}
if e := q.Get("error"); e != "" { // 用户在授权页点了拒绝
fail(e + " " + q.Get("error_description"))
return
}
// 2. code 换 token(服务端 → Kango,client_secret 不经过浏览器)
form := url.Values{
"grant_type": {"authorization_code"},
"code": {q.Get("code")},
"redirect_uri": {redirect},
"client_id": {appId},
"client_secret": {secret},
}
if pk != nil && pk.Value != "" { // 授权页跳转方式:带上 PKCE 原文
form.Set("code_verifier", pk.Value)
}
var tok struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token"`
Error string `json:"error"`
ErrorDesc string `json:"error_description"`
}
resp, err := httpc.PostForm(kgApi+"/oauth/token", form)
if err != nil {
fail(err.Error())
return
}
json.NewDecoder(resp.Body).Decode(&tok)
resp.Body.Close()
if tok.AccessToken == "" {
fail("换 token 失败:" + tok.Error + " " + tok.ErrorDesc)
return
}
// 3. 验 id_token(推荐)
if tok.IdToken != "" {
if err := verifyIdToken(tok.IdToken); err != nil {
fail("id_token 校验失败:" + err.Error())
return
}
}
// 4. 拉用户信息
req, _ := http.NewRequest(http.MethodGet, kgApi+"/oauth/userinfo", nil)
req.Header.Set("Authorization", "Bearer "+tok.AccessToken)
resp, err = httpc.Do(req)
if err != nil {
fail(err.Error())
return
}
var info map[string]any
json.NewDecoder(resp.Body).Decode(&info)
resp.Body.Close()
if info["sub"] == nil {
fail(fmt.Sprint("获取用户信息失败:", info["error_description"]))
return
}
// 5. 按 sub(openId) / unionId 查找或创建本站账号,建立本站会话
sid := randHex(32)
mu.Lock()
sessions[sid] = info
mu.Unlock()
http.SetCookie(w, &http.Cookie{Name: sessCk, Value: sid, Path: "/",
HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode})
http.Redirect(w, r, "/", http.StatusFound) // 回首页,地址栏不再留 code
}
// POST /logout
func logout(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if c, err := r.Cookie(sessCk); err == nil {
mu.Lock()
delete(sessions, c.Value)
mu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: sessCk, Path: "/", MaxAge: -1})
http.Redirect(w, r, "/", http.StatusFound)
}
// verifyIdToken:按 header 的 kid 从 JWKS 取公钥,RS256 验签,再核对 aud 与 exp。
// 正式项目也可以直接用成熟的 OIDC / JWT 库。
func verifyIdToken(t string) error {
p := strings.Split(t, ".")
if len(p) != 3 {
return errors.New("格式错误")
}
var hdr struct{ Alg, Kid string }
b, _ := base64.RawURLEncoding.DecodeString(p[0])
json.Unmarshal(b, &hdr)
if hdr.Alg != "RS256" { // 只认 RS256,防 alg=none / HS256 混淆
return errors.New("alg 不是 RS256")
}
pub, err := jwksKey(hdr.Kid)
if err != nil {
return err
}
sum := sha256.Sum256([]byte(p[0] + "." + p[1]))
sig, _ := base64.RawURLEncoding.DecodeString(p[2])
if rsa.VerifyPKCS1v15(pub, crypto.SHA256, sum[:], sig) != nil {
return errors.New("签名不匹配")
}
b, _ = base64.RawURLEncoding.DecodeString(p[1])
var c struct {
Aud string `json:"aud"`
Exp int64 `json:"exp"`
}
json.Unmarshal(b, &c)
if c.Aud != appId {
return errors.New("aud 不是本应用")
}
if time.Now().Unix() >= c.Exp {
return errors.New("已过期")
}
return nil
}
// jwksKey 从 JWKS 取 kid 对应的公钥。示例每次都拉;正式项目应缓存(按 Cache-Control),
// 遇到不认识的 kid 再刷新一次(平台轮换密钥时会出现)。
func jwksKey(kid string) (*rsa.PublicKey, error) {
resp, err := httpc.Get(kgApi + "/oauth/jwks")
if err != nil {
return nil, err
}
defer resp.Body.Close()
var set struct {
Keys []struct{ Kid, N, E string } `json:"keys"`
}
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
return nil, err
}
for _, k := range set.Keys {
if k.Kid == kid {
n, _ := base64.RawURLEncoding.DecodeString(k.N)
e, _ := base64.RawURLEncoding.DecodeString(k.E)
return &rsa.PublicKey{N: new(big.Int).SetBytes(n), E: int(new(big.Int).SetBytes(e).Int64())}, nil
}
}
return nil, errors.New("JWKS 里没有 kid=" + kid)
}
func main() {
if appId == "" || secret == "" {
log.Fatal("请设置环境变量 KG_APP_ID / KG_APP_SECRET")
}
http.HandleFunc("/{$}", index)
http.HandleFunc("/kango/authorize", authorize)
http.HandleFunc(cbPath, callback)
http.HandleFunc("/logout", logout)
log.Fatal(http.ListenAndServe(":8090", nil))
}运行(Go 1.22+):
bash
export KG_APP_ID=YOUR_APP_KEY
export KG_APP_SECRET=YOUR_APP_SECRET
go run .然后用 HTTPS 域名反代到 :8090(示例里 Cookie 设了 Secure,本地纯 http 调试时改成 false),浏览器打开首页即可登录。
PHP
单文件 index.php(PHP 7.4+,需 curl、openssl 扩展)。所有请求都由它按路径分发;state、code_verifier、登录用户都存在 PHP 会话里:
php
<?php
// Kango 登录 · PHP 服务端示例(机密应用),扫码登录与授权页跳转两种方式共用一个回调。
// 单文件前端控制器:所有请求都进本文件,按路径分发。
const KG_API = 'https://kg.citv.cc/api'; // Kango 接口地址
const REDIRECT = 'https://your-site.com/kango/callback'; // 须在开发者中心登记
const SCOPE = 'openid profile';
define('APP_ID', getenv('KG_APP_ID') ?: '');
define('APP_SECRET', getenv('KG_APP_SECRET') ?: ''); // AppSecret 只放服务端:环境变量 / 配置文件,绝不下发到页面
if (APP_ID === '' || APP_SECRET === '') {
http_response_code(500);
exit('请设置环境变量 KG_APP_ID / KG_APP_SECRET');
}
// state / code_verifier / 登录用户都存在 PHP 会话里
session_start([
'cookie_httponly' => true,
'cookie_secure' => true, // 本地纯 http 调试时改成 false
'cookie_samesite' => 'Lax', // 必须 Lax:Kango 回跳是跨站的顶层跳转,Strict 不会带上会话 Cookie
'use_strict_mode' => true,
]);
switch (parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)) {
case '/':
index();
break;
case '/kango/authorize':
authorize();
break;
case '/kango/callback':
callback();
break;
case '/logout':
logout();
break;
default:
http_response_code(404);
echo 'not found';
}
function rand_hex(int $n): string
{
return bin2hex(random_bytes($n));
}
function b64url_encode(string $s): string
{
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
function b64url_decode(string $s): string
{
return (string) base64_decode(strtr($s, '-_', '+/'));
}
function redirect_to(string $url): void
{
header('Location: ' . $url, true, 302);
exit;
}
function fail(string $msg): void
{
redirect_to('/?err=' . rawurlencode($msg));
}
// GET / :未登录时生成 state,页面显示二维码 + 授权页入口
function index(): void
{
$user = $_SESSION['user'] ?? null;
$state = '';
if (!$user) {
$state = rand_hex(16);
$_SESSION['kg_state'] = $state;
unset($_SESSION['kg_verifier']); // 扫码方式不带 PKCE,清掉上次跳授权页残留的 verifier
}
$err = (string) ($_GET['err'] ?? '');
$h = function ($s) { return htmlspecialchars((string) $s, ENT_QUOTES, 'UTF-8'); };
$js = function ($v) { // 安全地嵌进 <script>
return json_encode($v, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
};
header('Cache-Control: no-store');
?>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<title>登录</title>
</head>
<body>
<?php if ($user): ?>
<h1>已登录:<?= $h($user['nickname'] ?? '') ?></h1>
<p>openId:<?= $h($user['sub']) ?></p>
<form method="post" action="/logout"><button>退出登录</button></form>
<?php else: ?>
<h1>使用 Kango 登录</h1>
<?php if ($err !== ''): ?>
<p style="color:red"><?= $h($err) ?></p>
<?php endif; ?>
<!-- 方式一:扫码登录 -->
<div id="login_container"></div>
<script src="https://kg.citv.cc/jssdk/kglogin.js"></script>
<script>
new KgLogin({
id: "login_container",
appid: <?= $js(APP_ID) ?>,
scope: <?= $js(SCOPE) ?>,
redirect_uri: <?= $js(REDIRECT) ?>,
state: <?= $js($state) ?>
});
</script>
<!-- 方式二:授权页跳转 -->
<p><a href="/kango/authorize">跳转 Kango 授权页登录</a></p>
<?php endif; ?>
</body>
</html>
<?php
}
// GET /kango/authorize :授权页跳转方式,生成 state + PKCE 后 302 到 Kango 授权页
function authorize(): void
{
$state = rand_hex(16);
$verifier = rand_hex(32);
$_SESSION['kg_state'] = $state;
$_SESSION['kg_verifier'] = $verifier;
redirect_to(KG_API . '/oauth/authorize?' . http_build_query([
'response_type' => 'code',
'client_id' => APP_ID,
'redirect_uri' => REDIRECT,
'scope' => SCOPE,
'state' => $state,
'code_challenge' => b64url_encode(hash('sha256', $verifier, true)),
'code_challenge_method' => 'S256',
], '', '&', PHP_QUERY_RFC3986));
}
// GET /kango/callback?code=&state= :两种方式共用
function callback(): void
{
// 1. 校验 state,用完即删
$state = (string) ($_SESSION['kg_state'] ?? '');
$verifier = (string) ($_SESSION['kg_verifier'] ?? '');
unset($_SESSION['kg_state'], $_SESSION['kg_verifier']);
if ($state === '' || !hash_equals($state, (string) ($_GET['state'] ?? ''))) {
fail('state 校验失败,请重新登录');
}
if (!empty($_GET['error'])) { // 用户在授权页点了拒绝
fail($_GET['error'] . ' ' . ($_GET['error_description'] ?? ''));
}
// 2. code 换 token(服务端 → Kango,client_secret 不经过浏览器)
$form = [
'grant_type' => 'authorization_code',
'code' => (string) ($_GET['code'] ?? ''),
'redirect_uri' => REDIRECT,
'client_id' => APP_ID,
'client_secret' => APP_SECRET,
];
if ($verifier !== '') { // 授权页跳转方式:带上 PKCE 原文
$form['code_verifier'] = $verifier;
}
$tok = http_json(KG_API . '/oauth/token', $form);
if (empty($tok['access_token'])) {
fail('换 token 失败:' . ($tok['error'] ?? '') . ' ' . ($tok['error_description'] ?? ''));
}
// 3. 验 id_token(推荐)
if (!empty($tok['id_token'])) {
$e = verify_id_token($tok['id_token']);
if ($e !== '') {
fail('id_token 校验失败:' . $e);
}
}
// 4. 拉用户信息
$info = http_json(KG_API . '/oauth/userinfo', null, $tok['access_token']);
if (empty($info['sub'])) {
fail('获取用户信息失败:' . ($info['error_description'] ?? ''));
}
// 5. 按 sub(openId) / unionId 查找或创建本站账号,建立本站会话
session_regenerate_id(true); // 登录后换会话 id,防会话固定
$_SESSION['user'] = $info;
redirect_to('/'); // 回首页,地址栏不再留 code
}
// POST /logout
function logout(): void
{
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('method not allowed');
}
$_SESSION = [];
session_destroy();
redirect_to('/');
}
// 调 Kango 接口:$form 非 null 时 POST 表单,否则 GET;返回解码后的 JSON
function http_json(string $url, ?array $form, string $bearer = ''): array
{
$ch = curl_init($url);
$headers = ['Accept: application/json'];
if ($bearer !== '') {
$headers[] = 'Authorization: Bearer ' . $bearer;
}
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => $headers,
]);
if ($form !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); // application/x-www-form-urlencoded
}
$body = curl_exec($ch);
if ($body === false) {
fail('请求 Kango 失败:' . curl_error($ch));
}
return json_decode($body, true) ?: [];
}
// verify_id_token:按 header 的 kid 从 JWKS 取公钥,RS256 验签,再核对 aud 与 exp。返回空串表示通过。
// 正式项目也可以直接用成熟的 JWT 库(如 firebase/php-jwt 的 JWK::parseKeySet)。
function verify_id_token(string $jwt): string
{
$p = explode('.', $jwt);
if (count($p) !== 3) {
return '格式错误';
}
$hdr = json_decode(b64url_decode($p[0]), true) ?: [];
if (($hdr['alg'] ?? '') !== 'RS256') { // 只认 RS256,防 alg=none / HS256 混淆
return 'alg 不是 RS256';
}
$cert = jwks_cert((string) ($hdr['kid'] ?? ''));
if ($cert === '') {
return 'JWKS 里没有对应的 kid';
}
if (openssl_verify($p[0] . '.' . $p[1], b64url_decode($p[2]), $cert, OPENSSL_ALGO_SHA256) !== 1) {
return '签名不匹配';
}
$c = json_decode(b64url_decode($p[1]), true) ?: [];
if (($c['aud'] ?? '') !== APP_ID) {
return 'aud 不是本应用';
}
if (time() >= (int) ($c['exp'] ?? 0)) {
return '已过期';
}
return '';
}
// jwks_cert:从 JWKS 取 kid 对应的公钥证书(x5c,转成 PEM 给 openssl 用)。
// 示例每次都拉;正式项目应缓存,遇到不认识的 kid 再刷新一次(平台轮换密钥时会出现)。
function jwks_cert(string $kid): string
{
$set = http_json(KG_API . '/oauth/jwks', null);
foreach ($set['keys'] ?? [] as $k) {
if (($k['kid'] ?? '') === $kid && !empty($k['x5c'][0])) {
return "-----BEGIN CERTIFICATE-----\n" . chunk_split($k['x5c'][0], 64, "\n") . "-----END CERTIFICATE-----\n";
}
}
return '';
}本地运行(PHP 内置服务器,把 index.php 当路由脚本):
bash
export KG_APP_ID=YOUR_APP_KEY
export KG_APP_SECRET=YOUR_APP_SECRET
php -S 0.0.0.0:8090 index.php正式部署时用 nginx / Apache 把所有请求重写到 index.php,例如 nginx:
nginx
location / {
try_files $uri /index.php$is_args$args;
}
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/index.php;
fastcgi_param KG_APP_ID YOUR_APP_KEY;
fastcgi_param KG_APP_SECRET YOUR_APP_SECRET; # 或写进 php-fpm 的 env[...],不要放进网站目录
fastcgi_pass unix:/run/php/php-fpm.sock;
}会话 Cookie 同样设了 Secure,本地纯 http 调试时把 cookie_secure 改成 false。
错误响应
令牌端点错误为 OAuth 标准格式:
json
{ "error": "invalid_grant", "error_description": "授权码无效或已过期" }| HTTP | error | 常见原因 |
|---|---|---|
| 400 | invalid_request | 缺少 PKCE 参数 / 未使用 S256(授权页跳转方式) |
| 400 | unsupported_response_type | response_type 不是 code |
| 400 | unsupported_grant_type | grant_type 不是 authorization_code / refresh_token |
| 401 | invalid_client | client_id 不存在或已禁用 / client_secret 错误 |
| 400 | invalid_grant | 授权码无效或已过期 / 与 client 不匹配 / redirect_uri 不一致 / PKCE 校验失败 / refresh_token 无效 |
| 400 | invalid_grant | refresh_token 已被使用过(重放),该授权下的令牌已全部吊销,需重新授权 |
| 401 | invalid_token | userinfo 缺少或无效的 Bearer access_token(含因重放被吊销) |
| — | access_denied | 用户在授权页拒绝授权(回跳到 redirect_uri) |
扫码登录出码时的错误(appid 不存在或应用未上线、redirect_uri 未登记、公开客户端未传 code_challenge 等)直接显示在二维码区域,不会回跳 redirect_uri。