feat(auth): 微信扫码登录改为「带参二维码 + 关注/扫码事件」(登录即涨粉)

上一版做成了网页授权(OAuth 允许页),方向错了。改成用户要的流程:
扫码 → 弹公众号关注页 → 关注即登录,服务号顺带涨粉。

流程:PC 建票 → 后端用 access_token 调「带参数二维码」接口(scene=ticket) →
展示微信二维码图 → 用户扫码关注 → 微信推 subscribe/SCAN 事件到 /wx/mp/callback →
按 openid 找/建用户 → ticket 置 authorized → PC 轮询拿 JWT。

明文模式(消息加解密):回调只验签名 sha1(sort(token,ts,nonce)),不做 AES。

关键实现点:
- access_token 缓存进 Redis(跨实例共享,避免重复拉取互相失效)+ 进程内锁双检;
- 事件同时处理 subscribe(未关注,EventKey 带 qrscene_ 前缀)与 SCAN(已关注,不带);
- 事件回调必须验签——否则任何人 POST 一个 openid 就能登录别人;
- 回调无论如何回 "success",否则微信重试并给用户弹"公众号故障";
- User.wechat_openid 用部分唯一索引(WHERE <> ''),避开存量空串互撞。

配置(appid/secret/token)后台可改、secret AES 加密入库。管理端「运维 → 登录设置」
列出还需在公众平台做的事(服务器 URL / Token 一致 / 明文模式 / IP 白名单)。

