diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index c7466d9..9122b0c 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -227,6 +227,29 @@ export async function listRedeemCodes(): Promise { } // ---- 微信支付配置(DB 存储、热生效;APIv3 密钥密文入库、不回显)---- +// ---- 微信扫码登录配置 ---- +export interface WechatMPConfig { + appid: string; + token: string; // 消息推送签名校验用(与公众平台服务器配置一致) + has_app_secret: boolean; + enabled: boolean; +} + +export async function getWechatMP(): Promise { + const res = guard(await fetch(`${ADMIN}/wechat-mp`, { headers: authHeaders() })); + const d = (await res.json().catch(() => ({}))) as Partial & { error?: string }; + if (!res.ok) throw new Error(d.error ?? `wechat-mp failed: ${res.status}`); + return { appid: d.appid ?? "", token: d.token ?? "", has_app_secret: !!d.has_app_secret, enabled: !!d.enabled }; +} + +// app_secret 传空串 = 沿用已保存的。 +export async function saveWechatMP(body: { appid: string; app_secret: string; token: string }): Promise<{ enabled: boolean }> { + const res = guard(await fetch(`${ADMIN}/wechat-mp`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(body) })); + const d = (await res.json().catch(() => ({}))) as { enabled?: boolean; error?: string }; + if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`); + return { enabled: !!d.enabled }; +} + export interface WechatPayConfig { mchid: string; cert_serial: string; diff --git a/sundynix-admin/src/pages/LoginConfigPage.tsx b/sundynix-admin/src/pages/LoginConfigPage.tsx new file mode 100644 index 0000000..fc0671c --- /dev/null +++ b/sundynix-admin/src/pages/LoginConfigPage.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { getWechatMP, saveWechatMP, GATEWAY, type WechatMPConfig } from "../api"; + +// 运维 · 登录设置:微信公众号扫码登录(带参二维码 + 关注/扫码事件)。 +// AppSecret 只写不回显(密文入库,与微信支付 APIv3 密钥同一套加密)。 +export function LoginConfigPage() { + const [cfg, setCfg] = useState(null); + const [appid, setAppid] = useState(""); + const [token, setToken] = useState(""); + const [secret, setSecret] = useState(""); // 留空=沿用已存 + const [busy, setBusy] = useState(false); + const [err, setErr] = useState(""); + const [ok, setOk] = useState(false); + + useEffect(() => { + getWechatMP() + .then((c) => { + setCfg(c); + setAppid(c.appid); + setToken(c.token); + }) + .catch((e) => setErr((e as Error).message)); + }, []); + + const save = async () => { + if (busy) return; + setBusy(true); + setErr(""); + setOk(false); + try { + const r = await saveWechatMP({ appid: appid.trim(), app_secret: secret, token: token.trim() }); + setSecret(""); + setCfg((c) => (c ? { ...c, appid: appid.trim(), token: token.trim(), has_app_secret: c.has_app_secret || !!secret, enabled: r.enabled } : c)); + setOk(true); + } catch (e) { + setErr((e as Error).message); + } finally { + setBusy(false); + } + }; + + // 回调 URL 用当前后端地址推断(生产 GATEWAY 为空串=同源,显示成本域名) + const origin = GATEWAY || (typeof window !== "undefined" ? window.location.origin : ""); + const callbackURL = origin.replace(/:\d+$/, "") + "/wx/mp/callback"; + + return ( +
+
+

登录设置 · 微信扫码

+

公众号扫码登录(登录即引导关注)。AppSecret 加密入库、只写不回显,随时可换

+
+ +
+
+

公众号配置

+ {cfg?.enabled ? ( + 已启用 + ) : ( + 未启用(三项齐全才生效) + )} +
+ +
+ + + +
+ + {err &&

{err}

} + {ok &&

已保存

} + +
+ +
+
+ + {/* 配置清单:把「必须在公众平台做什么」写在眼前,省得来回翻文档 */} +
+

还需在微信公众平台「服务器配置」里配置

