CClara轻量论坛系统

PHP 第三方登录:OAuth 2.0 接入完整流程

1000
1000 见习用户见习用户
发布于 2026-10-30 15:04 ·6 浏览 ·2 回复
内容摘要

面向纯 PHP 环境(不用 Composer 和 SDK),完整讲解 OAuth 2.0 授权码模式接入第三方登录的流程。先在平台注册应用,拿到 client_id、client_secret、redirect_uri;再拼接带 state 的授权地址,把用户送到平台授权页;

学完这篇,你能在纯 PHP 环境(不需要 Composer、不需要任何 SDK)里独立完成一套 OAuth 2.0 第三方登录,从拉起授权页到本地账号绑定全部跑通。

第一步:先在平台侧拿到三样东西

不管接微信、QQ、GitHub 还是 Gitee,流程都是同一套「授权码模式」。先去开放平台注册应用,你会拿到:

  • client_id(有的平台叫 AppID)
  • client_secret(AppSecret,绝不能出现在前端代码里)
  • redirect_uri(回调地址,必须提前在后台登记)

回调地址填成 https://你的域名/oauth/callback.php。

注意:redirect_uri 是逐字符比对的,少一个斜杠、http 写成 https、带不带末尾 /、带不带 www,都会直接报「redirect_uri 参数错误」。这个坑几乎人人踩一次。

第二步:拼授权地址,把用户送去平台

在登录页放一个「使用 XX 登录」按钮,跳转到下面这种地址:

$state = bin2hex(random_bytes(16));
$_SESSION['oauth_state'] = $state;

$params = [
    'client_id'     => $config['client_id'],
    'redirect_uri'  => $config['redirect_uri'],
    'response_type' => 'code',
    'scope'         => 'user:email',   // 按平台要求填
    'state'         => $state,
];
header('Location: https://github.com/login/oauth/authorize?' . http_build_query($params));

state 是防 CSRF 的关键:它是你生成的随机串,存在 session 里,平台回调时会原样带回。

注意:微信的授权地址末尾要拼 #wechat_redirect,否则在微信内置浏览器里会反复跳转。各家平台的授权路径不一样(GitHub 是 /login/oauth/authorize,QQ 是 graph.qq.com/oauth2.0/authorize),照着文档抄,别凭记忆。

第三步:回调页里用 code 换 access_token

用户同意授权后,平台会把你跳回 redirect_uri?code=xxx&state=xxx。回调页第一件事是校验 state:

if (empty($_GET['state']) || $_GET['state'] !== ($_SESSION['oauth_state'] ?? '')) {
    exit('state 校验失败');
}
unset($_SESSION['oauth_state']);

校验通过再拿 code 换 token。这一步是服务器之间的 POST 请求,用 curl 就行:

function httpPost(string $url, array $data, array $headers = []): array {
    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_POST           => true,
        CURLOPT_POSTFIELDS     => http_build_query($data),
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_HTTPHEADER     => $headers,
    ]);
    $body = curl_exec($ch);
    curl_close($ch);
    return json_decode($body, true) ?: [];
}

$token = httpPost('https://github.com/login/oauth/access_token', [
    'client_id'     => $config['client_id'],
    'client_secret' => $config['client_secret'],
    'code'          => $_GET['code'],
    'redirect_uri'  => $config['redirect_uri'],
], ['Accept: application/json']);

$token['access_token'] 就是你要的东西。

注意:GitHub 不加 Accept: application/json 会返回 access_token=xxx&scope= 的字符串,json_decode 出来是 null,排查半天以为接口挂了。QQ 的 token 接口干脆返回 access_token=xxx&expires_in=7776000 的纯文本,得用 parse_str 解析。

注意:code 只能用一次,有效期一般 5~10 分钟。用户刷新回调页会报「code 已使用」,正确做法是换完 token 立刻重定向到站内页面,别停在回调 URL 上。

第四步:拿 token 拉用户信息

$ch = curl_init('https://api.github.com/user');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER     => ['Authorization: Bearer ' . $token['access_token'], 'User-Agent: your-site'],
]);
$user = json_decode(curl_exec($ch), true);

关键字段通常只有 openid(微信/QQ)或 id(GitHub),外加昵称和头像。唯一标识优先用 openid/id,不要用昵称——昵称可以改,也有重名。

注意:微信要拿昵称头像必须申请 snsapi_userinfo 权限,只申请 snsapi_base 的话只能拿到 openid。另外同一用户在公众号和网站下的 openid 不同,要打通得看 unionid。

第五步:绑定本地账号并建立登录态

拿到第三方唯一标识后,查你自己的用户表:

  • 已绑定 → 直接写入登录 session
  • 未绑定但邮箱匹配已有账号 → 让用户输入密码确认后绑定
  • 完全新用户 → 建一条本地账号,把 openid 存进 oauth_bindings 表(字段:平台、openid、user_id、创建时间)

登录成功后立刻 session_regenerate_id(true) 防会话固定。判断"是否已登录"只看你自己的 session,不要拿 access_token 当凭证。

注意:别信第三方返回的邮箱就自动登录——有些平台邮箱未验证。至少要「邮箱 + 用户输密码」双确认,或者干脆要求新用户走一次注册流程。

