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>
This commit is contained in:
Blizzard
2026-07-21 09:53:27 +08:00
parent 04b5677605
commit a1c68ec6b2
5 changed files with 210 additions and 1 deletions
+39
View File
@@ -105,6 +105,45 @@ func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) {
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) {
@@ -0,0 +1,68 @@
package wechat
import (
"context"
"net/http"
"net/http/httptest"
"testing"
)
// PullToken 正常路径:带对密钥、解析 token + 有效期。
func TestPullToken_OK(t *testing.T) {
var gotAuth string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotAuth = r.Header.Get("Authorization")
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"access_token":"TOKEN_ABC","expires_in":7200}`))
}))
defer srv.Close()
tok, ttl, err := PullToken(context.Background(), srv.URL, "s3cr3t")
if err != nil {
t.Fatal(err)
}
if tok != "TOKEN_ABC" || ttl != 7200 {
t.Fatalf("解析错:tok=%q ttl=%d", tok, ttl)
}
if gotAuth != "Bearer s3cr3t" {
t.Fatalf("密钥头没带对:%q", gotAuth)
}
}
// 中控拒绝(密钥不对 → 403)必须报错,不能把 403 页面当 token。
func TestPullToken_Rejected(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusForbidden)
_, _ = w.Write([]byte("forbidden"))
}))
defer srv.Close()
if _, _, err := PullToken(context.Background(), srv.URL, "wrong"); err == nil {
t.Fatal("403 应报错")
}
}
// 坏 JSON / 空 token 必须报错,不能返回空串当成功。
func TestPullToken_BadBody(t *testing.T) {
for _, body := range []string{`not json`, `{"access_token":""}`, `{}`} {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
}))
if _, _, err := PullToken(context.Background(), srv.URL, ""); err == nil {
t.Fatalf("坏响应 %q 应报错", body)
}
srv.Close()
}
}
// 中控没给 expires_in 时按微信默认 7200 兜底(避免缓存 0 秒后每次都拉)。
func TestPullToken_DefaultExpiry(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"access_token":"X"}`))
}))
defer srv.Close()
_, ttl, err := PullToken(context.Background(), srv.URL, "")
if err != nil || ttl != 7200 {
t.Fatalf("应兜底 7200,得 ttl=%d err=%v", ttl, err)
}
}