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>
113 lines
3.6 KiB
Go
113 lines
3.6 KiB
Go
package wechat
|
|
|
|
import (
|
|
"crypto/sha1"
|
|
"encoding/hex"
|
|
"sort"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestConfig_Enabled(t *testing.T) {
|
|
// 三者缺一不可: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},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := tc.c.Enabled(); got != tc.want {
|
|
t.Fatalf("%+v Enabled=%v want %v", tc.c, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 验签必须与微信算法一致: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("普通文本消息不该触发登录")
|
|
}
|
|
}
|
|
|
|
func TestConfig_SecretRoundTrip(t *testing.T) {
|
|
c := Config{AppID: "x", AppSecret: "plain-secret", Token: "t"}
|
|
stored, err := c.EncryptedForStore()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stored.AppSecret == "plain-secret" {
|
|
t.Fatal("落库不该是明文")
|
|
}
|
|
if back := stored.DecryptFromStore(); back.AppSecret != "plain-secret" {
|
|
t.Fatalf("还原失败:%q", back.AppSecret)
|
|
}
|
|
}
|