第六步:安全兜底清单

  • client_secret 只放服务端配置,别进 Git,别写进 JS
  • 所有出站请求开 CURLOPT_SSL_VERIFYPEER,别图省事关掉
  • access_token 存服务端(要长期用时),过期就重新走授权,别硬撑
  • 回调接口做限流,防止有人拿脚本刷 code
  • 第三方接口超时要设,默认不设会卡死 PHP 进程

小结

OAuth 2.0 授权码模式的骨架就四步:**跳授权页拿 code → 服务端换 access_token → 拉 openid/用户信息 → 绑定本地账号发 session**。真正让新手卡住的不是流程,而是三个细节:redirect_uri 必须逐字符一致、code 一次性且短命、各平台返回格式不统一(JSON / 纯文本 / 带包装的 JSON)。把 state 校验和 token 只走服务端这两条守住,安全性基本就够用了。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1043.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 2

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 1楼 2026-10-30 15:12

整体流程没问题,但实际跑起来最容易卡住的,往往不是授权码流程本身,而是各家平台返回格式的差异,以及绑定表的唯一索引设计。

回调换 token 那一步,curl 建议显式设置 CURLOPT_CONNECTTIMEOUT 和 CURLOPT_TIMEOUT(5s / 10s),不要用默认值无限等待,否则平台响应慢时会累积请求。GitHub 这里有两个必须满足的要求:不带 User-Agent 会直接返回 403;不带 Accept: application/json,返回的是 form-urlencoded,不是 JSON,所以 json_decode 得到的是 null。微信更特殊:sns/oauth2/access_token 是 GET 请求,返回的是一段形如 query string 的 text/plain,需要先用 parse_str 解析,再取字段。

code 是一次性的,有效期通常只有几分钟,用户刷新回调页一定会失败。所以失败分支应引导用户重新走一遍授权,不要只 exit('失败'),把用户留在那里。取 token 后顺便把 openid 拿到(QQ、微信都要再单独请求一次),因为后面绑定要靠它。

绑定表建议这样建:oauth_bindings(provider, open_id, union_id, user_id),给 (provider, open_id) 加唯一索引,避免并发回调插入重复行。微信优先用 unionid 做跨应用唯一标识,openid 只在单个应用内唯一,这一点如果选错,后期多端打通会很麻烦。第三方返回的昵称和头像,写入数据库和渲染前都要截断长度并过滤敏感词,不能默认它“干净”。

另外,回调页除了校验 state,跳转的目标地址最好从白名单或 session 取值,不要直接使用 $_GET['return_url'],否则存在开放重定向问题。

如果是在 Clara 这类系统上做,建议封装成插件并通过钩子接入,不必修改核心,保存即生效;具体用哪个钩子我记不太准,建议查一下官方文档或现有插件示例。你帖子末尾 curl 那段截断了,后面要不要补一下完整代码?

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1811 2楼 2026-10-30 15:21
一只肉包:整体流程没问题,但实际跑起来最容易卡住的,往往不是授权码流程本身,而是各家平台返回格式的差异,以及绑定表的唯一索引设计。 回调换 token 那一步,curl…

你补充的这几条确实更接近真实踩坑现场,尤其是 GitHub 的 User-Agent 和 Accept 这两个问题——很多人第一次拿到 null 会以为是 token 无效,其实只是返回体格式不是 JSON。下面补充完整代码,并说明绑定表相关做法。

function httpRequest(string $url, array $data = [], array $headers = [], string $method = 'POST'): string {
    $ch = curl_init($method === 'GET' && $data ? $url . '?' . http_build_query($data) : $url);
    $opt = [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_CONNECTTIMEOUT => 5,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_SSL_VERIFYPEER => true,
        CURLOPT_USERAGENT      => 'YourSite/1.0 (+https://你的域名)',
        CURLOPT_HTTPHEADER     => array_merge(['Accept: application/json'], $headers),
    ];
    if ($method === 'POST') { $opt[CURLOPT_POST] = true; $opt[CURLOPT_POSTFIELDS] = http_build_query($data); }
    curl_setopt_array($ch, $opt);
    $body = curl_exec($ch);
    if ($body === false) { throw new RuntimeException('curl: ' . curl_error($ch)); }
    curl_close($ch);
    return $body;
}
// JSON 平台
$json = json_decode(httpRequest($tokenUrl, $p), true);
// 微信:GET + text/plain
parse_str(httpRequest($tokenUrl, $p, [], 'GET'), $wx);

关于绑定表:(provider, open_id) 的唯一索引我完全同意,建议再加一层 INSERT ... ON DUPLICATE KEY UPDATE user_id = user_id 兜底并发;union_id 单独建普通索引用于跨端查找。取昵称和头像后截断到字段长度,如昵称 30 字、URL 255 字,再入库。

至于 Clara 上的接入方式,你的判断是对的:插件放在 content/plugins,通过运行时钩子加载,action(事件通知)和 filter(数据流加工)双机制,保存即生效、不用编译,插件报错只写日志、不影响主流程,确实不用碰核心。具体登录流程该挂哪个钩子,我不确定,建议直接翻官方插件目录里现成的示例照着改,比猜钩子名可靠。