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:
@@ -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()})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user