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:
@@ -33,6 +33,11 @@ services:
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT: http://192.168.100.128:4318
|
||||
# 微信域名校验文件目录(容器内路径);未设则该路由不注册
|
||||
WECHAT_VERIFY_DIR: /etc/sundynix/wechat-verify
|
||||
# 微信 access_token 中控(腾讯云静态 IP 换 token,gateway 只拉不换)。
|
||||
# 配了才从中控拉,避免本地动态出网 IP 触发 40164;不配则 gateway 直连微信换 token。
|
||||
# 值放宿主机 .env:WECHAT_TOKEN_URL=https://.../token.json、WECHAT_TOKEN_SECRET=<共享密钥>
|
||||
WECHAT_TOKEN_URL: ${WECHAT_TOKEN_URL:-}
|
||||
WECHAT_TOKEN_SECRET: ${WECHAT_TOKEN_SECRET:-}
|
||||
ports: ["3000:8080"] # frp 外网 → 132:3000 → 容器 8080
|
||||
volumes:
|
||||
# 微信支付证书(商户私钥 + 微信支付公钥):宿主机 132 的目录只读挂进容器。
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# 微信 access_token 中控(腾讯云静态 IP)
|
||||
|
||||
## 为什么
|
||||
|
||||
gateway 换 access_token 时微信看到的是**本地宽带出网 IP(动态,会变)**,一变白名单
|
||||
就失效、二维码建不出来(40164)。微信官方推荐「中控服务器」:一台**固定 IP** 的机器
|
||||
统一换 token,业务机拉取使用。腾讯云那台(`162.14.122.200`,静态)正好当中控。
|
||||
|
||||
微信 IP 白名单**只限制换 token 这一步**(用 appsecret),拿 token 建二维码不查 IP。
|
||||
所以:腾讯云换 token → gateway 拉 token → gateway 本地建二维码。白名单只填腾讯云 IP。
|
||||
|
||||
## 腾讯云上做(三步)
|
||||
|
||||
### 1. 换 token 脚本 `/home/workspace/wxtoken/refresh.sh`
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# 用 appid/secret 换 access_token,写成 token.json。从腾讯云静态 IP 发出。
|
||||
APPID="wxc236cddde8e7f863"
|
||||
SECRET="你的公众号 AppSecret" # 建议重置后填新的
|
||||
OUT="/home/workspace/wxtoken/token.json"
|
||||
|
||||
resp=$(curl -s "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${APPID}&secret=${SECRET}")
|
||||
# 只在拿到 access_token 时才覆盖,避免偶发失败把好文件冲掉
|
||||
echo "$resp" | grep -q '"access_token"' && echo "$resp" > "$OUT.tmp" && mv "$OUT.tmp" "$OUT"
|
||||
```
|
||||
|
||||
```bash
|
||||
chmod +x /home/workspace/wxtoken/refresh.sh
|
||||
/home/workspace/wxtoken/refresh.sh # 先手动跑一次,确认 token.json 生成
|
||||
```
|
||||
|
||||
### 2. cron 每 ~1.5 小时刷新(token 有效 2h,留足余量)
|
||||
|
||||
```bash
|
||||
crontab -e
|
||||
# 加一行:
|
||||
*/90 * * * * /home/workspace/wxtoken/refresh.sh
|
||||
```
|
||||
|
||||
### 3. nginx 把 token.json 以 HTTPS + 密钥吐出
|
||||
|
||||
在腾讯云那台的站点配置里加(token 是 2h 高权限凭证,必须 HTTPS + 密钥,不能裸奔):
|
||||
|
||||
```nginx
|
||||
location = /wxtoken/token.json {
|
||||
# 共享密钥校验:gateway 拉取时带 Authorization: Bearer <同一串>
|
||||
if ($http_authorization != "Bearer 换成你自定的强随机串") { return 403; }
|
||||
default_type application/json;
|
||||
alias /home/workspace/wxtoken/token.json;
|
||||
add_header Cache-Control "no-store";
|
||||
}
|
||||
```
|
||||
|
||||
reload nginx 后自测(换成真域名):
|
||||
```bash
|
||||
curl -s -H "Authorization: Bearer 换成你自定的强随机串" https://<腾讯云域名>/wxtoken/token.json
|
||||
# 应返回 {"access_token":"...","expires_in":7200}
|
||||
curl -s https://<腾讯云域名>/wxtoken/token.json # 不带密钥应 403
|
||||
```
|
||||
|
||||
## gateway 侧(在 132 的 .env 里配,随部署生效)
|
||||
|
||||
```
|
||||
WECHAT_TOKEN_URL=https://<腾讯云域名>/wxtoken/token.json
|
||||
WECHAT_TOKEN_SECRET=换成你自定的强随机串 # 与 nginx 那串一字不差
|
||||
```
|
||||
|
||||
配了 gateway 就**只从中控拉、不自己换 token**;不配则维持直连(受本地 IP 白名单约束)。
|
||||
|
||||
## 切换 + 验证(关键,坐实 qrcode 不受 IP 限制)
|
||||
|
||||
1. 先让**本地 IP 和腾讯云 IP 都在白名单**,配好上面 env、部署,扫码确认能登录(此时不确定
|
||||
token 是中控给的还是本地换的都能过)。
|
||||
2. 去公众平台把**本地 IP 从白名单删掉,只留腾讯云 `162.14.122.200`**,再扫码:
|
||||
- **能登录** → 换 token 走了中控、建二维码本地也没被拦,方案坐实,IP 隐患彻底消除。
|
||||
- **建二维码报 40164** → qrcode 也受 IP 限制,改用备选:腾讯云装 tinyproxy 正向代理,
|
||||
gateway 加 `WECHAT_HTTP_PROXY` env(代码侧已预留思路,需再改一处 http 客户端)。
|
||||
|
||||
## 排查
|
||||
|
||||
- gateway 日志 `[wxlogin] 建二维码失败: ... 40164` → 中控没生效或没配 env,仍在本地换 token
|
||||
- 中控 token.json 过期/为空 → 看 cron 有没有在跑、`refresh.sh` 手动能否生成
|
||||
- gateway 拉取 403 → `WECHAT_TOKEN_SECRET` 与 nginx 里的密钥不一致
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -60,7 +61,19 @@ func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, e
|
||||
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
|
||||
return t, nil
|
||||
}
|
||||
token, ttl, err := cfg.FetchAccessToken(ctx)
|
||||
// 中控模式:配了 WECHAT_TOKEN_URL 就只从中控拉(腾讯云静态 IP 换 token),
|
||||
// 绝不自己调 FetchAccessToken——微信要求单点刷新,多点各自换会互相顶掉 token。
|
||||
// 没配则维持直连(本地出网换 token,受动态 IP 白名单约束)。
|
||||
var (
|
||||
token string
|
||||
ttl int
|
||||
err error
|
||||
)
|
||||
if tokenURL := os.Getenv("WECHAT_TOKEN_URL"); tokenURL != "" {
|
||||
token, ttl, err = wechat.PullToken(ctx, tokenURL, os.Getenv("WECHAT_TOKEN_SECRET"))
|
||||
} else {
|
||||
token, ttl, err = cfg.FetchAccessToken(ctx)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user