微信个人绑定状态复核前检查点
Some checks are pending
Security Checks / security (push) Waiting to run

保存微信个人绑定后端、按实际收件账户推送和回归源码;前端未接入,回归用例未执行,尚未部署验收。
This commit is contained in:
smail 2026-09-28 21:27:30 +08:00
parent 2a532b2224
commit afc177132a
9 changed files with 981 additions and 24 deletions

View File

@ -0,0 +1,213 @@
package controllers
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/dto/response"
"git.pcux.cn/xuans/Smail/hooks"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/services/access"
pluginservice "git.pcux.cn/xuans/Smail/services/plugin"
"git.pcux.cn/xuans/Smail/session"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
func weChatHook() framework.EmailHook {
for _, name := range []string{"wechat_push", "WeChatPushHook", "WechatPush"} {
if hook, found := hooks.GetHook(name); found {
return hook
}
}
return nil
}
func notificationBindingActor(ctx *appcontext.Context) (pluginservice.NotificationBindingActor, error) {
if !authorizedPluginUser(ctx) || session.Instance == nil || ctx.Context == nil {
return pluginservice.NotificationBindingActor{}, pluginservice.ErrWeChatBinding
}
token := session.Instance.Token(ctx)
if token == "" {
return pluginservice.NotificationBindingActor{}, pluginservice.ErrWeChatBinding
}
hash := sha256.Sum256([]byte(token))
return pluginservice.NotificationBindingActor{UserID: ctx.UserID, SessionVersion: ctx.SessionVersion,
SecurityVersion: ctx.SecurityVersion, SessionHash: hex.EncodeToString(hash[:])}, nil
}
func weChatRequestService(ctx *appcontext.Context, w http.ResponseWriter, recent bool) *pluginservice.ConfigService {
if !authorizedPluginUser(ctx) {
writeConfigAccessError(w, &access.Denial{Code: access.CodeAccessDenied})
return nil
}
if recent && !ctx.RecentAuth {
writeConfigAccessError(w, &access.Denial{Code: access.CodeRecentAuthRequired})
return nil
}
service := hooks.PluginConfigService()
if service == nil {
response.NewErrorResponse(response.ServerError, "通知配置服务不可用", "").FPrint(w)
}
return service
}
func weChatPublicSettings(status pluginservice.WeChatUserSettingsStatus) map[string]any {
return map[string]any{"version": status.Version, "bound": status.Settings.OpenID != "", "enabled": status.Settings.Enabled,
"showContent": status.Settings.ShowContent, "displayName": status.Settings.DisplayName}
}
func writeWeChatSettings(w http.ResponseWriter, userID int, status pluginservice.WeChatUserSettingsStatus) {
data := weChatPublicSettings(status)
data["synced"] = hooks.SyncWeChatUserSettings(userID, status.Settings) == nil
response.NewSuccessResponse(data).FPrint(w)
}
func WeChatUserSettings(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
service := weChatRequestService(ctx, w, req.Method != http.MethodGet)
if service == nil {
return
}
if req.Method == http.MethodGet {
status, err := service.GetOwnWeChatSettings(ctx.UserID)
if err != nil {
writeWeChatError(w, err)
return
}
available, message := service.WeChatBindingAvailability()
running := weChatHook() != nil
if available && !running {
message = "微信推送插件未运行,请联系超级管理员"
}
data := weChatPublicSettings(status)
data["available"], data["configured"], data["running"], data["message"] = available && running, available, running, message
response.NewSuccessResponse(data).FPrint(w)
return
}
if req.Method != http.MethodPut && req.Method != http.MethodDelete {
w.Header().Set("Allow", "GET, PUT, DELETE")
http.Error(w, "请求方法不支持", http.StatusMethodNotAllowed)
return
}
var input struct {
Enabled bool `json:"enabled"`
ShowContent bool `json:"showContent"`
Version uint64 `json:"version"`
}
if !decodePluginJSONBody(w, req, &input) {
return
}
status, err := service.UpdateOwnWeChatPreferences(ctx.UserID, input.Enabled, input.ShowContent, req.Method == http.MethodDelete, input.Version)
if err != nil {
writeWeChatError(w, err)
return
}
writeWeChatSettings(w, ctx.UserID, status)
}
func WeChatBinding(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
var input struct {
Action string `json:"action"`
Ticket string `json:"ticket"`
}
if !decodePluginJSONBody(w, req, &input) {
return
}
service := weChatRequestService(ctx, w, input.Action == "begin" || input.Action == "confirm")
if service == nil {
return
}
actor, err := notificationBindingActor(ctx)
if err != nil {
writeWeChatError(w, err)
return
}
var result any
switch input.Action {
case "begin":
if weChatHook() == nil {
writeWeChatError(w, pluginservice.WeChatNotice("微信推送插件未运行,请联系超级管理员"))
return
}
result, err = service.BeginWeChatBinding(actor)
case "status":
result, err = service.GetWeChatBinding(actor, input.Ticket)
case "cancel":
err = service.CancelWeChatBinding(actor, input.Ticket)
case "confirm":
var status pluginservice.WeChatUserSettingsStatus
status, err = service.ConfirmWeChatBinding(actor, input.Ticket)
if err == nil {
writeWeChatSettings(w, ctx.UserID, status)
return
}
default:
err = pluginservice.WeChatNotice("绑定操作无效")
}
if err != nil {
writeWeChatError(w, err)
return
}
response.NewSuccessResponse(result).FPrint(w)
}
// WeChatBindingCallback 不依赖手机浏览器的登录 Cookie,也不自动完成绑定。
func WeChatBindingCallback(w http.ResponseWriter, req *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
service := hooks.PluginConfigService()
if !config.IsSetupReady() || service == nil || service.ClaimWeChatBinding(req.Context(), req.URL.Query().Get("state"), req.URL.Query().Get("code")) != nil {
http.Error(w, "授权失败或二维码已失效,请返回 Smail 页面重新扫码。", http.StatusBadRequest)
return
}
_, _ = w.Write([]byte("微信授权已完成。请返回发起扫码的 Smail 页面,核对微信昵称后点击确认绑定。"))
}
func TestWeChatMessage(ctx *appcontext.Context, w http.ResponseWriter, req *http.Request) {
service := weChatRequestService(ctx, w, true)
if service == nil {
return
}
status, err := service.GetOwnWeChatSettings(ctx.UserID)
if err != nil || status.Settings.OpenID == "" {
writeWeChatError(w, pluginservice.WeChatNotice("请先绑定本人的微信"))
return
}
provider, ok := weChatHook().(framework.TestMessageProvider)
if !ok {
writeWeChatError(w, pluginservice.WeChatNotice("微信推送插件未运行,请联系超级管理员"))
return
}
if err := service.ReserveWeChatTest(ctx.UserID); err != nil {
writeWeChatError(w, err)
return
}
payload, err := json.Marshal(status.Settings)
if err != nil || provider.TestMessage(payload) != nil {
writeWeChatError(w, pluginservice.WeChatNotice("测试消息发送失败,请确认已关注公众号及模板配置正确"))
return
}
response.NewSuccessResponse(nil).FPrint(w)
}
func writeWeChatError(w http.ResponseWriter, err error) {
message := "微信通知操作失败,请稍后重试"
if errors.Is(err, pluginservice.ErrPluginSettingConflict) {
message = "设置已被其他操作更新,请刷新后重试"
} else if errors.Is(err, pluginservice.ErrWeChatBinding) {
message = pluginservice.ErrWeChatBinding.Error()
} else {
// 只公开固定业务提示,避免数据库错误携带内部路径、查询或连接信息。
var notice pluginservice.WeChatNotice
if errors.As(err, &notice) {
message = notice.Error()
}
}
response.NewErrorResponse(response.ParamsError, message, "").FPrint(w)
}

