Files
sundynix-agentix/sundynix-gateway/internal/wechat/mp.go
T
Blizzard a1c68ec6b2 feat(auth): 微信 access_token 走中控服务器(腾讯云静态 IP 换 token)
隐患:gateway 换 access_token 时微信看到的是本地宽带出网 IP(106.58.232.43,
动态会变),一变白名单就失效、二维码建不出来(40164)。

按微信官方推荐的「中控服务器」架构解决:腾讯云静态 IP 统一换 token,gateway 拉取
使用。微信 IP 白名单只限制换 token 这一步,拿 token 建二维码不查 IP —— 所以
换 token 挪到中控、建二维码仍在 gateway 本地,白名单只填腾讯云 IP,永不失效。

- wechat.PullToken:从中控 HTTPS 端点拉 token(Bearer 密钥鉴权),坏响应/403/空
  token 一律报错不当成功;中控没给 expires_in 时按 7200 兜底。
- handler.accessToken:配了 WECHAT_TOKEN_URL 就只从中控拉、绝不自己 FetchAccessToken
  (微信要求单点刷新,多点各自换会互相顶掉 token);不配维持直连,零副作用可回退。
- compose:gateway 加 WECHAT_TOKEN_URL / WECHAT_TOKEN_SECRET(从宿主机 .env 注入)。
- deploy/wechat-token-zhongkong.md:腾讯云侧 cron 脚本 + nginx 配置 + 切换验证步骤。

本地验证(假中控 httptest + 真 gateway):建票时 Redis 缓存的是中控给的 token
(FAKE_TOKEN_FROM_ZHONGKONG),gateway 未直连微信换 token;随后拿该 token 调
qrcode/create(假 token 报 40001 属预期)——证明「中控换 token → 本地建二维码」链路成立。
4 组 PullToken 单测覆盖正常/403/坏JSON/兜底。

真机验证(部署后):把本地 IP 从白名单删掉、只留腾讯云 IP,扫码仍能登录即坐实
qrcode 不受 IP 限制;若建二维码报 40164 则退回 tinyproxy 正向代理备选。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 09:53:27 +08:00

234 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
//
// 登录即引导关注公众号(涨粉),流程:
// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
// 2. PC 显示这张微信二维码
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
// 两种都带 openid(FromUserName)
// 5. 后端按 openid 找/建用户 → ticket 置 authorized
// 6. PC 轮询 → 登录完成
//
// 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。
package wechat
import (
"context"
"crypto/sha1"
"encoding/hex"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/sundynix/sundynix-shared/secrets"
)
// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。
type Config struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
// Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。
Token string `json:"token"`
}
// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。
func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" }
// EncryptedForStore 返回 AppSecret 已加密的副本,用于落库。
func (c Config) EncryptedForStore() (Config, error) {
if c.AppSecret == "" {
return c, nil
}
enc, err := secrets.Encrypt(c.AppSecret)
if err != nil {
return c, err
}
c.AppSecret = enc
return c, nil
}
// DecryptFromStore 把库内密文 AppSecret 还原为明文。
func (c Config) DecryptFromStore() Config {
if c.AppSecret != "" {
if plain, err := secrets.Decrypt(c.AppSecret); err == nil {
c.AppSecret = plain
}
}
return c
}
// CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))。
// 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它。
func (c Config) CheckSignature(signature, timestamp, nonce string) bool {
if c.Token == "" {
return false
}
arr := []string{c.Token, timestamp, nonce}
sort.Strings(arr)
h := sha1.Sum([]byte(strings.Join(arr, "")))
return hex.EncodeToString(h[:]) == signature
}
// FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。
// 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。
func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
q := url.Values{}
q.Set("grant_type", "client_credential")
q.Set("appid", c.AppID)
q.Set("secret", c.AppSecret)
endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode()
body, err := httpGet(ctx, endpoint)
if err != nil {
return "", 0, err
}
var r struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.AccessToken == "" {
return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
return r.AccessToken, r.ExpiresIn, nil
}
// PullToken 从「中控服务器」拉取 access_token(微信官方推荐架构:单点取 token、多点用)。
// 中控(腾讯云静态 IP)负责换 token 并 serve,这样微信 IP 白名单只需填中控 IP;
// gateway 出网 IP(本地动态宽带)不再参与换 token,避免 IP 一变就 40164。
// tokenURL serve 的 JSON 形如 {"access_token":"...","expires_in":7200}secret 走 Bearer 头鉴权。
func PullToken(ctx context.Context, tokenURL, secret string) (string, int, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(rctx, http.MethodGet, tokenURL, nil)
if err != nil {
return "", 0, err
}
if secret != "" {
req.Header.Set("Authorization", "Bearer "+secret)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", 0, fmt.Errorf("请求中控 token 失败: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", 0, fmt.Errorf("中控 token 返回 %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var r struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", 0, fmt.Errorf("解析中控 token 失败: %s", strings.TrimSpace(string(body)))
}
if r.AccessToken == "" {
return "", 0, fmt.Errorf("中控返回空 token: %s", strings.TrimSpace(string(body)))
}
if r.ExpiresIn <= 0 {
r.ExpiresIn = 7200 // 中控没给有效期时按微信默认兜底
}
return r.AccessToken, r.ExpiresIn, nil
}
// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。
// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 <img> 展示的二维码图 URL。
func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) {
reqBody := map[string]any{
"expire_seconds": expireSec,
"action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket
"action_info": map[string]any{"scene": map[string]any{"scene_str": scene}},
}
raw, _ := json.Marshal(reqBody)
endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken)
body, err := httpPost(ctx, endpoint, raw)
if err != nil {
return "", err
}
var r struct {
Ticket string `json:"ticket"`
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err := json.Unmarshal(body, &r); err != nil {
return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body)))
}
if r.ErrCode != 0 || r.Ticket == "" {
return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg)
}
// showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码
return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil
}
// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。
type Event struct {
XMLName xml.Name `xml:"xml"`
MsgType string `xml:"MsgType"` // event
Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ...
EventKey string `xml:"EventKey"` // subscribe: qrscene_<scene>SCAN: <scene>
FromUserName string `xml:"FromUserName"` // 用户 openid
}
// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。
func (e Event) Scene() string {
return strings.TrimPrefix(e.EventKey, "qrscene_")
}
// IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。
func (e Event) IsLoginScan() bool {
if e.MsgType != "event" {
return false
}
return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != ""
}
// ParseEvent 解析明文事件 XML。
func ParseEvent(body []byte) (*Event, error) {
var e Event
if err := xml.Unmarshal(body, &e); err != nil {
return nil, err
}
return &e, nil
}
// ---- HTTP 小工具 ----
func httpGet(ctx context.Context, endpoint string) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) {
rctx, cancel := context.WithTimeout(ctx, 8*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body)))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("请求微信失败: %w", err)
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}