feat(auth): 微信扫码登录后端 —— 网页授权 + ticket 轮询
服务号「植趣 ZeeQ」已认证,走网页授权(snsapi_base,只拿 openid、用户无感), 不接管消息推送,副作用最小。 流程:PC 建 ticket → 二维码指向 /wx/mp?t= → 用户微信扫码 → 302 到微信授权页 → 回调 /api/v1/wx/mp/callback 用 code 换 openid → 找/建用户 → ticket 置 authorized → PC 轮询 /wx/mp/poll 拿到 authorized → 签发 JWT。ticket 一次性消费防重放。 - 配置(appid/secret/base_url)后台可改,secret AES 加密入库,与微信支付同一套 secrets; - ticket 存 Redis(短 TTL),无 Redis 时回退进程内内存(本地单实例可用,生产必须有 Redis); - User 加 wechat_openid。**部分唯一索引**(WHERE openid <> '')而非普通唯一: 存量邮箱用户该列是空串,普通唯一索引会让多个空串互撞、AutoMigrate 直接失败 —— 与之前 NULL 余额同类的坑,这次提前避开。 单测覆盖:授权 URL 拼接(含 #wechat_redirect 锚点必须在末尾)、secret 加密往返、 建号/查号、空 openid 不误命中存量用户。微信 API 调用依赖公网回调,本地测不了, 留待部署后真机扫码。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/sundynix/sundynix-gateway/internal/wechat"
|
||||
)
|
||||
|
||||
// 微信公众号(服务号)扫码登录。设计见 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
|
||||
|
||||
const (
|
||||
SettingWechatMP = "wechat_mp" // 公众号登录配置(setting 表)
|
||||
wxTicketTTL = 5 * time.Minute // 二维码/ticket 有效期
|
||||
)
|
||||
|
||||
// wxTicketState 是 ticket 在 Redis 里的值。
|
||||
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()
|
||||
}
|
||||
|
||||
func newTicket() string {
|
||||
b := make([]byte, 16)
|
||||
_, _ = rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// WxMPTicket: POST /api/v1/wx/mp/ticket —— 建一个待授权 ticket,返回二维码内容 URL。
|
||||
func (h *Handler) WxMPTicket(c *gin.Context) {
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
if !cfg.Enabled() {
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "微信登录未配置"})
|
||||
return
|
||||
}
|
||||
ticket := newTicket()
|
||||
st, _ := json.Marshal(wxTicketState{Status: "pending"})
|
||||
if err := h.cache.WxTicketSet(c.Request.Context(), 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()),
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
cfg := h.loadWechatMP(c.Request.Context())
|
||||
if !cfg.Enabled() {
|
||||
c.String(http.StatusServiceUnavailable, "微信登录未配置")
|
||||
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))
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
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, "微信登录未配置")
|
||||
return
|
||||
}
|
||||
info, err := cfg.ExchangeCode(ctx, code)
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 换 openid 失败 ticket=%s: %v", ticket, err)
|
||||
c.String(http.StatusBadGateway, "微信授权失败,请重试")
|
||||
return
|
||||
}
|
||||
|
||||
// 找用户;没有则建号 + 默认租户(复用邮箱注册那套)
|
||||
u, err := h.db.GetUserByWechatOpenID(ctx, info.OpenID)
|
||||
if err != nil {
|
||||
c.String(http.StatusBadGateway, "登录失败,请重试")
|
||||
return
|
||||
}
|
||||
if u == nil {
|
||||
u, err = h.db.CreateWechatUser(ctx, info.OpenID, "微信用户")
|
||||
if err != nil {
|
||||
log.Printf("[wxlogin] 建微信用户失败 openid=%s: %v", info.OpenID, err)
|
||||
c.String(http.StatusBadGateway, "登录失败,请重试")
|
||||
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>`)
|
||||
}
|
||||
|
||||
// WxMPPoll: GET /api/v1/wx/mp/poll?t=<ticket> —— PC 端轮询登录状态(公开路由)。
|
||||
// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。
|
||||
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}) // pending
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 管理端配置 ----
|
||||
|
||||
// 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,
|
||||
"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"`
|
||||
}
|
||||
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,
|
||||
BaseURL: strings.TrimRight(strings.TrimSpace(b.BaseURL), "/"),
|
||||
}
|
||||
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
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "enabled": cfg.Enabled()})
|
||||
}
|
||||
Reference in New Issue
Block a user