本地验证(真流程,非 mock):验签回 echostr 与微信算法一致;模拟 subscribe 事件
→ 建号 + 置票 → PC 轮询拿到 token+user → 库里确有该 openid 用户。真微信推真事件
留待部署后扫码。前端 web 登录页加「微信扫码/邮箱」双 tab,默认微信。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-21 08:58:31 +08:00
parent 07955ddf07
commit 883540bd7e
11 changed files with 607 additions and 188 deletions
@@ -5,9 +5,11 @@ import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"io"
"log"
"net/http"
"strings"
"sync"
"time"
"github.com/gin-gonic/gin"
@@ -15,20 +17,22 @@ import (
"github.com/sundynix/sundynix-gateway/internal/wechat"
)
// 微信公众号(服务号)扫码登录。设计见 internal/wechat/mp.go。
// 微信公众号「带参数二维码 + 关注/扫码事件」登录。设计见 internal/wechat/mp.go。
//
// 端点:
// POST /api/v1/wx/mp/ticket 建 ticket,返回二维码里要放的 URL
// GET /wx/mp?t=<ticket> 用户扫码后微信打开这个,302 到微信授权页
// GET /api/v1/wx/mp/callback 微信授权后回调,换 openid、找/建用户、置 ticket 已授权
// GET /api/v1/wx/mp/poll?t= PC 轮询,已授权则签发 JWT
// 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 有效期
)
// wxTicketState 是 ticket 在 Redis 里的值
// access_token 拉取的进程内串行化:单实例下杜绝并发拉取互相失效(多实例靠 Redis 缓存兜大头)
var wxTokenMu sync.Mutex
type wxTicketState struct {
Status string `json:"status"` // pending / authorized / consumed
UserID string `json:"user_id"`
@@ -46,120 +50,116 @@ func (h *Handler) loadWechatMP(ctx context.Context) 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
}
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 —— 建一个待授权 ticket,返回二维码内容 URL。
// WxMPTicket: POST /api/v1/wx/mp/ticket
func (h *Handler) WxMPTicket(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.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(c.Request.Context(), ticket, string(st), wxTicketTTL); err != nil {
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "创建登录票据失败"})
return
}
// 二维码里放的是本服务的中转地址,不是微信授权地址本身 —— 授权地址带 secret 相关参数,
// 且要在用户扫码那一刻才拼(redirect_uri 要精确匹配),所以扫码后再由 /wx/mp 现拼现跳。
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://" + c.Request.Host
}
c.JSON(http.StatusOK, gin.H{
"ticket": ticket,
"qr_url": base + "/wx/mp?t=" + ticket,
"expires_in": int(wxTicketTTL.Seconds()),
})
c.JSON(http.StatusOK, gin.H{"ticket": ticket, "qr_image": qrURL, "expires_in": int(wxTicketTTL.Seconds())})
}
// WxMPEntry: GET /wx/mp?t=<ticket> —— 用户微信扫码后打开,302 到微信 OAuth 授权页
// 这是公开路由(微信内置浏览器访问,无鉴权头)。
func (h *Handler) WxMPEntry(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
c.String(http.StatusBadRequest, "缺少登录票据")
return
}
// WxMPVerify: GET /wx/mp/callback —— 服务器配置 URL 验证
func (h *Handler) WxMPVerify(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
if !cfg.Enabled() {
c.String(http.StatusServiceUnavailable, "微信登录未配置")
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
// ticket 必须存在且仍 pending,否则可能是过期或伪造
if h.cache.WxTicketGet(c.Request.Context(), ticket) == "" {
c.String(http.StatusBadRequest, "登录二维码已过期,请刷新重试")
return
}
base := strings.TrimRight(cfg.BaseURL, "/")
if base == "" {
base = "https://" + c.Request.Host
}
redirectURI := base + "/api/v1/wx/mp/callback"
c.Redirect(http.StatusFound, cfg.AuthorizeURL(redirectURI, ticket))
c.String(http.StatusOK, c.Query("echostr"))
}
// WxMPCallback: GET /api/v1/wx/mp/callback?code&state —— 微信授权后回调(公开路由)
// 用 code 换 openid → 找/建用户 → ticket 置 authorized
func (h *Handler) WxMPCallback(c *gin.Context) {
code, ticket := c.Query("code"), c.Query("state")
if code == "" || ticket == "" {
c.String(http.StatusBadRequest, "授权参数缺失")
return
}
// WxMPEvent: POST /wx/mp/callback —— 事件推送
// 无论如何回 "success"(微信要求),否则会重试并给用户端弹"公众号服务故障"
func (h *Handler) WxMPEvent(c *gin.Context) {
ctx := c.Request.Context()
// ticket 必须仍有效(防止拿别人的回调套自己的 ticket)
if h.cache.WxTicketGet(ctx, ticket) == "" {
c.String(http.StatusBadRequest, "登录已过期,请在电脑上重新扫码")
return
}
cfg := h.loadWechatMP(ctx)
if !cfg.Enabled() {
c.String(http.StatusServiceUnavailable, "微信登录未配置")
// 验签:拒绝伪造事件(否则任何人 POST 一个 openid 就能登录别人)
if cfg.Token == "" || !cfg.CheckSignature(c.Query("signature"), c.Query("timestamp"), c.Query("nonce")) {
c.String(http.StatusForbidden, "signature check failed")
return
}
info, err := cfg.ExchangeCode(ctx, code)
if err != nil {
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err)
c.String(http.StatusBadGateway, "微信授权失败,请重试")
body, _ := io.ReadAll(c.Request.Body)
ev, err := wechat.ParseEvent(body)
if err != nil || !ev.IsLoginScan() {
c.String(http.StatusOK, "success") // 非登录扫码事件忽略,照常回执
return
}
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套)
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID)
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.StatusBadGateway, "登录失败,请重试")
c.String(http.StatusOK, "success")
return
}
if u == nil {
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户")
u, err = h.db.CreateWechatUser(ctx, openID, "微信用户")
if err != nil {
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err)
c.String(http.StatusBadGateway, "登录失败,请重试")
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})
if err := h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL); err != nil {
c.String(http.StatusBadGateway, "登录失败,请重试")
return
}
// 微信内置浏览器里显示一句提示即可,登录动作在 PC 端完成
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, `<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><body style="font-family:sans-serif;text-align:center;padding-top:30vh;color:#333"><h2>✅ 登录成功</h2><p style="color:#888">请回到电脑继续</p></body>`)
_ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL)
c.String(http.StatusOK, "success")
}
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket> —— PC 端轮询登录状态(公开路由)。
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket>
func (h *Handler) WxMPPoll(c *gin.Context) {
ticket := c.Query("t")
if ticket == "" {
@@ -178,10 +178,10 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
return
}
if st.Status != "authorized" {
c.JSON(http.StatusOK, gin.H{"status": st.Status}) // pending
c.JSON(http.StatusOK, gin.H{"status": st.Status})
return
}
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌
// 一次性消费:先置 consumed 再签发,避免同一 ticket 被轮询两次拿两个令牌
consumed, _ := json.Marshal(wxTicketState{Status: "consumed", UserID: st.UserID})
_ = h.cache.WxTicketSet(ctx, ticket, string(consumed), time.Minute)
@@ -195,24 +195,21 @@ func (h *Handler) WxMPPoll(c *gin.Context) {
// ---- 管理端配置 ----
// AdminGetWechatMP: GET /api/v1/admin/wechat-mp —— 当前配置(secret 不回显,只报有无)。
func (h *Handler) AdminGetWechatMP(c *gin.Context) {
cfg := h.loadWechatMP(c.Request.Context())
c.JSON(http.StatusOK, gin.H{
"appid": cfg.AppID,
"base_url": cfg.BaseURL,
"token": cfg.Token,
"has_app_secret": cfg.AppSecret != "",
"enabled": cfg.Enabled(),
})
}
// AdminSaveWechatMP: PUT /api/v1/admin/wechat-mp —— 保存配置。
// app_secret 留空 = 沿用已存(只写不回显,同微信支付 APIv3 密钥)。
func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
var b struct {
AppID string `json:"appid"`
AppSecret string `json:"app_secret"`
BaseURL string `json:"base_url"`
Token string `json:"token"`
}
if err := c.ShouldBindJSON(&b); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
@@ -221,13 +218,9 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
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,
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
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()})
@@ -238,5 +231,6 @@ func (h *Handler) AdminSaveWechatMP(c *gin.Context) {
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()})
}