先在开发者中心创建应用拿到凭证,再按标准 OAuth 2.0 走一遍授权即可。
不需要来回沟通、不需要等人工开通 —— 提交后审核通过就能拿到 client_id。
用布丁账号登录开发者中心,填一个能收信的邮箱并完成验证。邮箱是唯一联系方式 —— 审核结果、密钥找回、违规通知都发这里。
填应用名、简介、主页,以及回调地址白名单。提交后进入审核队列,通过即拿到 client_id 与 client_secret。
把用户浏览器送到 /v1/oauth2/authorize。用户在布丁的授权页上确认一次,浏览器带 code 回到你的回调地址。
拿 code 在服务端换 access_token,再用它调 /userinfo 拿昵称和头像。至此登录完成。
client_secret 是应用的身份证,一旦放进网页前端、App 安装包或公开的代码仓库,
任何人都能拿它冒充你的应用。浏览器只该拿到一次性的 code,code 用过即废。
生产环境基址 https://sdk.idcbdy.com。全部接口走 HTTPS,
生产环境不支持 http:// 授权回调(浏览器会拦,我们也会拒)。
| 方法 | 路径 | 说明 | 调用方 |
|---|---|---|---|
| GET | /v1/oauth2/authorize | 把用户送到授权页。参数:client_id、redirect_uri、response_type=code、state、scope,可选 code_challenge + code_challenge_method=S256 |
浏览器跳转 |
| GET | /v1/oauth2/authorize/info | 只取应用信息与权限文案,供你自绘授权页。不产生授权码 | 浏览器 / 服务端 |
| POST | /v1/oauth2/authorize/decision | 提交用户的同意/拒绝决定。用自绘授权页时才需要 | 浏览器 |
| POST | /v1/oauth2/token | 用 code 换令牌,或用 refresh_token 续期。需 client_secret服务端 |
服务器 → 服务器 |
| GET | /v1/oauth2/userinfo | 取用户资料。需带 access_token服务端 |
服务器 → 服务器 |
| POST | /v1/oauth2/revoke | 撤销令牌。用户在你的应用里点「解除绑定」时调用 | 服务器 → 服务器 |
| GET | /v1/oauth2/health | 健康检查。无需鉴权,可用于探活 | 任意 |
code。不支持 token 简化模式userinfocode_verifier/userinfo 时放在 Authorization: BearerBeareruid,但它不跨应用共享 ——
别的应用拿到的 uid 你不该也无法关联。想认人就用 uid 建本地账号,
昵称和头像只当展示字段,每次登录都刷新一遍。
布丁互联就是标准 OAuth 2.0,所以官方 SDK 不是必需的 —— 你完全可以用现成的任意 OAuth 库,或者直接发 HTTP 请求。 下面是最小可运行版本,复制走改掉三个常量就能跑。
# ── 第 1 步:把用户浏览器送到这里(在浏览器地址栏打开)────────────
# 用户点「同意授权」后,会 302 回你的 redirect_uri 并带上 code
https://sdk.idcbdy.com/v1/oauth2/authorize?\
client_id=你的client_id&\
redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&\
response_type=code&\
scope=userinfo&\
state=随机串随便你
# ── 第 2 步:拿 code 换令牌(★ 必须在服务端执行)───────────────
curl -s -X POST https://sdk.idcbdy.com/v1/oauth2/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'grant_type=authorization_code' \
-d 'client_id=你的client_id' \
-d 'client_secret=你的client_secret' \
-d 'code=回调地址带来的code' \
-d 'redirect_uri=https://example.com/callback'
# ── 第 3 步:取用户资料 ───────────────────────────────────
curl -s https://sdk.idcbdy.com/v1/oauth2/userinfo \
-H 'Authorization: Bearer 上一步拿到的access_token'
// 布丁互联接入 —— Node.js(Express 版,无第三方依赖)
import express from 'express'
import crypto from 'node:crypto'
const app = express()
// ★ 这三个从环境变量读,绝不写进代码、绝不进版本库
const CLIENT_ID = process.env.PUDDING_CLIENT_ID
const CLIENT_SECRET = process.env.PUDDING_CLIENT_SECRET
const REDIRECT_URI = 'https://example.com/callback'
const BASE = 'https://sdk.idcbdy.com/v1/oauth2'
// ① 跳到布丁授权页
app.get('/login', (req, res) => {
// state 必须是随机的:它是防 CSRF 的唯一手段,写死等于没有
const state = crypto.randomBytes(16).toString('hex')
req.session.state = state
const q = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: REDIRECT_URI,
response_type: 'code',
scope: 'userinfo',
state,
})
res.redirect(`${BASE}/authorize?${q}`)
})
// ② 处理回调:换令牌 + 取资料
app.get('/callback', async (req, res) => {
const { code, state } = req.query
// ★ state 不匹配就直接拒。少了这一步,攻击者能拿别人的 code 登进我们这
if (!state || state !== req.session.state) return res.status(400).send('state 校验失败')
const tr = await fetch(`${BASE}/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
client_id: CLIENT_ID,
client_secret: CLIENT_SECRET,
code,
redirect_uri: REDIRECT_URI,
}),
})
const tk = await tr.json()
if (!tr.ok) return res.status(502).send('换令牌失败: ' + JSON.stringify(tk))
const ur = await fetch(`${BASE}/userinfo`, {
headers: { Authorization: `Bearer ${tk.access_token}` },
})
const me = await ur.json()
// ★ 用 uid 认人,别用 nickname —— 昵称随时能改
const user = await findOrCreateUser({ uid: me.uid, name: me.nickname, avatar: me.avatar })
req.session.uid = user.id
res.redirect('/')
})
// 布丁互联接入 —— Go(标准库,无第三方依赖)
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
)
const puddingBase = "https://sdk.idcbdy.com/v1/oauth2"
var (
clientID = os.Getenv("PUDDING_CLIENT_ID")
clientSecret = os.Getenv("PUDDING_CLIENT_SECRET")
redirectURI = "https://example.com/callback"
)
// AuthURL 生成给用户点的那条链接。state 由调用方随机生成并先存进会话。
func AuthURL(state string) string {
q := url.Values{}
q.Set("client_id", clientID)
q.Set("redirect_uri", redirectURI)
q.Set("response_type", "code")
q.Set("scope", "userinfo")
q.Set("state", state)
return puddingBase + "/authorize?" + q.Encode()
}
type Token struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
// Exchange 用 code 换令牌。★ 必须在服务端调 —— client_secret 不能下发到浏览器。
func Exchange(ctx context.Context, code string) (*Token, error) {
form := url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("client_id", clientID)
form.Set("client_secret", clientSecret)
form.Set("code", code)
form.Set("redirect_uri", redirectURI)
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
puddingBase+"/token", strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// ★ 400 也要读 body:错误详情在 JSON 里,不看就只能猜
var out Token
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, fmt.Errorf("解析令牌响应失败: %w", err)
}
return &out, nil
}
type Profile struct {
UID string `json:"uid"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ShortNo string `json:"short_no"`
}
// UserInfo 取用户资料。用 UID 建本地账号,别用昵称。
func UserInfo(ctx context.Context, accessToken string) (*Profile, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
puddingBase+"/userinfo", nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var p Profile
if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
return nil, err
}
return &p, nil
}
// 布丁互联接入 —— PHP(无 Composer 依赖)
<?php
const BASE = 'https://sdk.idcbdy.com/v1/oauth2';
$clientId = getenv('PUDDING_CLIENT_ID');
$clientSecret = getenv('PUDDING_CLIENT_SECRET');
$redirectUri = 'https://example.com/callback';
// ① 生成授权链接。state 存进 session,回来要逐字比对。
function authUrl($clientId, $redirectUri) {
$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;
return BASE . '/authorize?' . http_build_query([
'client_id' => $clientId,
'redirect_uri' => $redirectUri,
'response_type' => 'code',
'scope' => 'userinfo',
'state' => $state,
]);
}
// ② 用 code 换令牌
function exchange($clientId, $clientSecret, $code, $redirectUri) {
$ch = curl_init(BASE . '/token');
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_POSTFIELDS => http_build_query([
'grant_type' => 'authorization_code',
'client_id' => $clientId,
'client_secret' => $clientSecret,
'code' => $code,
'redirect_uri' => $redirectUri,
]),
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true);
}
// ③ 取用户资料
function userinfo($accessToken) {
$ch = curl_init(BASE . '/userinfo');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $accessToken],
]);
$body = curl_exec($ch);
curl_close($ch);
return json_decode($body, true);
}
// ④ 回调处理
if (isset($_GET['code'])) {
// ★ state 必须校验:不校验的话,攻击者能拿别人的 code 登进我们的账号
if (!hash_equals($_SESSION['oauth_state'] ?? '', $_GET['state'] ?? '')) {
http_response_code(400); exit('state 校验失败');
}
$tk = exchange($clientId, $clientSecret, $_GET['code'], $redirectUri);
$me = userinfo($tk['access_token']);
// ★ 用 uid 认人
$_SESSION['uid'] = $me['uid'];
}
# 布丁互联接入 —— Python(Flask + requests)
import os, secrets, requests
from flask import Flask, request, session, redirect
app = Flask(__name__)
BASE = "https://sdk.idcbdy.com/v1/oauth2"
CLIENT_ID = os.environ["PUDDING_CLIENT_ID"]
CLIENT_SECRET = os.environ["PUDDING_CLIENT_SECRET"]
REDIRECT_URI = "https://example.com/callback"
# ① 跳去授权页
@app.get("/login")
def login():
# state 每次都要重新随机:写死或复用等于没有防护
session["state"] = secrets.token_urlsafe(16)
q = {
"client_id": CLIENT_ID,
"redirect_uri": REDIRECT_URI,
"response_type": "code",
"scope": "userinfo",
"state": session["state"],
}
return redirect(BASE + "/authorize?" + urlencode(q))
# ② 回调:换令牌 + 取资料
@app.get("/callback")
def callback():
# ★ 不校验 state,攻击者就能把别人的 code 塞给我们的回调
if not secrets.compare_digest(session.pop("state", ""), request.args.get("state", "")):
return "state 校验失败", 400
# ★ 换令牌只在服务端做,且必须带同一个 redirect_uri
r = requests.post(BASE + "/token", data={
"grant_type": "authorization_code",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
"code": request.args["code"],
"redirect_uri": REDIRECT_URI,
}, timeout=10)
tk = r.json()
me = requests.get(BASE + "/userinfo", headers={
"Authorization": f"Bearer {tk['access_token']}",
}, timeout=10).json()
# ★ 用 uid 认人,nickname 随时会变
user = find_or_create(uid=me["uid"], name=me["nickname"], avatar=me["avatar"])
session["uid"] = user.id
return redirect("/")
// 布丁互联接入 —— Java 11+(java.net.http,无第三方依赖)
import java.net.URI;
import java.net.URLEncoder;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.net.URLDecoder;
import java.security.SecureRandom;
import java.util.HexFormat;
public final class PuddingConnect {
private static final String BASE = "https://sdk.idcbdy.com/v1/oauth2";
private static final HttpClient HTTP = HttpClient.newHttpClient();
private final String clientId = System.getenv("PUDDING_CLIENT_ID");
private final String clientSecret = System.getenv("PUDDING_CLIENT_SECRET");
private final String redirectUri = "https://example.com/callback";
// ① 生成授权链接。state 随机生成后必须先存进会话,回调时逐字比对。
public String authUrl(String state) {
return BASE + "/authorize?"
+ "client_id=" + enc(clientId)
+ "&redirect_uri=" + enc(redirectUri)
+ "&response_type=code"
+ "&scope=userinfo"
+ "&state=" + enc(state);
}
// ② 用 code 换令牌。★ 必须在服务端调 —— client_secret 不能下发到浏览器。
public String exchange(String code) throws Exception {
String form = "grant_type=authorization_code"
+ "&client_id=" + enc(clientId)
+ "&client_secret=" + enc(clientSecret)
+ "&code=" + enc(code)
+ "&redirect_uri=" + enc(redirectUri);
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/token"))
.header("Content-Type", "application/x-www-form-urlencoded")
.timeout(java.time.Duration.ofSeconds(10))
.POST(HttpRequest.BodyPublishers.ofString(form))
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
// ③ 取用户资料
public String userinfo(String accessToken) throws Exception {
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/userinfo"))
.header("Authorization", "Bearer " + accessToken)
.timeout(java.time.Duration.ofSeconds(10))
.GET()
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
private static String enc(String s) {
return URLEncoder.encode(s, StandardCharsets.UTF_8);
}
}
如果你的应用是 App 或纯前端 SPA,它藏不住 secret ——
反编译、看网络请求都能拿到。这种情况下请开 PKCE:
授权时多带一个 code_challenge,
换令牌时把原始 code_verifier 带上。
这样即使 code 在中途被截走,没有 verifier 也换不出令牌。
BASE64URL(SHA256(verifier)),授权时带S256。不支持 plain官方不强制 SDK —— 标准 OAuth 2.0 用现成库就好。 但如果你想把上面的样板代码直接拿走,可以下载这份示例包。
只申请你用得到的权限。权限越多,用户在授权页上越犹豫,审核也越慢。
未来新增的 scope 会在作者后台公告,并且永远需要用户重新同意 —— 不会出现「上次点过同意,这次偷偷多拿一项权限」的情况。
error 与 error_description,别只看 HTTP 状态码;
② invalid_grant 九成是 code 用第二次了 —— code 是一次性的;
③ 还是不通就比对 redirect_uri:我们做的是精确字符串匹配,
https://a.com/cb 与 https://a.com/cb/ 是两个不同的地址。
不要写进前端、App 包、代码仓库、日志。client_secret 服务端只存哈希,泄露了改不回来,只能重置 —— 重置会让所有线上令牌立即失效。
随机生成、存进会话、回调时逐字比对、用完即弃。不校验 state 是 OAuth 接入最常见的漏洞,攻击者能借此把别人的账号绑到自己的会话上。
我们只在白名单里做精确匹配,不做通配、不做前缀。这样即便有人伪造一条带恶意回调的授权链接,用户点进去也拿不到任何东西。
昵称随时能改,用它当主键迟早出现两个人撞名、或者改名后账号丢失。头像同理:每次登录刷新一遍,别缓存成唯一凭据。