让用户用布丁账号,一键登录你的应用

布丁互联(Pudding Connect)是布丁家族的开放平台。基于标准 OAuth 2.0 授权码模式,几行代码就能接入布丁账号登录 —— 拿到昵称、头像和布丁号,不用再逼用户注册一个新账号、再想一个密码。

OAuth 2.0授权码模式,业界通用
PKCE支持,移动端与单页应用安全
3 个接口完成一次登录
0 依赖SDK 可选,纯 HTTP 也能接
Getting started

四步接入

先在开发者中心创建应用拿到凭证,再按标准 OAuth 2.0 走一遍授权即可。 不需要来回沟通、不需要等人工开通 —— 提交后审核通过就能拿到 client_id。

1

注册开发者

用布丁账号登录开发者中心,填一个能收信的邮箱并完成验证。邮箱是唯一联系方式 —— 审核结果、密钥找回、违规通知都发这里。

2

创建应用

填应用名、简介、主页,以及回调地址白名单。提交后进入审核队列,通过即拿到 client_id 与 client_secret。

3

发起授权

把用户浏览器送到 /v1/oauth2/authorize。用户在布丁的授权页上确认一次,浏览器带 code 回到你的回调地址。

4

换令牌取资料

拿 code 在服务端换 access_token,再用它调 /userinfo 拿昵称和头像。至此登录完成。

一次完整登录的时序
用户浏览器
① GET /authorize?client_id&redirect_uri&state
布丁互联
布丁互联
② 展示授权页(应用名 + 将获取的权限)
用户浏览器
用户浏览器
③ 用户点「同意授权」
布丁互联
布丁互联
④ 302 到 redirect_uri?code=…&state=…
你的服务端
你的服务端
⑤ POST /token(带 client_secret)
布丁互联
布丁互联
⑥ access_token + refresh_token
你的服务端
你的服务端
⑦ GET /userinfo(带 access_token)
布丁互联
布丁互联
⑧ {uid, nickname, avatar, short_no}
你的服务端
🔑
第 ⑤ 步必须在服务端做。 client_secret 是应用的身份证,一旦放进网页前端、App 安装包或公开的代码仓库, 任何人都能拿它冒充你的应用。浏览器只该拿到一次性的 code,code 用过即废。
API reference

接口文档

生产环境基址 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 健康检查。无需鉴权,可用于探活 任意

请求参数

client_id应用 ID。在开发者中心创建应用后生成,可公开
client_secret应用密钥。★ 只存哈希,丢了只能重置
redirect_uri回调地址。必须与白名单完全一致,不做前缀匹配
response_type固定 code。不支持 token 简化模式
state你生成的随机串,回来时原样带回。用来防 CSRF,务必校验
scope以空格分隔。默认 userinfo
code_challenge可选。传了就必须在换令牌时带 code_verifier

返回值

access_token访问令牌。调 /userinfo 时放在 Authorization: Bearer
refresh_token刷新令牌。access_token 过期后用它换新的
expires_inaccess_token 剩余有效秒数
token_type固定 Bearer
scope实际授予的权限,可能少于请求的
uid用户唯一标识。★ 用它做你的账号主键,不要用昵称
nickname昵称。用户随时可改,不适合做唯一标识
avatar头像 URL
short_no布丁号。给用户看的编号,比 uid 友好
⚠️
关于 uid。 同一个布丁账号在你这儿永远是同一个 uid,但它不跨应用共享 —— 别的应用拿到的 uid 你不该也无法关联。想认人就用 uid 建本地账号, 昵称和头像只当展示字段,每次登录都刷新一遍。
SDK & samples

SDK 与代码示例

布丁互联就是标准 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);
    }
}

关于 PKCE(移动端 / 单页应用必看)

如果你的应用是 App 或纯前端 SPA,它藏不住 secret —— 反编译、看网络请求都能拿到。这种情况下请开 PKCE: 授权时多带一个 code_challenge, 换令牌时把原始 code_verifier 带上。 这样即使 code 在中途被截走,没有 verifier 也换不出令牌。

code_challengeBASE64URL(SHA256(verifier)),授权时带
code_challenge_method固定写 S256。不支持 plain
code_verifier原始随机串(43~128 字符),换令牌时带

SDK 下载

官方不强制 SDK —— 标准 OAuth 2.0 用现成库就好。 但如果你想把上面的样板代码直接拿走,可以下载这份示例包。

⬇ 下载示例包(ZIP) 开发者中心
ℹ️
示例包里是上面这 6 种语言的最小可运行版本,不含任何私有密钥,可以直接提交进版本库。
Scopes & errors

权限与错误码

只申请你用得到的权限。权限越多,用户在授权页上越犹豫,审核也越慢。

权限范围(scope)

userinfo昵称、头像、布丁号。默认权限,不传 scope 时自动回落为它

未来新增的 scope 会在作者后台公告,并且永远需要用户重新同意 —— 不会出现「上次点过同意,这次偷偷多拿一项权限」的情况。

常见错误

invalid_clientclient_id 不存在,或 client_secret 不对
invalid_grantcode 已用过 / 已过期,或 redirect_uri 与授权时不一致
invalid_request缺必填参数,或 response_type 不是 code
invalid_scope申请了不存在的 scope
access_denied用户在授权页点了「拒绝」
unauthorized_client应用被停用,或回调地址没在白名单里
✅
排障三步: ① 先看返回 JSON 里的 error 与 error_description,别只看 HTTP 状态码; ② invalid_grant 九成是 code 用第二次了 —— code 是一次性的; ③ 还是不通就比对 redirect_uri:我们做的是精确字符串匹配, https://a.com/cb 与 https://a.com/cb/ 是两个不同的地址。
Security

接入前必须知道的四件事

!

secret 只留在服务端

不要写进前端、App 包、代码仓库、日志。client_secret 服务端只存哈希,泄露了改不回来,只能重置 —— 重置会让所有线上令牌立即失效。

!

state 必须校验

随机生成、存进会话、回调时逐字比对、用完即弃。不校验 state 是 OAuth 接入最常见的漏洞,攻击者能借此把别人的账号绑到自己的会话上。

!

redirect_uri 走白名单

我们只在白名单里做精确匹配,不做通配、不做前缀。这样即便有人伪造一条带恶意回调的授权链接,用户点进去也拿不到任何东西。

!

uid 认人,昵称只做展示

昵称随时能改,用它当主键迟早出现两个人撞名、或者改名后账号丢失。头像同理:每次登录刷新一遍,别缓存成唯一凭据。