Skip to content

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(需 unionid scope)

两种接入方式,只是拿授权码 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 对齐。

前置准备 ​

  1. 在开发者中心创建 H5APP,获得 AppKey(即 client_id)与 AppSecret(即 client_secret)。
  2. 登记 redirect_uris 回跳地址白名单(推荐,精确匹配)。登记后支持任意 scheme:native 客户端可用 custom scheme(如 oauthdemo://callback)或 loopback(http://127.0.0.1:port)。
  3. 未登记白名单时的回退规则:
    • 配置了「网站域名」→ 回跳地址必须是绝对 http(s) 且与网站同域或子域
    • 两者都未配置 → 拒绝授权(防开放重定向),接入前必须先登记
  4. 可选:登记允许的 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 只用一次,不要多个进程 / 标签页并发刷新同一张(并发时只有一个成功,其余按重放处理)。
  • 刷新请求超时后不要拿旧票重试:服务端可能已经轮换成功、只是应答丢了,重试会触发吊销。超时就让用户重新授权。

令牌有效期 ​

令牌有效期
授权码 code15 分钟(单次使用)
access_token8 小时(28800 秒)
refresh_token机密客户端 30 天;公开客户端(AppType=1)7 天。轮换制,每次刷新重新计时
id_token8 小时

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
unionIdunionid跨应用唯一标识
name / nicknameprofile昵称
pictureprofile头像 URL
genderprofile性别:0-未知 1-男 2-女
mobile / phone_numbermobile手机号 / E.164 格式手机号

id_token 验签 ​

id_token 为 JWT,RS256 签名,私钥只在平台,公钥经 JWKS 端点公开下发。机密客户端、公开客户端(SPA / App)都能本地验签,与 AppSecret 无关。

JWKS 端点 ​

http
GET /api/oauth/jwks
json
{
  "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://..."
}

验签要点:

  1. header 的 alg 必须是 RS256(只认这一种,防 alg=none / HS256 混淆伪造),按 kid 从 JWKS 取公钥验签
  2. 校验 iss == https://kg.citv.cc
  3. 校验 aud == 你的 AppKey
  4. 校验 exp 未过期
  5. 若请求时传了 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": "授权码无效或已过期" }
HTTPerror常见原因
400invalid_request缺少 PKCE 参数 / 未使用 S256(授权页跳转方式)
400unsupported_response_typeresponse_type 不是 code
400unsupported_grant_typegrant_type 不是 authorization_code / refresh_token
401invalid_clientclient_id 不存在或已禁用 / client_secret 错误
400invalid_grant授权码无效或已过期 / 与 client 不匹配 / redirect_uri 不一致 / PKCE 校验失败 / refresh_token 无效
400invalid_grantrefresh_token 已被使用过(重放),该授权下的令牌已全部吊销,需重新授权
401invalid_tokenuserinfo 缺少或无效的 Bearer access_token(含因重放被吊销)
—access_denied用户在授权页拒绝授权(回跳到 redirect_uri)

扫码登录出码时的错误(appid 不存在或应用未上线、redirect_uri 未登记、公开客户端未传 code_challenge 等)直接显示在二维码区域,不会回跳 redirect_uri。