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
+134 -58
View File
@@ -1,45 +1,48 @@
// Package wechat 实现微信公众号(服务号)网页授权登录。
// Package wechat 实现微信公众号(服务号)「带参数二维码 + 关注/扫码事件」登录。
//
// 为什么是网页授权而不是「带参数二维码 + 消息推送」:后者要在公众平台配「服务器配置」,
// 会接管该号的所有消息(自动回复失效),且需要额外接口权限。网页授权只需在公众平台配
// 「网页授权域名」,已认证服务号默认具备,副作用最小。
// 登录即引导关注公众号(涨粉),流程:
// 1. PC 建 login ticket → 后端用 access_token 调「带参数二维码」接口(scene=ticket) → 得微信二维码图
// 2. PC 显示这张微信二维码
// 3. 用户微信扫 → 弹出公众号关注页 → 用户「关注」
// 4. 微信把事件推到我们服务器(消息推送/服务器配置):
// - 未关注用户 → subscribe 事件,EventKey=qrscene_<ticket>
// - 已关注用户 → SCAN 事件,EventKey=<ticket>
// 两种都带 openid(FromUserName)
// 5. 后端按 openid 找/建用户 → ticket 置 authorized
// 6. PC 轮询 → 登录完成
//
// 登录流程(PC 端):
// 1. 前端请求建 ticket → 后端返回二维码,内容是本服务的 /wx/mp?t=<ticket>
// 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。返回可直接 <img> 展示的二维码图 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_<scene>SCAN: <scene>
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)
}
+89 -30
View File
@@ -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 := `<xml><ToUserName><![CDATA[gh_x]]></ToUserName>
<FromUserName><![CDATA[openid_new]]></FromUserName>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
<EventKey><![CDATA[qrscene_tkt-abc]]></EventKey></xml>`
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 := `<xml><FromUserName><![CDATA[openid_old]]></FromUserName>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[SCAN]]></Event>
<EventKey><![CDATA[tkt-def]]></EventKey></xml>`
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 := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[unsubscribe]]></Event></xml>`
ev, _ := ParseEvent([]byte(unsub))
if ev.IsLoginScan() {
t.Fatal("取关事件不该被当成登录")
}
// 无 scene 的 subscribe(用户直接搜号关注,不是扫登录码)也不登录
plainSub := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[event]]></MsgType><Event><![CDATA[subscribe]]></Event><EventKey><![CDATA[]]></EventKey></xml>`
ev2, _ := ParseEvent([]byte(plainSub))
if ev2.IsLoginScan() {
t.Fatal("无 scene 的关注不该触发登录")
}
text := `<xml><FromUserName><![CDATA[o]]></FromUserName><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[hi]]></Content></xml>`
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)
}
}