883540bd7e
上一版做成了网页授权(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>
237 lines
7.3 KiB
Go
237 lines
7.3 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"crypto/rand"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"io"
|
||
"log"
|
||
"net/http"
|
||
"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
|
||
}
|
||
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()})
|
||
}
|