a1c68ec6b2
隐患: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>
250 lines
7.8 KiB
Go
250 lines
7.8 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"os"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
||
)
|
||
|
||
// 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
|
||
//
|
||
// 端点:
|
||
// POST /api/v1/wx/mp/ticket 建 login ticket + 微信二维码,返回二维码图 URL
|
||
// GET /wx/mp/callback 服务器配置 URL 验证(echostr)
|
||
// POST /wx/mp/callback 事件推送:关注/扫码 → 置 ticket 已授权
|
||
// GET /api/v1/wx/mp/poll?t= PC 轮询登录态,已授权则签发 JWT
|
||
|
||
const (
|
||
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
|
||
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
|
||
)
|
||
|
||
// access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)。
|
||
var wxTokenMu sync.Mutex
|
||
|
||
type wxTicketState struct {
|
||
Status string `json:"status"` // pending / authorized / consumed
|
||
UserID string `json:"user_id"`
|
||
}
|
||
|
||
func (h *Handler) loadWechatMP(ctx context.Context) wechat.Config {
|
||
raw := h.db.GetSetting(ctx, SettingWechatMP)
|
||
if raw == "" {
|
||
return wechat.Config{}
|
||
}
|
||
var c wechat.Config
|
||
if json.Unmarshal([]byte(raw), &c) != nil {
|
||
return wechat.Config{}
|
||
}
|
||
return c.DecryptFromStore()
|
||
}
|
||
|
||
// accessToken 取(缓存优先)微信 access_token。
|
||
func (h *Handler) accessToken(ctx context.Context, cfg wechat.Config) (string, error) {
|
||
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" {
|
||
return t, nil
|
||
}
|
||
wxTokenMu.Lock()
|
||
defer wxTokenMu.Unlock()
|
||
if t := h.cache.WxTokenGet(ctx, cfg.AppID); t != "" { // 双检
|
||
return t, nil
|
||
}
|
||
// 中控模式:配了 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
|
||
}
|
||
if ttl > 200 {
|
||
ttl -= 200 // 安全边界,避免临界过期
|
||
}
|
||
h.cache.WxTokenSet(ctx, cfg.AppID, token, time.Duration(ttl)*time.Second)
|
||
return token, nil
|
||
}
|
||
|
||
func newTicket() string {
|
||
b := make([]byte, 16)
|
||
_, _ = rand.Read(b)
|
||
return hex.EncodeToString(b)
|
||
}
|
||
|
||
// WxMPTicket: POST /api/v1/wx/mp/ticket
|
||
func (h *Handler) WxMPTicket(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
cfg := h.loadWechatMP(ctx)
|
||
if !cfg.Enabled() {
|
||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
|
||
return
|
||
}
|
||
token, err := h.accessToken(ctx, cfg)
|
||
if err != nil {
|
||
log.Printf("[wxlogin] 取 access_token 失败: %v", err)
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "微信登录暂不可用"})
|
||
return
|
||
}
|
||
ticket := newTicket()
|
||
qrURL, err := cfg.CreateLoginQR(ctx, token, ticket, int(wxTicketTTL.Seconds()))
|
||
if err != nil {
|
||
log.Printf("[wxlogin] 建二维码失败: %v", err)
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "生成二维码失败"})
|
||
return
|
||
}
|
||
st, _ := json.Marshal(wxTicketState{Status: "pending"})
|
||
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
|
||
}
|
||
|
||
// WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证。
|
||
func (h *Handler) WxMPVerify(c *gin.Context) {
|
||
cfg := h.loadWechatMP(c.Request.Context())
|
||
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
|
||
c.String(http.StatusForbidden, "signature check failed")
|
||
return
|
||
}
|
||
c.String(http.StatusOK, c.Query("echostr"))
|
||
}
|
||
|
||
// WxMPEvent: POST /wx/mp/callback —— 事件推送。
|
||
// 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"。
|
||
func (h *Handler) WxMPEvent(c *gin.Context) {
|
||
ctx := c.Request.Context()
|
||
cfg := h.loadWechatMP(ctx)
|
||
// 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
|
||
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
|
||
c.String(http.StatusForbidden, "signature check failed")
|
||
return
|
||
}
|
||
body, _ := io.ReadAll(c.Request.Body)
|
||
ev, err := wechat.ParseEvent(body)
|
||
if err != nil || !ev.IsLoginScan() {
|
||
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
|
||
return
|
||
}
|
||
|
||
ticket, openID := ev.Scene(), ev.FromUserName
|
||
if h.cache.WxTicketGet(ctx, ticket) == "" { // ticket 必须仍有效
|
||
c.String(http.StatusOK, "success")
|
||
return
|
||
}
|
||
u, err := h.db.GetUserByWechatOpenID(ctx, openID)
|
||
if err != nil {
|
||
c.String(http.StatusOK, "success")
|
||
return
|
||
}
|
||
if u == nil {
|
||
u, err = h.db.CreateWechatUser(ctx, openID, "微信用户")
|
||
if err != nil {
|
||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", openID, err)
|
||
c.String(http.StatusOK, "success")
|
||
return
|
||
}
|
||
if _, e := h.db.EnsureDefaultTenant(ctx, u.ID, "我的空间"); e != nil {
|
||
log.Printf("[wxlogin] 建默认租户失败 uid=%s: %v", u.ID, e)
|
||
}
|
||
}
|
||
st, _ := json.Marshal(wxTicketState{Status: "authorized", UserID: u.ID})
|
||
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
|
||
c.String(http.StatusOK, "success")
|
||
}
|
||
|
||
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
|
||
func (h *Handler) WxMPPoll(c *gin.Context) {
|
||
ticket := c.Query("t")
|
||
if ticket == "" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少票据"})
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
raw := h.cache.WxTicketGet(ctx, ticket)
|
||
if raw == "" {
|
||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||
return
|
||
}
|
||
var st wxTicketState
|
||
if json.Unmarshal([]byte(raw), &st) != nil {
|
||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||
return
|
||
}
|
||
if st.Status != "authorized" {
|
||
c.JSON(http.StatusOK, gin.H{"status": st.Status})
|
||
return
|
||
}
|
||
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌
|
||
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
|
||
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
|
||
|
||
u, err := h.db.GetUserByID(ctx, st.UserID)
|
||
if err != nil || u == nil {
|
||
c.JSON(http.StatusOK, gin.H{"status": "expired"})
|
||
return
|
||
}
|
||
issueToken(c, u)
|
||
}
|
||
|
||
// ---- 管理端配置 ----
|
||
|
||
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
|
||
cfg := h.loadWechatMP(c.Request.Context())
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"appid": cfg.AppID,
|
||
"token": cfg.Token,
|
||
"has_app_secret": cfg.AppSecret != "",
|
||
"enabled": cfg.Enabled(),
|
||
})
|
||
}
|
||
|
||
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
|
||
var b struct {
|
||
AppID string `json:"appid"`
|
||
AppSecret string `json:"app_secret"`
|
||
Token string `json:"token"`
|
||
}
|
||
if err := c.ShouldBindJSON(&b); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||
return
|
||
}
|
||
ctx := c.Request.Context()
|
||
secret := strings.TrimSpace(b.AppSecret)
|
||
if secret == "" {
|
||
secret = h.loadWechatMP(ctx).AppSecret
|
||
}
|
||
cfg := wechat.Config{AppID: strings.TrimSpace(b.AppID), AppSecret: secret, Token: strings.TrimSpace(b.Token)}
|
||
stored, err := cfg.EncryptedForStore()
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "密钥加密失败: " + err.Error()})
|
||
return
|
||
}
|
||
raw, _ := json.Marshal(stored)
|
||
if err := h.db.SetSetting(ctx, SettingWechatMP, string(raw)); err != nil {
|
||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
h.cache.WxTokenSet(ctx, cfg.AppID, "", time.Millisecond) // 换密钥→旧 token 作废
|
||
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
|
||
}
|