294 lines
10 KiB
Go
294 lines
10 KiB
Go
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.validateBindingActor(actor); err != nil {
|
|
return err
|
|
}
|
|
s.bindingMu.Lock()
|
|
defer s.bindingMu.Unlock()
|
|
// 过期、回调失败或重复取消都允许关闭页面,但不得撤销其他会话的请求。
|
|
if binding, found := s.wechatBindings[ticket]; found && binding.Actor != actor {
|
|
return ErrWeChatBinding
|
|
}
|
|
delete(s.wechatBindings, ticket)
|
|
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
|
|
}
|