View File

@ -118,6 +118,31 @@ func (h *HookSender) callHook(ctx *context.Context, endpoint string, dto framewo
}
func (h *HookSender) ReceiveSaveAfter(ctx *context.Context, email *parsemail.Email, ue []*models.UserEmail) {
if h.name == "wechat_push" {
if pluginConfigService == nil {
return
}
allowed := make([]*models.UserEmail, 0, len(ue))
for _, recipient := range ue {
if recipient == nil || recipient.UserID <= 0 {
continue
}
// 发信前重新读取,停用、解绑或禁用账户后不能继续使用插件缓存中的旧接收人。
status, err := pluginConfigService.GetOwnWeChatSettings(recipient.UserID)
if err != nil || !status.Settings.Enabled || status.Settings.OpenID == "" {
continue
}
payload, err := json.Marshal(status.Settings)
if err != nil || h.ConfigureUserSettings(recipient.UserID, payload) != nil {
continue
}
allowed = append(allowed, recipient)
}
if len(allowed) == 0 {
return
}
ue = allowed
}
_, _ = h.callHook(ctx, "ReceiveSaveAfter", framework.HookDTO{Ctx: ctx, Email: email, UserEmail: ue})
}
@ -385,6 +410,11 @@ func Init(serverVersion string) {
_ = process.Kill()
continue
}
if syncErr := syncWeChatUserSettings(logicalID, hook); syncErr != nil {
log.Warnf("[%s] 微信个人设置恢复失败,插件未启用", logicalID)
_ = process.Kill()
continue
}
if healthErr := hook.Health(); healthErr != nil {
log.Warnf("[%s] Plugin health check failed", logicalID)
_ = process.Kill()
@ -401,6 +431,48 @@ func Init(serverVersion string) {
}
}
func SyncWeChatUserSettings(userID int, settings pluginservice.WeChatUserSettings) error {
var target framework.EmailHook
for _, name := range []string{"wechat_push", "WeChatPushHook", "WechatPush"} {
if hook, found := GetHook(name); found {
target = hook
break
}
}
configurer, ok := target.(framework.UserSettingsConfigurer)
if !ok || userID <= 0 {
return errors.New("微信插件不可用")
}
payload, err := json.Marshal(settings)
if err != nil {
return err
}
return configurer.ConfigureUserSettings(userID, payload)
}
func syncWeChatUserSettings(logicalID string, hook *HookSender) error {
if logicalID != "wechat_push" {
return nil
}
if hook == nil || pluginConfigService == nil {
return pluginservice.ErrPluginStoreUnavailable
}
entries, err := pluginConfigService.ListWeChatUserSettings()
if err != nil {
return err
}
for _, entry := range entries {
payload, err := json.Marshal(entry.Settings)
if err != nil {
return err
}
if err := hook.ConfigureUserSettings(entry.UserID, payload); err != nil {
return err
}
}
return nil
}
func SyncTelegramUserSettings(userID int, settings pluginservice.TelegramUserSettings) error {
if userID <= 0 {
return errors.New("invalid user ID")

View File

@ -43,6 +43,48 @@ type WeChatPushHook struct {
pushUser string
webDomain string
httpClient *http.Client
userSettings map[int]weChatUserSettings
}
type weChatUserSettings struct {
AppID string `json:"appId"`
OpenID string `json:"openId"`
DisplayName string `json:"displayName"`
Enabled bool `json:"enabled"`
ShowContent bool `json:"showContent"`
}
func (w *WeChatPushHook) ConfigureUserSettings(userID int, data json.RawMessage) error {
if userID <= 0 || len(data) > 4096 || !json.Valid(data) {
return fmt.Errorf("微信个人配置无效")
}
var settings weChatUserSettings
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&settings); err != nil || settings.Enabled && (settings.OpenID == "" || settings.AppID == "") {
return fmt.Errorf("微信个人配置无效")
}
w.tokenMu.Lock()
defer w.tokenMu.Unlock()
if w.userSettings == nil {
w.userSettings = make(map[int]weChatUserSettings)
}
w.userSettings[userID] = settings
return nil
}
func (w *WeChatPushHook) TestMessage(data json.RawMessage) error {
var settings weChatUserSettings
if len(data) > 4096 || json.Unmarshal(data, &settings) != nil || settings.OpenID == "" {
return fmt.Errorf("请先绑定本人的微信")
}
w.tokenMu.Lock()
appID := w.appId
w.tokenMu.Unlock()
if appID == "" || settings.AppID != appID {
return fmt.Errorf("系统公众号已变更,请重新绑定")
}
return w.sendUserMsg(&context.Context{}, settings.OpenID, "这是一条 Smail 微信通知测试消息。")
}
func (w *WeChatPushHook) GetName(ctx *context.Context) string {
@ -106,7 +148,7 @@ func (w *WeChatPushHook) Health() error {
return fmt.Errorf("微信插件不可用")
}
w.tokenMu.Lock()
configured := w.appId != "" && w.secret != "" && w.templateId != "" && w.pushUser != "" && w.webDomain != "" && w.httpClient != nil
configured := w.appId != "" && w.secret != "" && w.templateId != "" && w.webDomain != "" && w.httpClient != nil
w.tokenMu.Unlock()
if !configured {
return fmt.Errorf("微信插件尚未完成运行时配置")
@ -127,19 +169,29 @@ func (w *WeChatPushHook) ReceiveSaveAfter(ctx *context.Context, email *parsemail
w.tokenMu.Lock()
appID := w.appId
secret := w.secret
pushUser := w.pushUser
w.tokenMu.Unlock()
if email == nil || appID == "" || secret == "" || pushUser == "" {
if email == nil || appID == "" || secret == "" || email.MessageId <= 0 {
return
}
seen := make(map[int]bool)
for _, u := range ue {
if u == nil {
if u == nil || u.UserID <= 0 || u.IsRead != 0 || u.Status != 0 || seen[u.UserID] {
continue
}
// 管理员(Uid=1)收到邮件且非已读、非已删除 触发通知
if u.UserID == 1 && u.IsRead == 0 && u.Status == 0 && email.MessageId > 0 {
w.sendUserMsg(ctx, pushUser, buildWeChatMessage(email))
seen[u.UserID] = true
w.tokenMu.Lock()
settings := w.userSettings[u.UserID]
w.tokenMu.Unlock()
if !settings.Enabled || settings.OpenID == "" || settings.AppID != appID {
continue
}
content := "您收到一封新邮件,请登录邮箱查看。"
if settings.ShowContent {
content = buildWeChatMessage(email)
}
if err := w.sendUserMsg(ctx, settings.OpenID, content); err != nil {
log.WithContext(ctx).Warn("微信个人通知发送失败")
}
}
@ -226,7 +278,7 @@ type DataItem struct {
Color string `json:"color"`
}
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) {
func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, content string) error {
content = truncateWeChatText(content, maxWeChatMessageBytes)
w.tokenMu.Lock()
webDomain := w.webDomain
@ -246,12 +298,12 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
})
if err != nil {
log.WithContext(ctx).Error("wechat push request build failed")
return
return fmt.Errorf("微信推送请求构造失败")
}
token := w.getWxAccessToken()
if token == "" {
log.WithContext(ctx).Error("wechat push authentication failed")
return
return fmt.Errorf("微信公众号认证失败")
}
endpoint, _ := urlpkg.Parse("https://api.weixin.qq.com/cgi-bin/message/template/send")
query := endpoint.Query()
@ -260,18 +312,18 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
request, err := http.NewRequest(http.MethodPost, endpoint.String(), bytes.NewReader(sendMsgReq))
if err != nil {
log.WithContext(ctx).Error("wechat push request build failed")
return
return fmt.Errorf("微信推送请求构造失败")
}
request.Header.Set("Content-Type", "application/json")
resp, err := client.Do(request)
if err != nil || resp == nil {
log.WithContext(ctx).Error("wechat push request failed")
return
return fmt.Errorf("微信推送请求失败")
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
log.WithContext(ctx).Error("wechat push request was rejected")
return
return fmt.Errorf("微信推送请求被拒绝")
}
body, err := io.ReadAll(io.LimitReader(resp.Body, maxWeChatResponseBytes+1))
var result struct {
@ -279,7 +331,7 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
}
if err != nil || len(body) > maxWeChatResponseBytes || json.Unmarshal(body, &result) != nil || result.ErrorCode == nil {
log.WithContext(ctx).Error("微信推送响应无效或超过大小限制")
return
return fmt.Errorf("微信推送响应无效")
}
if *result.ErrorCode != 0 {
// 只记录业务码,不能输出可能含凭据或收件人信息的响应正文。
@ -292,7 +344,9 @@ func (w *WeChatPushHook) sendUserMsg(ctx *context.Context, userId string, conten
}
w.tokenMu.Unlock()
}
return fmt.Errorf("微信推送失败,业务码:%d", *result.ErrorCode)
}
return nil
}
func buildWeChatMessage(email *parsemail.Email) string {
@ -362,10 +416,6 @@ func parseWeChatRuntimeConfig(data json.RawMessage) (weChatRuntimeConfig, weChat
if err != nil {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信模板 ID 无效")
}
userID, err := credential.ValidateSecret(settings.UserID, 256)
if err != nil {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信用户 ID 无效")
}
webDomain, err := normalizeWeChatWebDomain(settings.WebDomain)
if err != nil {
return weChatRuntimeConfig{}, weChatSettings{}, "", fmt.Errorf("微信回调域名无效")
@ -384,7 +434,6 @@ func parseWeChatRuntimeConfig(data json.RawMessage) (weChatRuntimeConfig, weChat
}
settings.AppID = appID
settings.TemplateID = templateID
settings.UserID = userID
settings.WebDomain = webDomain
return payload, settings, secret, nil
}

View File

@ -13,6 +13,8 @@ import (
"git.pcux.cn/xuans/Smail/dto/parsemail"
"git.pcux.cn/xuans/Smail/hooks/framework"
"git.pcux.cn/xuans/Smail/models"
appcontext "git.pcux.cn/xuans/Smail/utils/context"
)
type weChatRoundTripFunc func(*http.Request) (*http.Response, error)
@ -53,6 +55,50 @@ func TestWeChatRuntimeConfigAcceptsAuthorizedPayload(t *testing.T) {
}
}
func TestWeChatRoutesOnlyToBoundActualRecipients(t *testing.T) {
hook := NewWechatPushHook()
if err := hook.Configure(validWeChatRuntimePayload()); err != nil {
t.Fatal(err)
}
hook.token, hook.tokenExpires = "test-access-token", time.Now().Add(time.Hour).Unix()
var recipients []string
hook.httpClient = &http.Client{Transport: weChatRoundTripFunc(func(request *http.Request) (*http.Response, error) {
var payload sendMsgRequest
if err := json.NewDecoder(request.Body).Decode(&payload); err != nil {
t.Fatal(err)
}
recipients = append(recipients, payload.Touser)
if payload.Data.Content.Value != "您收到一封新邮件,请登录邮箱查看。" {
t.Fatal("默认通知不应泄露正文或验证码")
}
return &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(`{"errcode":0}`))}, nil
})}
ctx := &appcontext.Context{}
email := &parsemail.Email{MessageId: 12, Subject: "private subject"}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 1}})
if len(recipients) != 0 {
t.Fatal("未绑定的用户一不应触发旧版全局收件人通知")
}
for id, recipient := range map[int]string{17: "openid-first", 29: "openid-second"} {
payload, err := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: recipient, Enabled: true})
if err != nil || hook.ConfigureUserSettings(id, payload) != nil {
t.Fatal("个人绑定配置失败")
}
}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 17}, {UserID: 29, IsRead: 1}, {UserID: 1}})
if len(recipients) != 1 || recipients[0] != "openid-first" {
t.Fatalf("实际通知收件人错误:%v", recipients)
}
payload, _ := json.Marshal(weChatUserSettings{AppID: "app-id", OpenID: "openid-first", Enabled: false})
if err := hook.ConfigureUserSettings(17, payload); err != nil {
t.Fatal(err)
}
hook.ReceiveSaveAfter(ctx, email, []*models.UserEmail{{UserID: 17}, {UserID: 29}})
if len(recipients) != 2 || recipients[1] != "openid-second" {
t.Fatal("停用本人推送不应影响其他收件账户")
}
}
func TestWeChatRuntimeConfigRejectsZeroGeneration(t *testing.T) {
var payload weChatRuntimeConfig
if err := json.Unmarshal(validWeChatRuntimePayload(), &payload); err != nil {

View File

@ -164,6 +164,10 @@ func registerV1TOTPRoute(mux *http.ServeMux) {
}
func registerV1PluginRoutes(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/user", limitBody(4<<10, contextIterceptor(controllers.WeChatUserSettings)))
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/binding", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.WeChatBinding))))
mux.HandleFunc("/api/v1/plugin/settings/wechat_push/test", onlyMethod(http.MethodPost, limitBody(4<<10, contextIterceptor(controllers.TestWeChatMessage))))
mux.HandleFunc("/api/v1/plugin/binding/wechat/callback", onlyMethod(http.MethodGet, controllers.WeChatBindingCallback))
for _, id := range []string{"telegram_push", "wechat_push"} {
mux.HandleFunc("/api/v1/plugin/secrets/"+id, limitBody(16<<10, contextIterceptor(controllers.PluginSecret)))
}

View File

@ -102,11 +102,15 @@ type bulkTarget struct {
// ConfigService 是插件全局 Secret 和用户设置的唯一写服务。
// HTTP 路由在 CG-025 完成能力接入前不得直接暴露本服务的写方法。
type ConfigService struct {
engine *xorm.Engine
vault *credential.Vault
now func() time.Time
ticketsMu sync.Mutex
tickets map[string]pluginBulkTicket
engine *xorm.Engine
vault *credential.Vault
now func() time.Time
ticketsMu sync.Mutex
tickets map[string]pluginBulkTicket
bindingMu sync.Mutex
wechatBindings map[string]weChatBinding
weChatTestMu sync.Mutex
weChatTestAttempts map[int]time.Time
}
func NewConfigService(engine *xorm.Engine, vault *credential.Vault) (*ConfigService, error) {

View File

@ -91,6 +91,95 @@ func TestConfigServiceAllowsEmptyTelegramChatIDToDisablePush(t *testing.T) {
}
}
func TestEveryRoleKeepsIndependentOwnWeChatSettings(t *testing.T) {
service, engine := newPluginConfigService(t)
for _, role := range []access.Role{access.RoleSuperAdmin, access.RoleAdmin, access.RoleUser} {
t.Run(string(role), func(t *testing.T) {
user := insertPluginTestUser(t, engine, "wechat-"+string(role), role)
status, err := service.GetOwnWeChatSettings(user.ID)
if err != nil || status.Settings.OpenID != "" || status.Version != 0 {
t.Fatalf("角色=%s,不应自动继承系统接收人:%v", role, err)
}
if _, err := service.UpdateOwnWeChatPreferences(user.ID, true, false, false, 0); !errors.Is(err, ErrWeChatBinding) {
t.Fatal("未绑定身份时不应直接启用微信推送")
}
settings := WeChatUserSettings{AppID: "wx-test-application", OpenID: "openid-" + string(role), Enabled: true}
status, err = service.saveWeChatSettings(user.ID, settings, 0)
if err != nil || status.Settings != settings || status.Version != 1 {
t.Fatalf("角色=%s,保存独立接收人失败:%v", role, err)
}
record, found, err := service.findUserSetting(weChatPluginID, user.ID)
if err != nil || !found || record.SettingsCiphertext == "" || record.SettingsCiphertext == settings.OpenID {
t.Fatal("微信个人身份必须加密保存")
}
if _, err := service.UpdateOwnWeChatPreferences(user.ID, false, false, false, 0); !errors.Is(err, ErrPluginSettingConflict) {
t.Fatal("旧版本不应覆盖绑定设置")
}
status, err = service.UpdateOwnWeChatPreferences(user.ID, false, false, false, 1)
if err != nil || status.Settings.OpenID != settings.OpenID || status.Settings.Enabled {
t.Fatal("停用推送应保留本人绑定")
}
status, err = service.UpdateOwnWeChatPreferences(user.ID, false, false, true, 2)
if err != nil || status.Settings.OpenID != "" || status.Settings.Enabled || status.Version != 3 {
t.Fatal("解绑应清除本人接收身份")
}
})
}
}
func TestWeChatBindingStatusRejectsOtherSessionsAndRevokedAccounts(t *testing.T) {
service, engine := newPluginConfigService(t)
user := insertPluginTestUser(t, engine, "wechat-binding", access.RoleSuperAdmin)
other := insertPluginTestUser(t, engine, "wechat-other", access.RoleUser)
actor := NotificationBindingActor{UserID: user.ID, SessionVersion: user.SessionVersion,
SecurityVersion: user.SecurityVersion, SessionHash: "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}
now := time.Now().UTC()
service.now = func() time.Time { return now }
service.wechatBindings = map[string]weChatBinding{"ticket": {Actor: actor, Status: WeChatBindingStatus{Phase: "waiting", ExpiresAt: now.Add(time.Minute)}}}
if _, err := service.GetWeChatBinding(actor, "ticket"); err != nil {
t.Fatalf("本人会话应能读取绑定状态:%v", err)
}
otherActor := actor
otherActor.UserID = other.ID
if _, err := service.GetWeChatBinding(otherActor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
t.Fatal("其他账户不应读取绑定请求")
}
otherActor = actor
otherActor.SessionHash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
if _, err := service.GetWeChatBinding(otherActor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
t.Fatal("同一账户的其他会话不应接管绑定")
}
now = now.Add(2 * time.Minute)
if _, err := service.GetWeChatBinding(actor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
t.Fatal("过期绑定不应继续使用")
}
now = now.Add(-2 * time.Minute)
if _, err := engine.ID(user.ID).Cols("disabled").Update(&models.User{Disabled: 1}); err != nil {
t.Fatal(err)
}
if _, err := service.GetWeChatBinding(actor, "ticket"); !errors.Is(err, ErrWeChatBinding) {
t.Fatal("被禁用账户的绑定必须立即失效")
}
}
func TestWeChatTestMessagesAreRateLimitedPerAccount(t *testing.T) {
service, engine := newPluginConfigService(t)
first := insertPluginTestUser(t, engine, "wechat-rate-first", access.RoleUser)
second := insertPluginTestUser(t, engine, "wechat-rate-second", access.RoleUser)
now := time.Now().UTC()
service.now = func() time.Time { return now }
if service.ReserveWeChatTest(first.ID) != nil || service.ReserveWeChatTest(second.ID) != nil {
t.Fatal("不同账户首次测试应相互独立")
}
if service.ReserveWeChatTest(first.ID) == nil {
t.Fatal("短时间内不应重复发送测试消息")
}
now = now.Add(30 * time.Second)
if err := service.ReserveWeChatTest(first.ID); err != nil {
t.Fatalf("冷却结束后应允许重试:%v", err)
}
}
func TestEveryRoleKeepsIndependentOwnTelegramSettings(t *testing.T) {
service, engine := newPluginConfigService(t)
var users []models.User

View File

@ -0,0 +1,289 @@
package plugin
import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/url"
"strings"
"time"
"git.pcux.cn/xuans/Smail/config"
"git.pcux.cn/xuans/Smail/utils/credential"
)
const WeChatCallbackPath = "/api/v1/plugin/binding/wechat/callback"
// NotificationBindingActor 将绑定限制在发起扫码的登录会话,角色不影响本人绑定。
type NotificationBindingActor struct {
UserID int
SessionVersion int
SecurityVersion int
SessionHash string
}
type WeChatBindingStatus struct {
Ticket string `json:"ticket,omitempty"`
URL string `json:"url,omitempty"`
Phase string `json:"phase"`
DisplayName string `json:"displayName,omitempty"`
ExpiresAt time.Time `json:"expiresAt"`
}
type weChatBinding struct {
Actor NotificationBindingActor
Version uint64
Fingerprint string
Settings WeChatUserSettings
Status WeChatBindingStatus
}
type weChatBindingConfig struct {
AppID string
Secret string
Callback string
Fingerprint string
}
func (s *ConfigService) weChatConfiguration() (weChatBindingConfig, error) {
var result weChatBindingConfig
manager := config.CurrentManager()
current := config.CurrentConfig()
if manager == nil || current == nil {
return result, WeChatNotice("请联系超级管理员配置微信公众号")
}
instance, exists := manager.Snapshot().Plugins.Plugins[weChatPluginID]
if !exists || !instance.Enabled {
return result, WeChatNotice("请联系超级管理员配置并启用微信公众号")
}
var settings struct {
AppID string `json:"appId"`
TemplateID string `json:"templateId"`
WebDomain string `json:"webDomain"`
}
if json.Unmarshal(instance.Settings, &settings) != nil || !validWeChatIdentity(settings.AppID) || settings.TemplateID == "" {
return result, WeChatNotice("请联系超级管理员补全微信公众号配置")
}
domain := strings.TrimSpace(current.WebDomain)
parsed, err := url.Parse("https://" + domain)
if err != nil || domain == "" || parsed.Host != domain || parsed.User != nil || parsed.Path != "" ||
parsed.RawQuery != "" || parsed.Fragment != "" || !strings.EqualFold(settings.WebDomain, domain) {
return result, WeChatNotice("公众号授权域名与后台域名不一致,请联系超级管理员")
}
secret, err := s.openSecretForPlugin(weChatPluginID, "appSecret")
if err != nil || secret == "" {
return result, WeChatNotice("请联系超级管理员配置微信公众号密钥")
}
fingerprint, err := s.vault.Fingerprint(settings.AppID + "\x00" + secret + "\x00" + domain)
if err != nil {
return result, ErrPluginStoreUnavailable
}
return weChatBindingConfig{AppID: settings.AppID, Secret: secret, Callback: "https://" + domain + WeChatCallbackPath, Fingerprint: fingerprint}, nil
}
func (s *ConfigService) WeChatBindingAvailability() (bool, string) {
if _, err := s.weChatConfiguration(); err != nil {
return false, err.Error()
}
return true, ""
}
func (s *ConfigService) validateBindingActor(actor NotificationBindingActor) error {
user, err := s.activeNotificationUser(actor.UserID)
if err != nil || len(actor.SessionHash) != 64 || user.SessionVersion != actor.SessionVersion || user.SecurityVersion != actor.SecurityVersion {
return ErrWeChatBinding
}
return nil
}
func (s *ConfigService) BeginWeChatBinding(actor NotificationBindingActor) (WeChatBindingStatus, error) {
if err := s.validateBindingActor(actor); err != nil {
return WeChatBindingStatus{}, err
}
configuration, err := s.weChatConfiguration()
if err != nil {
return WeChatBindingStatus{}, err
}
current, err := s.GetOwnWeChatSettings(actor.UserID)
if err != nil {
return WeChatBindingStatus{}, err
}
var nonce [32]byte
if _, err := rand.Read(nonce[:]); err != nil {
return WeChatBindingStatus{}, ErrPluginStoreUnavailable
}
ticket := base64.RawURLEncoding.EncodeToString(nonce[:])
now := s.currentTime()
status := WeChatBindingStatus{Ticket: ticket, Phase: "waiting", ExpiresAt: now.Add(5 * time.Minute)}
s.bindingMu.Lock()
defer s.bindingMu.Unlock()
if s.wechatBindings == nil {
s.wechatBindings = make(map[string]weChatBinding)
}
for key, binding := range s.wechatBindings {
if !binding.Status.ExpiresAt.After(now) || binding.Actor.UserID == actor.UserID {
delete(s.wechatBindings, key)
}
}
if len(s.wechatBindings) >= 2048 {
return WeChatBindingStatus{}, WeChatNotice("当前绑定请求较多,请稍后重试")
}
s.wechatBindings[ticket] = weChatBinding{Actor: actor, Version: current.Version, Fingerprint: configuration.Fingerprint, Status: status}
query := url.Values{"appid": {configuration.AppID}, "redirect_uri": {configuration.Callback},
"response_type": {"code"}, "scope": {"snsapi_userinfo"}, "state": {ticket}}
status.URL = "https://open.weixin.qq.com/connect/oauth2/authorize?" + query.Encode() + "#wechat_redirect"
return status, nil
}
func (s *ConfigService) GetWeChatBinding(actor NotificationBindingActor, ticket string) (WeChatBindingStatus, error) {
if err := s.validateBindingActor(actor); err != nil {
return WeChatBindingStatus{}, err
}
s.bindingMu.Lock()
defer s.bindingMu.Unlock()
binding, found := s.wechatBindings[ticket]
if !found || binding.Actor != actor || !binding.Status.ExpiresAt.After(s.currentTime()) {
return WeChatBindingStatus{}, ErrWeChatBinding
}
return binding.Status, nil
}
func (s *ConfigService) CancelWeChatBinding(actor NotificationBindingActor, ticket string) error {
if _, err := s.GetWeChatBinding(actor, ticket); err != nil {
return err
}
s.bindingMu.Lock()
delete(s.wechatBindings, ticket)
s.bindingMu.Unlock()
return nil
}
// ClaimWeChatBinding 只认领一次外部身份,不直接写入个人设置;仍需原页面确认。
func (s *ConfigService) ClaimWeChatBinding(ctx context.Context, ticket, code string) error {
if s == nil || len(ticket) != 43 || code == "" || len(code) > 512 {
return ErrWeChatBinding
}
s.bindingMu.Lock()
binding, found := s.wechatBindings[ticket]
if !found || binding.Status.Phase != "waiting" || !binding.Status.ExpiresAt.After(s.currentTime()) {
s.bindingMu.Unlock()
return ErrWeChatBinding
}
binding.Status.Phase = "processing"
s.wechatBindings[ticket] = binding
s.bindingMu.Unlock()
accepted := false
defer func() {
if !accepted {
s.bindingMu.Lock()
delete(s.wechatBindings, ticket)
s.bindingMu.Unlock()
}
}()
if err := s.validateBindingActor(binding.Actor); err != nil {
return err
}
configuration, err := s.weChatConfiguration()
if err != nil || configuration.Fingerprint != binding.Fingerprint {
return ErrWeChatBinding
}
var token struct {
AccessToken string `json:"access_token"`
OpenID string `json:"openid"`
ErrorCode int `json:"errcode"`
}
if err := requestWeChatBindingJSON(ctx, "/sns/oauth2/access_token", url.Values{
"appid": {configuration.AppID}, "secret": {configuration.Secret}, "code": {code}, "grant_type": {"authorization_code"},
}, &token); err != nil || token.ErrorCode != 0 || !validWeChatIdentity(token.OpenID) || token.AccessToken == "" || len(token.AccessToken) > 4096 {
return WeChatNotice("微信授权失败,请重新扫码")
}
var profile struct {
OpenID string `json:"openid"`
Nickname string `json:"nickname"`
ErrorCode int `json:"errcode"`
}
if err := requestWeChatBindingJSON(ctx, "/sns/userinfo", url.Values{
"access_token": {token.AccessToken}, "openid": {token.OpenID}, "lang": {"zh_CN"},
}, &profile); err != nil || profile.ErrorCode != 0 || profile.OpenID != token.OpenID || len(profile.Nickname) > 256 {
return WeChatNotice("微信身份读取失败,请重新扫码")
}
binding.Settings = WeChatUserSettings{AppID: configuration.AppID, OpenID: token.OpenID, DisplayName: profile.Nickname, Enabled: true}
binding.Status.Phase = "claimed"
binding.Status.DisplayName = profile.Nickname
if binding.Status.DisplayName == "" {
binding.Status.DisplayName = "微信用户 · " + token.OpenID[len(token.OpenID)-6:]
}
s.bindingMu.Lock()
defer s.bindingMu.Unlock()
if current, exists := s.wechatBindings[ticket]; !exists || current.Status.Phase != "processing" || !current.Status.ExpiresAt.After(s.currentTime()) {
return ErrWeChatBinding
}
s.wechatBindings[ticket] = binding
accepted = true
return nil
}
func (s *ConfigService) ConfirmWeChatBinding(actor NotificationBindingActor, ticket string) (WeChatUserSettingsStatus, error) {
if err := s.validateBindingActor(actor); err != nil {
return WeChatUserSettingsStatus{}, err
}
configuration, err := s.weChatConfiguration()
if err != nil {
return WeChatUserSettingsStatus{}, err
}
s.bindingMu.Lock()
defer s.bindingMu.Unlock()
binding, found := s.wechatBindings[ticket]
if !found || binding.Actor != actor || binding.Status.Phase != "claimed" ||
!binding.Status.ExpiresAt.After(s.currentTime()) || binding.Fingerprint != configuration.Fingerprint {
return WeChatUserSettingsStatus{}, ErrWeChatBinding
}
delete(s.wechatBindings, ticket)
return s.saveWeChatSettings(actor.UserID, binding.Settings, binding.Version)
}
func (s *ConfigService) ReserveWeChatTest(userID int) error {
if _, err := s.activeNotificationUser(userID); err != nil {
return err
}
s.weChatTestMu.Lock()
defer s.weChatTestMu.Unlock()
now := s.currentTime()
if s.weChatTestAttempts == nil {
s.weChatTestAttempts = make(map[int]time.Time)
}
for id, last := range s.weChatTestAttempts {
if now.Sub(last) >= 30*time.Second {
delete(s.weChatTestAttempts, id)
}
}
if _, exists := s.weChatTestAttempts[userID]; exists || len(s.weChatTestAttempts) >= 4096 {
return WeChatNotice("测试消息发送过于频繁,请三十秒后重试")
}
s.weChatTestAttempts[userID] = now
return nil
}
func requestWeChatBindingJSON(ctx context.Context, path string, query url.Values, target any) error {
client := credential.NewRestrictedHTTPClientForTargets(10_000, []credential.NetworkTarget{{Scheme: "https", Host: "api.weixin.qq.com", Port: 443}})
request, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://api.weixin.qq.com"+path+"?"+query.Encode(), nil)
if err != nil {
return ErrWeChatBinding
}
response, err := client.Do(request)
if err != nil {
return ErrWeChatBinding
}
defer response.Body.Close()
if response.StatusCode != http.StatusOK {
return ErrWeChatBinding
}
data, err := io.ReadAll(io.LimitReader(response.Body, (64<<10)+1))
if err != nil || len(data) > 64<<10 || json.Unmarshal(data, target) != nil {
return ErrWeChatBinding
}
return nil
}

View File

@ -0,0 +1,191 @@
package plugin
import (
"encoding/json"
"errors"
"strings"
"git.pcux.cn/xuans/Smail/models"
"git.pcux.cn/xuans/Smail/services/access"
"git.pcux.cn/xuans/Smail/utils/credential"
)
const weChatPluginID = "wechat_push"
var ErrWeChatBinding = errors.New("微信绑定已失效,请重新扫码")
// WeChatNotice 仅承载可公开的固定业务提示,数据库和外部请求错误不得使用此类型。
type WeChatNotice string
func (notice WeChatNotice) Error() string { return string(notice) }
// WeChatUserSettings 只通过已验证的扫码流程写入接收身份,不接受页面直接指定 OpenID。
type WeChatUserSettings struct {
AppID string `json:"appId"`
OpenID string `json:"openId"`
DisplayName string `json:"displayName"`
Enabled bool `json:"enabled"`
ShowContent bool `json:"showContent"`
}
type WeChatUserSettingsStatus struct {
Settings WeChatUserSettings
Version uint64
}
type WeChatUserSettingsEntry struct {
UserID int
Settings WeChatUserSettings
}
func (s *ConfigService) activeNotificationUser(userID int) (models.User, error) {
if s == nil || s.engine == nil || s.vault == nil || userID <= 0 {
return models.User{}, ErrPluginStoreUnavailable
}
user, found, err := s.findUser(userID)
if err != nil || !found || user.Disabled != 0 {
return models.User{}, ErrPluginStoreUnavailable
}
if err := access.Authorize(access.Request{ActorRole: user.EffectiveRole(), Capability: access.CapabilityPluginWriteOwn, OwnResource: true}); err != nil {
return models.User{}, err
}
return user, nil
}
func (s *ConfigService) GetOwnWeChatSettings(userID int) (WeChatUserSettingsStatus, error) {
if _, err := s.activeNotificationUser(userID); err != nil {
return WeChatUserSettingsStatus{}, err
}
record, found, err := s.findUserSetting(weChatPluginID, userID)
if err != nil || !found {
return WeChatUserSettingsStatus{}, err
}
settings, err := s.decodeWeChatSettings(record)
return WeChatUserSettingsStatus{Settings: settings, Version: record.Version}, err
}
func (s *ConfigService) decodeWeChatSettings(record models.PluginUserSetting) (WeChatUserSettings, error) {
var settings WeChatUserSettings
if record.SchemaVersion != userSettingsSchemaVersion {
return settings, ErrPluginStoreUnavailable
}
plaintext, err := s.vault.Decrypt(credential.CurrentVersion, record.SettingsCiphertext)
if err != nil {
return settings, ErrPluginStoreUnavailable
}
fingerprint, err := s.vault.Fingerprint(plaintext)
if err != nil || fingerprint != record.SettingsHash || !json.Valid([]byte(plaintext)) {
return settings, ErrPluginStoreUnavailable
}
decoder := json.NewDecoder(strings.NewReader(plaintext))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&settings); err != nil || !validWeChatSettings(settings) {
return WeChatUserSettings{}, ErrPluginStoreUnavailable
}
return settings, nil
}
func validWeChatIdentity(value string) bool {
if len(value) < 8 || len(value) > 128 {
return false
}
for _, char := range value {
if !(char >= 'a' && char <= 'z' || char >= 'A' && char <= 'Z' || char >= '0' && char <= '9' || char == '-' || char == '_') {
return false
}
}
return true
}
func validWeChatSettings(settings WeChatUserSettings) bool {
if len(settings.DisplayName) > 256 {
return false
}
if settings.OpenID == "" {
return settings.AppID == "" && !settings.Enabled
}
return validWeChatIdentity(settings.OpenID) && validWeChatIdentity(settings.AppID)
}
func (s *ConfigService) saveWeChatSettings(userID int, settings WeChatUserSettings, expectedVersion uint64) (WeChatUserSettingsStatus, error) {
if _, err := s.activeNotificationUser(userID); err != nil {
return WeChatUserSettingsStatus{}, err
}
if !validWeChatSettings(settings) {
return WeChatUserSettingsStatus{}, ErrWeChatBinding
}
payload, err := json.Marshal(settings)
if err != nil {
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
}
ciphertext, err := s.vault.Encrypt(string(payload))
if err != nil {
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
}
fingerprint, err := s.vault.Fingerprint(string(payload))
if err != nil {
return WeChatUserSettingsStatus{}, ErrPluginStoreUnavailable
}
current, found, err := s.findUserSetting(weChatPluginID, userID)
if err != nil {
return WeChatUserSettingsStatus{}, err
}
if current.Version != expectedVersion || !found && expectedVersion != 0 {
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
}
now := s.currentTime()
record := models.PluginUserSetting{PluginID: weChatPluginID, UserID: userID, SchemaVersion: userSettingsSchemaVersion,
SettingsCiphertext: ciphertext, SettingsHash: fingerprint, Enabled: true, Version: expectedVersion + 1, CreatedAt: now, UpdatedAt: now}
if !found {
if _, err := s.engine.Insert(&record); err != nil {
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
}
} else {
changed, err := s.engine.ID(current.ID).Where("version = ?", expectedVersion).
Cols("schema_version", "settings_ciphertext", "settings_hash", "enabled", "version", "updated_at").Update(&record)
if err != nil || changed != 1 {
return WeChatUserSettingsStatus{}, ErrPluginSettingConflict
}
}
return WeChatUserSettingsStatus{Settings: settings, Version: record.Version}, nil
}
func (s *ConfigService) UpdateOwnWeChatPreferences(userID int, enabled, showContent, unbind bool, expectedVersion uint64) (WeChatUserSettingsStatus, error) {
current, err := s.GetOwnWeChatSettings(userID)
if err != nil {
return current, err
}
settings := current.Settings
if unbind {
settings = WeChatUserSettings{}
} else {
settings.Enabled, settings.ShowContent = enabled, showContent
}
return s.saveWeChatSettings(userID, settings, expectedVersion)
}
func (s *ConfigService) ListWeChatUserSettings() ([]WeChatUserSettingsEntry, error) {
if s == nil || s.engine == nil || s.vault == nil {
return nil, ErrPluginStoreUnavailable
}
var records []models.PluginUserSetting
if err := s.engine.Where("plugin_id = ? AND enabled = ?", weChatPluginID, true).Find(&records); err != nil {
return nil, ErrPluginStoreUnavailable
}
entries := make([]WeChatUserSettingsEntry, 0, len(records))
for _, record := range records {
user, found, err := s.findUser(record.UserID)
if err != nil {
return nil, ErrPluginStoreUnavailable
}
if !found || user.Disabled != 0 {
continue
}
settings, err := s.decodeWeChatSettings(record)
if err != nil {
return nil, err
}
entries = append(entries, WeChatUserSettingsEntry{UserID: record.UserID, Settings: settings})
}
return entries, nil
}