简介:这是一套面向微信开发者与公众号运营者的多域名回调登录系统源码,核心解决公众号单一回调域名受限问题,通过多域名回调机制实现微信授权登录在不同站点间的灵活调度。资源包共501个文件、约8.5MB,主要由257个JS脚本、86个PHP接口文件、57个CSS样式,以及图片、字体、SQL数据库、日志等辅材构成。JS层承载前端授权跳转与页面交互,PHP层实现后台验签、回调解析与用户绑定,CSS负责管理界面布局,结构清晰,便于按模块查阅和二次开发。目前已有370人学习下载,适合需要快速上线公众号登录功能的中小型企业技术团队,也可作为研究微信OAuth2.0授权流程、多域名回调策略与登录态保持机制的参考实现。解压后按目录索引初始化数据库即可部署,能为开发者节省从零搭建的时间成本。
1. 公众号无限回调登录接口到底解决什么问题
做公众号矩阵的人,迟早会被“回调登录”卡一次:网页授权回调域名在公众号后台只能配置一个,校验的也只是域名本身,跟路径、端口通通无关。当你同时维护官网、活动 H5、多个落地页,或者用多公众号组矩阵时,所有登录跳转就只能从唯一入口进出,再按参数分发给目标站点。
这个入口就是标题里的“多域名回调系统”。所谓“无限回调”,不是绕过平台限制,而是把回调整体参数化:入口域名固定,由 state 携带站点标识和来源上下文,网关据此路由、验签、换 token、恢复会话。
下面按网页授权链路、最小登录接口、中继网关与 Nginx、state 签名与幂等、以及调试脚本的顺序展开。新手能照着一路搭通,熟手可以直接拿走路由设计和排错顺序。
2. 网页授权回调的运作原理与登录接口最小实现
2.1 网页授权回调登录的完整链路
微信网页授权用的是标准 OAuth2 授权码模式,从用户点击到登录态建立,本质上是两次远端请求。第一次由浏览器发起,把用户引导到微信授权页:
https://open.weixin.qq.com/connect/oauth2/authorize?appid=APPID&redirect_uri=REDIRECT&response_type=code&scope=snsapi_base&state=STATE#wechat_redirect
用户确认身份后,微信带着code和原样的state重定向回redirect_uri。第二次发生在服务端,拿 code 去换取 access_token:
https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code
这其实就是一个由微信服务端在授权完成后触发的“回调函数”,只是它的载体是 HTTP 接口。理解这一点,就明白为什么回调接口必须无状态、可重试、必须记日志:它不是你页面里的普通路由,而是外部系统主动推给登录凭据的通道。
这个链路里最关键的一个事实是:code 只能用一次,有效期约 5 分钟,换过一次立刻失效。所以回调接口的第一原则是任何异常都要先把原始报文落日志,再处理业务。现场一旦被后续请求冲掉,排错就只能靠猜。
2.2 用 PHP 实现一个最简登录回调接口
不同语言写法大同小异,核心逻辑都一样。用 PHP 写最直接的版本:
<?php // callback.php —— 公众号网页授权统一回调入口 $code = $_GET['code'] ?? ''; $state = $_GET['state'] ?? ''; if (strlen($code) === 0 || strlen($state) === 0) { http_response_code(400); exit('missing code or state'); } // 先落一条最原始的日志,code 只留前 6 位,避免敏感信息进日志 error_log(sprintf("[oauth-cb] ts=%d code=%s state=%s", time(), substr($code, 0, 6), $state)); $appid = getenv('WX_APPID'); $secret = getenv('WX_SECRET'); $tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" . "?appid={$appid}&secret={$secret}&code={$code}&grant_type=authorization_code"; $resp = file_get_contents($tokenUrl); $data = json_decode((string)$resp, true); if (!isset($data['openid'])) { // 换取失败:把微信返回的 errcode/errmsg 完整写日志 error_log('[oauth-cb] token error: ' . $resp); exit('token error'); } session_start(); $_SESSION['openid'] = $data['openid']; $_SESSION['logged_in'] = true; // state 里存的是 base64 后的来源地址,还原并跳回 $from = base64_decode($state); header('Location: ' . ($from ?: '/'));几个取舍说明:getenv读密钥,避免 appid/secret 被提交进仓库;file_get_contents在 QPS 不高时够用,回调量上来后要换 curl 或 Guzzle 做连接复用;换取失败时微信返回的errcode/errmsg必须完整落日志,这是排错的第一现场。另外$_SESSION只适合单机部署,多实例环境要把会话迁到 Redis,否则用户在 A 机器登录,下一次请求被调度到 B 机器就直接掉线。
2.2.1 state 必须先于 code 校验
先校验 state 再动 code,一是防伪造回调。攻击者拿别人分享的跳转链接反复请求入口,如果先换 code,等于变相帮别人消耗一次性凭证。二是 state 里带着路由上下文,不解析它,你根本不知道该把用户送回哪里。校验失败直接返回 400,不开会话、不跳转。
2.2.2 会话写入的时机
很多代码的习惯是:先查用户表,没这个人就 insert,再写会话。放在登录回调里,这个顺序会放大问题。回调在弱网和前端重试下可能并发进来两次,两个请求同时查不到用户、同时插入,产生重复记录。正确做法是先建立会话、异步同步用户资料,或者用唯一索引兜底。登录接口的目标是尽快建立可信会话,不是在这一步把用户表事务做完。
2.3 scope 参数与授权方式的取舍
| 参数 | 取值 | 行为 | 适用场景 | 注意点 |
|---|---|---|---|---|
| scope | snsapi_base | 静默授权,用户无感 | 登录态、埋点、领券 | 只有 openid,无昵称头像 |
| scope | snsapi_userinfo | 弹确认框,可拿昵称头像 | 会员中心、社区类页面 | 每次首次授权都要确认,转化有损耗 |
| redirect_uri | URL 编码后的完整地址 | 微信跳回的位置 | 登录回调 | 域名必须与后台配置一致,否则报 redirect_uri 参数错误 |
| state | 自定义字符串 | 原样带回,防 CSRF | 路由与来源标记 | 不校验 state 等于给伪造回调留了口子 |
提示:绝大多数“登录”场景用 snsapi_base 就够,用户头像昵称可以等进入业务后再补,没必要让每个访客多点一次确认。如果你在微信开发者工具里调试,发现域名配了还是报错,先检查是不是把协议头或路径也写进了回调域名配置。
3. 多域名回调系统的中继设计与 Nginx 路由
3.1 为什么必须加一层中继
微信公众平台的限制是硬性的:网页授权回调域名只能填一个,校验的是域名而非路径。https://cb.example.com/wx/oauth和https://cb.example.com/wx/other算同一个域名,但换端口、换子域、换协议都不行。业务侧同时有a.com官网、m.b.com活动页、独立落地页c.com,三个站点都要微信登录,后台却只给一个位置,这就必须引入中继。
常见做法是把所有公众号的回调收敛到一个固定域名下的网关,由网关按 state 里的业务标识,把用户重定向回对应站点,登录态通过会话或一次性票据交给目标站点。微信后台只配一次,以后新增站点只需改网关路由表,不用再上平台改配置。这就是“多域名回调系统”的骨架。
3.2 三种中继拓扑怎么选
| 方案 | 结构 | 优点 | 风险 |
|---|---|---|---|
| 单域名参数路由 | 统一回调域名,按 state 分发 | 后台只配一次,新增站点零改动 | 网关单点,需要监控和限流 |
| 多公众号各自回调 | 每个公众号配各自域名,后端聚合登录态 | 业务隔离清晰,互不影响 | 公众号多了,后台维护成本高 |
| 泛解析动态跳转 | 泛域名解析到网关,动态生成子域 | 域名可批量生成,弹性最好 | 容易被风控识别为异常域名 |
一般选第一种,可控性最好:所有报文经过固定入口,验签、日志、限流集中在一处,后续接数据分析也方便。第二种适合两个团队各自维护公众号的极端隔离需求。第三种除非有规模化的活动域名诉求,不建议碰,域名批量动态变化很容易触发平台风控。
3.3 Nginx 网关配置示例
回调网关在 Nginx 上只暴露两个 location:一个给网页授权,一个给消息与事件推送。两者逻辑完全不同,混在同一入口会让验签和权限判断互相打架,支付回调更要单独隔离。
server { listen 443 ssl; http2 on; server_name cb.example.com; ssl_certificate /etc/nginx/ssl/cb.example.com.pem; ssl_certificate_key /etc/nginx/ssl/cb.example.com.key; # 网页授权回调:路径固定,路由信息全在参数里 location /wx/oauth { proxy_pass http://127.0.0.1:8080/oauth/gateway; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; } # 微信消息/事件推送回调 location /wx/notify { proxy_pass http://127.0.0.1:8080/notify/push; proxy_set_header Host $host; } }参数说明:proxy_set_header Host $host保证后端拿到的是访问时的原始域名,框架如果用域名做路由判断就依赖它;X-Real-IP记录真实来源 IP,微信回调出口 IP 相对固定,可以用来做一层粗校验。网页授权与消息推送拆开,后续分别加限流和监控都清晰。
提示:网关层只做转发,验签和身份判断必须放到业务服务里。任何来自 Nginx 自定义 header 的信任都是把安全边界前移到了不可控的位置。
3.4 网关后端按 state 路由到站点
<?php // oauth/gateway.php —— 多域名回调系统的统一分发入口 $state = $_GET['state'] ?? ''; if ($state === '') { http_response_code(400); exit('empty state'); } // state 格式:base64(json).signature,见第 4 章签名逻辑 $ctx = parseState($state, getenv('SIGN_SECRET')); if (!$ctx || !isset($ctx['site'])) { http_response_code(400); exit('bad state'); } // 路由表:站点标识 => 公众号密钥 $siteMap = [ 'mall' => ['appid' => getenv('MALL_APPID'), 'secret' => getenv('MALL_SECRET')], 'h5camp' => ['appid' => getenv('H5_APPID'), 'secret' => getenv('H5_SECRET')], 'landing' => ['appid' => getenv('LAND_APPID'), 'secret' => getenv('LAND_SECRET')], ]; if (!isset($siteMap[$ctx['site']])) { http_response_code(400); exit('unknown site'); } // 用该站点自己的 appid/secret 去换 access_token $appid = $siteMap[$ctx['site']]['appid']; $secret = $siteMap[$ctx['site']]['secret']; $tokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" . "?appid={$appid}&secret={$secret}&code={$_GET['code']}&grant_type=authorization_code"; // 后续换取与会话写入同第 2 章,跳转地址取 $ctx['target']这段解决了多公众号矩阵的核心问题:每个站点密钥独立,换 token 时按 state 里的 site 标识选择对应密钥,不会出现 A 公众号的 code 被拿去 B 公众号换取导致 40029 的串号错误。路由表从环境变量读取,新增站点不用改代码,这是“无限回调”能持续扩展的前提。
4. 无限回调的稳定性设计:state 签名、幂等与会话恢复
4.1 state 不能只当普通参数用
单公众号单域名场景,state 塞个随机串就完事,但在无限回调系统里 state 是控制面:它告诉网关“我是哪个站点、从哪来、要回哪去”,同时还要防伪造。标准的做法是把它做成带 HMAC 签名的小数据包:
<?php function makeState(array $ctx, string $secret): string { ksort($ctx); $payload = base64_encode(json_encode($ctx, JSON_UNESCAPED_UNICODE)); $sig = hash_hmac('sha256', $payload, $secret); return $payload . '.' . $sig; } function parseState(string $state, string $secret): ?array { $parts = explode('.', $state); if (count($parts) !== 2) return null; [$payload, $sig] = $parts; $expect = hash_hmac('sha256', $payload, $secret); if (!hash_equals($expect, $sig)) return null; // 常量时间比较 $ctx = json_decode(base64_decode($payload), true); return is_array($ctx) ? $ctx : null; }签名密钥建议单独生成,与公众号 secret 分开。轮换时只换签名密钥,不影响业务侧换取 token 的逻辑。state 里不要放 openid、手机号这类敏感数据,它会出现在 URL 和访问日志里,经中间设备记录是常见泄露路径。
4.2 用 Redis 存放回调上下文
state 包体积有限,业务字段一多 URL 就很长。更可控的方式是 state 只放一个随机 ID,上下文整体写进 Redis,TTL 与 code 有效期对齐,一般设 600 秒:
SET oauth:ctx:a1b2c3 '{"site":"mall","target":"/user","ts":1699999999}' EX 600回调消费端:
$key = 'oauth:ctx:' . $stateId; $raw = $redis->get($key); if ($raw === false) { exit('state expired, please re-login'); } $ctx = json_decode($raw, true); $redis->del($key); // 一次性消费,防重放要点是取完即删:state 一旦被消费,同一个 ID 再次出现直接判为过期。多站点共用一套 Redis 时,给键加oauth:ctx:前缀,便于统一清理和排查热键。
4.3 code 与 openid 的幂等设计
微信回调在弱网环境会重放,同一 code 第二次换取必然 invalid code,这个微信本身挡得住。真正要防的是同一次登录被重复处理导致会话互相覆盖,解法是给“站点 + openid”维度做短窗口去重:
$dedupKey = sprintf('oauth:dedup:%s:%s', $ctx['site'], $openid); $claimed = $redis->set($dedupKey, time(), ['NX', 'EX' => 30]); if (!$claimed) { // 30 秒内的重复回调,直接恢复已有会话 resumeSession($openid, $ctx['site']); header('Location: ' . $ctx['target']); exit; } // 正常业务继续SET NX EX是原子操作,两个并发请求只有一个能抢到写权限,天然避免竞态。去重窗口不建议拉长,30 秒足够覆盖微信重试和用户手滑刷新,又不会拦掉几分钟后用户真实的二次登录。
4.4 回调报错的排查次序
| 报错现象 | 根因 | 处理动作 |
|---|---|---|
| redirect_uri 参数错误 | 回调域名与后台配置不一致 | 核对公众号后台“网页授权域名”,只填域名,不带协议和路径 |
| invalid code | code 过期或被二次使用 | 查日志确认同一 code 是否出现两次,检查是否有重放 |
| 40029 invalid code | appid 与 secret 不匹配 | 检查路由表里站点标识与公众号密钥映射是否错位 |
| 40164 请求 IP 不在白名单 | openapi 调用源 IP 未加白名单 | 到公众平台把网关出口 IP 加入 IP 白名单 |
| 一直跳回首页 | state 中 target 解析失败 | 先看 parseState 返回值,再核对签名密钥是否一致 |
顺着这个表走,能覆盖绝大多数回调事故。标准排查顺序是:先看日志有没有原始请求,再验 state 签名,再核对 appid/secret 映射,最后查 IP 白名单,不要一上来就怀疑代码逻辑。
5. 公众号回调的上线落地技巧:本地联调、日志与检查
5.1 用 curl 把回调链路在本地打穿
微信授权必须真机加真实公众号才能触发,但入口之后的逻辑完全可以在本地测完。把 code 换成固定 mock 值,state 用程序生成的合法签名:
STATE="eyJzaXRlIjoibWFsbCIsInRhcmdldCI6Ii91c2VyIn0.sig_mock" curl -i "http://127.0.0.1:8080/oauth/gateway?code=mock_code_001&state=${STATE}"这一步能验证 state 解析、签名校验、站点路由三段逻辑。换取 token 的请求会真实打到微信,联调环境没有合法 appid,需要在代码里留一个开关:当 code 以mock_开头时,直接返回固定 openid,不发起外呼。这样一来,CI 环境没有外网权限也能跑完整条回调用例。
5.2 回调日志必须留住的字段
- 时间戳、来源 IP、完整 URL(去掉 code 全文)
- code 前 6 位,用于关联微信侧问题单
- state 解析后的站点 ID、目标路径
- 换取 token 的 HTTP 状态码与微信 errcode/errmsg
- 是否命中 30 秒去重、会话恢复结果
回调日志单独开一个索引或文件,不要和业务日志混在一起。排错时一条grep oauth-cb就能把“用户点授权到网关、换 token、建会话”整条链路串起来,顺手记下$_SERVER['HTTP_REFERER'],有时能直接看出入口是不是被外部页面恶意引用。
5.3 上线前的最后一遍检查
- 公众号后台回调域名与网关入口域名完全一致,没有多余斜杠或路径
- scope 选了 snsapi_base,没有引入多余确认弹窗,“不需要登录”的访问者也能顺畅进入
- state 签名密钥已部署,且与仓库里的占位值不同
- Redis 中
oauth:前缀键都有 TTL,不存在永不失效的会话残留 - Nginx 已将网页授权回调与消息推送回调拆分到不同 location
- 用 5.1 的 curl 脚本在预发环境完整走一遍,确认 302 落点正确、会话可恢复
检查完这几项再上真实授权,绝大多数 redirect_uri 报错和登录后乱跳转的问题,在线上暴露之前就能被拦住。
本文还有配套的精品资源,点击获取