+
    +
  1. 服务器地址(URL) 填 {callbackURL}
  2. +
  3. 令牌(Token) 填与上面完全一致的那串
  4. +
  5. 消息加解密方式选 明文模式(本服务按明文处理,选其它会验签失败)
  6. +
  7. 「IP 白名单」加上服务器出网的公网 IP(否则拉取 access_token 报 40164,二维码建不出来)
  8. +
  9. 保存服务器配置时微信会即时回调本服务验证——需先部署好本页配置再去点提交
  10. +
+
+
+ ); +} diff --git a/sundynix-admin/src/routes.tsx b/sundynix-admin/src/routes.tsx index 1b36aaf..56003ef 100644 --- a/sundynix-admin/src/routes.tsx +++ b/sundynix-admin/src/routes.tsx @@ -1,4 +1,5 @@ import { SubscriptionPage } from "./pages/SubscriptionPage"; +import { LoginConfigPage } from "./pages/LoginConfigPage"; import { lazy, type ReactNode } from "react"; // 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。 @@ -77,6 +78,13 @@ export const routes: RouteDef[] = [ ready: true, element: , }, + { + path: "login-config", + label: "登录设置", + group: "运维", + ready: true, + element: , + }, { path: "datasources", label: "数据源 & RAG", diff --git a/sundynix-gateway/internal/handler/wechat_login.go b/sundynix-gateway/internal/handler/wechat_login.go index f096883..8ef6aa3 100644 --- a/sundynix-gateway/internal/handler/wechat_login.go +++ b/sundynix-gateway/internal/handler/wechat_login.go @@ -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= 用户扫码后微信打开这个,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= —— 用户微信扫码后打开,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, `

✅ 登录成功

请回到电脑继续

`) + _ = h.cache.WxTicketSet(ctx, ticket, string(st), wxTicketTTL) + c.String(http.StatusOK, "success") } -// WxMPPoll: GET /api/v1/wx/mp/poll?t= —— PC 端轮询登录状态(公开路由)。 -// authorized 时签发 JWT 并把 ticket 置 consumed(一次性,防重放)。 +// WxMPPoll: GET /api/v1/wx/mp/poll?t= 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()}) } diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 60f038c..94142e0 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -47,17 +47,17 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. r.GET("/metrics", gin.WrapH(promhttp.Handler())) r.GET("/healthz", h.Healthz) r.GET("/readyz", h.Readyz) - // 微信扫码入口:用户在微信里打开二维码指向的这个地址,服务端 302 到微信授权页。 - // 顶级路径(非 /api)——二维码 URL 越短越好,且要落在「网页授权域名」根下。 - r.GET("/wx/mp", h.WxMPEntry) + // 微信公众号消息推送回调(服务器配置 URL):GET 验签回 echostr,POST 收关注/扫码事件。 + // 顶级路径(非 /api),微信服务器直接访问,无鉴权头。 + r.GET("/wx/mp/callback", h.WxMPVerify) + r.POST("/wx/mp/callback", h.WxMPEvent) api := r.Group("/api/v1") { // —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)—— api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权) - // 微信扫码登录(全公开:ticket 是唯一凭证;微信浏览器/PC 轮询都无鉴权头) - api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票,返回二维码 URL - api.GET("/wx/mp/callback", h.WxMPCallback) // 微信授权回调 + // 微信扫码登录(全公开:ticket 是唯一凭证;PC 建票与轮询都无鉴权头) + api.POST("/wx/mp/ticket", h.WxMPTicket) // PC 建票 + 微信二维码 api.GET("/wx/mp/poll", h.WxMPPoll) // PC 轮询登录态 api.POST("/auth/register", h.Register) // 注册 + 签发 JWT api.POST("/auth/login", h.Login) // 登录 + 签发 JWT diff --git a/sundynix-gateway/internal/store/redis.go b/sundynix-gateway/internal/store/redis.go index f59a271..5f804f6 100644 --- a/sundynix-gateway/internal/store/redis.go +++ b/sundynix-gateway/internal/store/redis.go @@ -182,6 +182,26 @@ func (r *Redis) WxTicketGet(ctx context.Context, ticket string) string { return v } +// WxTokenGet/Set 缓存微信 access_token(跨实例共享,避免重复拉取互相失效)。 +// 无 Redis 时返回空 → 调用方每次现拉(单实例开发可接受)。 +func (r *Redis) WxTokenGet(ctx context.Context, appID string) string { + if r.rdb == nil { + return "" + } + v, err := r.rdb.Get(ctx, "wxtoken:"+appID).Result() + if err != nil { + return "" + } + return v +} + +func (r *Redis) WxTokenSet(ctx context.Context, appID, token string, ttl time.Duration) { + if r.rdb == nil { + return + } + _ = r.rdb.Set(ctx, "wxtoken:"+appID, token, ttl).Err() +} + // Close 释放底层连接。 func (r *Redis) Close() { if r.rdb != nil { diff --git a/sundynix-gateway/internal/wechat/mp.go b/sundynix-gateway/internal/wechat/mp.go index 52e85c5..3888fa4 100644 --- a/sundynix-gateway/internal/wechat/mp.go +++ b/sundynix-gateway/internal/wechat/mp.go @@ -1,45 +1,48 @@ -// Package wechat 实现微信公众号(服务号)网页授权登录。 +// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。 // -// 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」, -// 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配 -// 「网页授权域名」,已认证服务号默认具备,副作用最小。 +// 登录即引导关注公众号(涨粉),流程: +// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图 +// 2. PC 显示这张微信二维码 +// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」 +// 4. 微信把事件推到我们服务器(消息推送/服务器配置): +// - 未关注用户 → subscribe 事件,EventKey=qrscene_ +// - 已关注用户 → SCAN 事件,EventKey= +// 两种都带 openid(FromUserName) +// 5. 后端按 openid 找/建用户 → ticket 置 authorized +// 6. PC 轮询 → 登录完成 // -// 登录流程(PC 端): -// 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t= -// 2. 用户微信扫码 → 微信内置浏览器打开该 URL → 后端 302 到微信 OAuth 授权页(state=ticket) -// 3. 用户「允许」→ 微信回调 /api/v1/wx/mp/callback?code&state → 用 code 换 openid -// 4. openid 找/建用户 → ticket 置 authorized(userID) -// 5. PC 端轮询 ticket → authorized → 签发 JWT -// -// 配置(appid/secret)后台可改、密钥加密入库,与微信支付同一套 secrets。 +// 消息加解密方式用「明文模式」:回调只验签名(Token),不做 AES 解密。走 HTTPS 已足够。 package wechat import ( "context" + "crypto/sha1" + "encoding/hex" "encoding/json" + "encoding/xml" "fmt" "io" "net/http" "net/url" + "sort" "strings" "time" "github.com/sundynix/sundynix-shared/secrets" ) -// Config 是公众号网页授权所需配置。AppSecret 加密入库,只写不回显(同微信支付 APIv3 密钥)。 +// Config 是公众号登录所需配置。AppSecret 加密入库、只写不回显(同微信支付 APIv3 密钥)。 type Config struct { AppID string `json:"appid"` AppSecret string `json:"app_secret"` - // 授权回调基地址,如 https://agent.sundynix.cn。留空则用请求 Host 推断。 - // 显式配置更稳:微信要求回调域名与「网页授权域名」完全一致,靠 Host 推断在反代下易错。 - BaseURL string `json:"base_url"` + // Token:消息推送签名校验用,与公众平台「服务器配置」里填的一致。 + Token string `json:"token"` } -// Enabled 报告配置是否完整到可用。 -func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" } +// Enabled 报告配置是否完整到可用(登录二维码需要 appid+secret;回调验签需要 token)。 +func (c Config) Enabled() bool { return c.AppID != "" && c.AppSecret != "" && c.Token != "" } -// EncryptedForStore 返回一份 AppSecret 已加密的副本,用于落库。 +// EncryptedForStore 返回 AppSecret 已加密的副本,用于落库。 func (c Config) EncryptedForStore() (Config, error) { if c.AppSecret == "" { return c, nil @@ -52,7 +55,7 @@ func (c Config) EncryptedForStore() (Config, error) { return c, nil } -// DecryptFromStore 把库内密文 AppSecret 还原为明文(无前缀的历史明文透传)。 +// DecryptFromStore 把库内密文 AppSecret 还原为明文。 func (c Config) DecryptFromStore() Config { if c.AppSecret != "" { if plain, err := secrets.Decrypt(c.AppSecret); err == nil { @@ -62,57 +65,130 @@ func (c Config) DecryptFromStore() Config { return c } -// AuthorizeURL 构造微信 OAuth 授权跳转地址。 -// scope=snsapi_base:只拿 openid,不弹授权页、用户无感(登录只需要唯一标识,够用了)。 -// state 回传我们的 ticket,用于把回调关联回发起登录的那个 PC 会话。 -func (c Config) AuthorizeURL(redirectURI, state string) string { - q := url.Values{} - q.Set("appid", c.AppID) - q.Set("redirect_uri", redirectURI) - q.Set("response_type", "code") - q.Set("scope", "snsapi_base") - q.Set("state", state) - // #wechat_redirect 是微信要求的锚点,缺了不跳转 - return "https://open.weixin.qq.com/connect/oauth2/authorize?" + q.Encode() + "#wechat_redirect" +// CheckSignature 校验微信消息推送签名:sha1(sort(token,timestamp,nonce))。 +// 服务器配置的 URL 验证(GET echostr)与每条事件推送(POST)都用它。 +func (c Config) CheckSignature(signature, timestamp, nonce string) bool { + if c.Token == "" { + return false + } + arr := []string{c.Token, timestamp, nonce} + sort.Strings(arr) + h := sha1.Sum([]byte(strings.Join(arr, ""))) + return hex.EncodeToString(h[:]) == signature } -// UserInfo 是 code 换取的结果(snsapi_base 下只有 openid)。 -type UserInfo struct { - OpenID string -} - -// ExchangeCode 用授权 code 换 openid(网页授权专用接口,不占 access_token 的 IP 白名单?—— -// 实际上仍走 api.weixin.qq.com,出网 IP 必须在白名单里,否则报 40164)。 -func (c Config) ExchangeCode(ctx context.Context, code string) (*UserInfo, error) { +// FetchAccessToken 拉取 access_token(纯函数,缓存交给调用方)。 +// 返回 (token, 有效秒数)。出网 IP 必须在公众平台 IP 白名单里,否则报 40164。 +func (c Config) FetchAccessToken(ctx context.Context) (string, int, error) { q := url.Values{} + q.Set("grant_type", "client_credential") q.Set("appid", c.AppID) q.Set("secret", c.AppSecret) - q.Set("code", code) - q.Set("grant_type", "authorization_code") - endpoint := "https://api.weixin.qq.com/sns/oauth2/access_token?" + q.Encode() + endpoint := "https://api.weixin.qq.com/cgi-bin/token?" + q.Encode() - reqCtx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, endpoint, nil) - resp, err := http.DefaultClient.Do(req) + body, err := httpGet(ctx, endpoint) if err != nil { - return nil, fmt.Errorf("请求微信换 openid 失败: %w", err) + return "", 0, err } - defer resp.Body.Close() - body, _ := io.ReadAll(resp.Body) - - // 微信无论成败都返回 200 + JSON;errcode 非 0 才是失败。 var r struct { - OpenID string `json:"openid"` + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(body, &r); err != nil { + return "", 0, fmt.Errorf("解析 access_token 响应失败: %s", strings.TrimSpace(string(body))) + } + if r.ErrCode != 0 || r.AccessToken == "" { + return "", 0, fmt.Errorf("获取 access_token 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) + } + return r.AccessToken, r.ExpiresIn, nil +} + +// CreateLoginQR 用带参数「临时」二维码承载 scene(=登录 ticket)。 +// expireSec:二维码有效期,登录场景取 ticket 的 TTL。返回可直接 展示的二维码图 URL。 +func (c Config) CreateLoginQR(ctx context.Context, accessToken, scene string, expireSec int) (string, error) { + reqBody := map[string]any{ + "expire_seconds": expireSec, + "action_name": "QR_STR_SCENE", // 字符串型 scene,便于放我们的随机 ticket + "action_info": map[string]any{"scene": map[string]any{"scene_str": scene}}, + } + raw, _ := json.Marshal(reqBody) + endpoint := "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + url.QueryEscape(accessToken) + + body, err := httpPost(ctx, endpoint, raw) + if err != nil { + return "", err + } + var r struct { + Ticket string `json:"ticket"` ErrCode int `json:"errcode"` ErrMsg string `json:"errmsg"` } if err := json.Unmarshal(body, &r); err != nil { - return nil, fmt.Errorf("解析微信响应失败: %s", strings.TrimSpace(string(body))) + return "", fmt.Errorf("解析二维码响应失败: %s", strings.TrimSpace(string(body))) } - if r.ErrCode != 0 || r.OpenID == "" { - // 40163=code 已使用,40029=code 无效,40164=IP 不在白名单——原样带出便于排查 - return nil, fmt.Errorf("微信换 openid 失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) + if r.ErrCode != 0 || r.Ticket == "" { + return "", fmt.Errorf("创建二维码失败: errcode=%d errmsg=%s", r.ErrCode, r.ErrMsg) } - return &UserInfo{OpenID: r.OpenID}, nil + // showqrcode 是微信提供的二维码图地址,ticket 需 URL 编码 + return "https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=" + url.QueryEscape(r.Ticket), nil +} + +// Event 是微信推送的事件(明文 XML)。只取登录需要的字段。 +type Event struct { + XMLName xml.Name `xml:"xml"` + MsgType string `xml:"MsgType"` // event + Event string `xml:"Event"` // subscribe / SCAN / unsubscribe ... + EventKey string `xml:"EventKey"` // subscribe: qrscene_;SCAN: + FromUserName string `xml:"FromUserName"` // 用户 openid +} + +// Scene 从事件里还原出我们的 scene(登录 ticket)。subscribe 事件带 qrscene_ 前缀,SCAN 不带。 +func (e Event) Scene() string { + return strings.TrimPrefix(e.EventKey, "qrscene_") +} + +// IsLoginScan 报告该事件是否是"扫我们登录二维码"(关注或已关注扫码),并携带 scene。 +func (e Event) IsLoginScan() bool { + if e.MsgType != "event" { + return false + } + return (e.Event == "subscribe" || e.Event == "SCAN") && e.Scene() != "" +} + +// ParseEvent 解析明文事件 XML。 +func ParseEvent(body []byte) (*Event, error) { + var e Event + if err := xml.Unmarshal(body, &e); err != nil { + return nil, err + } + return &e, nil +} + +// ---- HTTP 小工具 ---- + +func httpGet(ctx context.Context, endpoint string) ([]byte, error) { + rctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(rctx, http.MethodGet, endpoint, nil) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求微信失败: %w", err) + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) +} + +func httpPost(ctx context.Context, endpoint string, body []byte) ([]byte, error) { + rctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + req, _ := http.NewRequestWithContext(rctx, http.MethodPost, endpoint, strings.NewReader(string(body))) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("请求微信失败: %w", err) + } + defer resp.Body.Close() + return io.ReadAll(resp.Body) } diff --git a/sundynix-gateway/internal/wechat/mp_test.go b/sundynix-gateway/internal/wechat/mp_test.go index ae31d66..b0e509c 100644 --- a/sundynix-gateway/internal/wechat/mp_test.go +++ b/sundynix-gateway/internal/wechat/mp_test.go @@ -1,53 +1,112 @@ package wechat import ( + "crypto/sha1" + "encoding/hex" + "sort" "strings" "testing" ) func TestConfig_Enabled(t *testing.T) { - if (Config{AppID: "x"}).Enabled() { - t.Fatal("缺 secret 不该 enabled") + // 三者缺一不可:appid+secret 建二维码,token 验签 + cases := []struct { + c Config + want bool + }{ + {Config{AppID: "a", AppSecret: "s", Token: "t"}, true}, + {Config{AppID: "a", AppSecret: "s"}, false}, + {Config{AppID: "a", Token: "t"}, false}, + {Config{}, false}, } - if !(Config{AppID: "x", AppSecret: "y"}).Enabled() { - t.Fatal("齐全应 enabled") - } -} - -func TestAuthorizeURL(t *testing.T) { - c := Config{AppID: "wxAPP"} - u := c.AuthorizeURL("https://a.b/cb", "tkt123") - // 必备参数与微信要求的锚点 - for _, want := range []string{ - "open.weixin.qq.com/connect/oauth2/authorize", - "appid=wxAPP", - "scope=snsapi_base", - "state=tkt123", - "redirect_uri=https%3A%2F%2Fa.b%2Fcb", // 必须 URL 编码 - "#wechat_redirect", // 缺了微信不跳转 - } { - if !strings.Contains(u, want) { - t.Fatalf("授权 URL 缺 %q:%s", want, u) + for _, tc := range cases { + if got := tc.c.Enabled(); got != tc.want { + t.Fatalf("%+v Enabled=%v want %v", tc.c, got, tc.want) } } - // 锚点必须在最后 - if !strings.HasSuffix(u, "#wechat_redirect") { - t.Fatalf("#wechat_redirect 必须在末尾:%s", u) +} + +// 验签必须与微信算法一致:sha1(sort(token,ts,nonce))。 +func TestCheckSignature(t *testing.T) { + c := Config{Token: "mytoken"} + ts, nonce := "1700000000", "abc123" + arr := []string{c.Token, ts, nonce} + sort.Strings(arr) + sum := sha1.Sum([]byte(strings.Join(arr, ""))) + good := hex.EncodeToString(sum[:]) + + if !c.CheckSignature(good, ts, nonce) { + t.Fatal("正确签名应通过") + } + if c.CheckSignature("deadbeef", ts, nonce) { + t.Fatal("错误签名不该通过") + } + if (Config{}).CheckSignature(good, ts, nonce) { + t.Fatal("无 token 一律不通过(防未配置时被绕过)") + } +} + +// 事件解析 + scene 提取:subscribe 带 qrscene_ 前缀,SCAN 不带;两者都要能登录。 +func TestParseEvent_SubscribeAndScan(t *testing.T) { + subscribe := ` + + + +` + ev, err := ParseEvent([]byte(subscribe)) + if err != nil { + t.Fatal(err) + } + if !ev.IsLoginScan() { + t.Fatal("subscribe 带 qrscene 应识别为登录扫码") + } + if ev.Scene() != "tkt-abc" { + t.Fatalf("subscribe 应剥掉 qrscene_ 前缀,得 %q", ev.Scene()) + } + if ev.FromUserName != "openid_new" { + t.Fatalf("openid 取错:%q", ev.FromUserName) + } + + scan := ` + + +` + ev2, _ := ParseEvent([]byte(scan)) + if !ev2.IsLoginScan() || ev2.Scene() != "tkt-def" { + t.Fatalf("SCAN 事件应识别,scene=%q", ev2.Scene()) + } +} + +// 非登录事件(取关、普通消息)不能被当成登录。 +func TestParseEvent_IgnoresNonLogin(t *testing.T) { + unsub := `` + ev, _ := ParseEvent([]byte(unsub)) + if ev.IsLoginScan() { + t.Fatal("取关事件不该被当成登录") + } + // 无 scene 的 subscribe(用户直接搜号关注,不是扫登录码)也不登录 + plainSub := `` + ev2, _ := ParseEvent([]byte(plainSub)) + if ev2.IsLoginScan() { + t.Fatal("无 scene 的关注不该触发登录") + } + text := `` + ev3, _ := ParseEvent([]byte(text)) + if ev3.IsLoginScan() { + t.Fatal("普通文本消息不该触发登录") } } -// secret 加密往返:落库是密文,取出还原成明文。 func TestConfig_SecretRoundTrip(t *testing.T) { - c := Config{AppID: "x", AppSecret: "the-plain-secret"} + c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"} stored, err := c.EncryptedForStore() if err != nil { t.Fatal(err) } - if stored.AppSecret == "the-plain-secret" { - t.Fatal("落库的 secret 不该是明文") + if stored.AppSecret == "plain-secret" { + t.Fatal("落库不该是明文") } - back := stored.DecryptFromStore() - if back.AppSecret != "the-plain-secret" { + if back := stored.DecryptFromStore(); back.AppSecret != "plain-secret" { t.Fatalf("还原失败:%q", back.AppSecret) } } diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index 32d6039..7aef44e 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -76,6 +76,25 @@ export async function authLogin(email: string, password: string): Promise { + const res = await fetch(`${GATEWAY}/api/v1/wx/mp/ticket`, { method: "POST" }); + return jsonOrThrow<{ ticket: string; qr_image: string; expires_in: number }>(res, "创建登录二维码失败"); +} + +// 轮询:pending | authorized(带 token/user) | expired | consumed。 +export async function wxPoll(ticket: string): Promise<{ status: string; user?: AuthUser }> { + const res = await fetch(`${GATEWAY}/api/v1/wx/mp/poll?t=${encodeURIComponent(ticket)}`); + if (!res.ok) return { status: "expired" }; + const d = (await res.json()) as { status?: string; token?: string; user?: AuthUser }; + if (d.token && d.user) { + setToken(d.token); // authorized:后端走 issueToken 返回 token+user + return { status: "authorized", user: d.user }; + } + return { status: d.status ?? "pending" }; +} + export async function authMe(): Promise { if (!authToken) return null; const res = await fetch(`${GATEWAY}/api/v1/auth/me`, { headers: bearer() }); diff --git a/sundynix-web/src/pages/AuthPage.test.tsx b/sundynix-web/src/pages/AuthPage.test.tsx index 05dc87e..b51b161 100644 --- a/sundynix-web/src/pages/AuthPage.test.tsx +++ b/sundynix-web/src/pages/AuthPage.test.tsx @@ -7,14 +7,24 @@ import { AuthPage } from "./AuthPage"; vi.mock("../api", () => ({ authLogin: vi.fn(), authRegister: vi.fn(), + // 微信是默认 tab:给 wxTicket 一个 pending promise,组件挂载不报错、也不产生副作用。 + // 邮箱相关测试会先切到「邮箱」tab。 + wxTicket: vi.fn(() => new Promise(() => {})), + wxPoll: vi.fn(() => new Promise(() => {})), })); import { authLogin, authRegister } from "../api"; +// 所有邮箱表单测试的公共前置:默认在微信 tab,先切到邮箱。 +async function gotoEmail() { + await userEvent.click(await screen.findByRole("button", { name: "邮箱" })); +} + describe("AuthPage", () => { beforeEach(() => vi.clearAllMocks()); it("默认登录态:无名字字段,切到注册后出现", async () => { render(); + await gotoEmail(); // 登录态无「名字」标签 expect(screen.queryByText(/名字/)).toBeNull(); await userEvent.click(screen.getByText(/还没有账户/)); @@ -26,6 +36,7 @@ describe("AuthPage", () => { it("邮箱或密码为空时提交按钮禁用", async () => { render(); + await gotoEmail(); const btn = screen.getByRole("button", { name: "登录" }); expect(btn).toBeDisabled(); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); @@ -39,6 +50,7 @@ describe("AuthPage", () => { (authLogin as ReturnType).mockResolvedValue(user); const onAuthed = vi.fn(); render(); + await gotoEmail(); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); await userEvent.type(screen.getByPlaceholderText("••••••"), "pass123"); await userEvent.click(screen.getByRole("button", { name: "登录" })); @@ -51,6 +63,7 @@ describe("AuthPage", () => { (authLogin as ReturnType).mockRejectedValue(new Error("邮箱或密码不正确")); const onAuthed = vi.fn(); render(); + await gotoEmail(); await userEvent.type(screen.getByPlaceholderText(/you@example/), "a@b.com"); await userEvent.type(screen.getByPlaceholderText("••••••"), "wrong"); await userEvent.click(screen.getByRole("button", { name: "登录" })); @@ -63,6 +76,7 @@ describe("AuthPage", () => { (authRegister as ReturnType).mockResolvedValue(user); const onAuthed = vi.fn(); render(); + await gotoEmail(); await userEvent.click(screen.getByText(/还没有账户/)); await userEvent.type(screen.getByPlaceholderText(/怎么称呼你/), "小明"); await userEvent.type(screen.getByPlaceholderText(/you@example/), "c@d.com"); diff --git a/sundynix-web/src/pages/AuthPage.tsx b/sundynix-web/src/pages/AuthPage.tsx index 555bea9..6fa6484 100644 --- a/sundynix-web/src/pages/AuthPage.tsx +++ b/sundynix-web/src/pages/AuthPage.tsx @@ -1,9 +1,10 @@ -import { useState } from "react"; -import { authLogin, authRegister, type AuthUser } from "../api"; +import { useEffect, useRef, useState } from "react"; +import { authLogin, authRegister, wxTicket, wxPoll, type AuthUser } from "../api"; import { Button, Field, Input } from "../ui"; // 登录/注册一页两态。注册即建个人默认租户(后端现成行为),登录后进壳。 export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { + const [tab, setTab] = useState<"wechat" | "email">("wechat"); const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -32,9 +33,24 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) {
sundynix
-

- {mode === "login" ? "登录以管理你的组织、团队与账单" : "注册后自动创建你的个人工作区"} -

+

登录以管理你的组织、团队与账单

+ +
+ {(["wechat", "email"] as const).map((t) => ( + + ))} +
+ + {tab === "wechat" ? ( + + ) : ( + <>
{mode === "register" && ( @@ -66,7 +82,91 @@ export function AuthPage({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { className="mt-4 text-xs text-slate-500 transition hover:text-slate-300"> {mode === "login" ? "还没有账户?去注册" : "已有账户?去登录"} + + )}
); } + +// WechatLogin 公众号扫码登录:建票拿微信二维码图 → 展示 → 轮询。 +// 二维码是微信生成的图(showqrcode),扫码后弹关注页,关注即登录。 +function WechatLogin({ onAuthed }: { onAuthed: (u: AuthUser) => void }) { + const [qr, setQr] = useState(""); + const [err, setErr] = useState(""); + const [expired, setExpired] = useState(false); + const [nonce, setNonce] = useState(0); + const ticketRef = useRef(""); + + useEffect(() => { + let alive = true; + let timer: number | null = null; + setErr(""); + setExpired(false); + setQr(""); + + void (async () => { + try { + const t = await wxTicket(); + if (!alive) return; + ticketRef.current = t.ticket; + setQr(t.qr_image); + const deadline = Date.now() + t.expires_in * 1000; + const tick = async () => { + if (!alive) return; + if (Date.now() > deadline) { + setExpired(true); + return; + } + try { + const r = await wxPoll(ticketRef.current); + if (!alive) return; + if (r.status === "authorized" && r.user) { + onAuthed(r.user); + return; + } + if (r.status === "expired") { + setExpired(true); + return; + } + } catch { + /* 单次失败忽略,继续轮询 */ + } + timer = window.setTimeout(() => void tick(), 2000); + }; + timer = window.setTimeout(() => void tick(), 2000); + } catch (e) { + if (alive) setErr((e as Error).message); + } + })(); + + return () => { + alive = false; + if (timer) window.clearTimeout(timer); + }; + }, [nonce, onAuthed]); + + return ( +
+ {err ? ( +

{err}

+ ) : qr ? ( +
+ 微信登录二维码 + {expired && ( + + )} +
+ ) : ( +

正在生成二维码…

+ )} +

微信扫一扫,关注公众号即可登录

+
+ ); +}