diff --git a/backend/go.mod b/backend/go.mod index 9830483..513a90c 100755 --- a/backend/go.mod +++ b/backend/go.mod @@ -16,6 +16,7 @@ require ( github.com/pressly/goose/v3 v3.23.0 github.com/samber/lo v1.47.0 github.com/sirupsen/logrus v1.9.3 + github.com/smartystreets/goconvey v1.6.4 github.com/speps/go-hashids/v2 v2.0.1 github.com/spf13/cobra v1.8.1 github.com/swaggo/fiber-swagger v1.3.0 @@ -41,11 +42,13 @@ require ( github.com/gofiber/utils/v2 v2.0.0-beta.4 // indirect github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/hcl v1.0.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect + github.com/jtolds/gls v4.20.0+incompatible // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.6 // indirect @@ -65,6 +68,7 @@ require ( github.com/sagikazarmark/locafero v0.3.0 // indirect github.com/sagikazarmark/slog-shim v0.1.0 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect + github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d // indirect github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.10.0 // indirect github.com/spf13/cast v1.5.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 97555c3..d049782 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -172,6 +172,7 @@ github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= @@ -196,6 +197,7 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las= github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk= @@ -285,7 +287,9 @@ github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLy github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= diff --git a/backend/providers/wechat/errors.go b/backend/providers/wechat/errors.go new file mode 100644 index 0000000..58c8609 --- /dev/null +++ b/backend/providers/wechat/errors.go @@ -0,0 +1,55 @@ +package wechat + +import "github.com/pkg/errors" + +// -1 系统繁忙,此时请开发者稍候再试 +// 0 请求成功 +// 40001 AppSecret错误或者AppSecret不属于这个公众号,请开发者确认AppSecret的正确性 +// 40002 请确保grant_type字段值为client_credential +// 40164 调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置。 +// 40243 AppSecret已被冻结,请登录MP解冻后再次调用。 +// 89503 此IP调用需要管理员确认,请联系管理员 +// 89501 此IP正在等待管理员确认,请联系管理员 +// 89506 24小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用 +// 89507 1小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用 +// 10003 redirect_uri域名与后台配置不一致 +// 10004 此公众号被封禁 +// 10005 此公众号并没有这些scope的权限 +// 10006 必须关注此测试号 +// 10009 操作太频繁了,请稍后重试 +// 10010 scope不能为空 +// 10011 redirect_uri不能为空 +// 10012 appid不能为空 +// 10013 state不能为空 +// 10015 公众号未授权第三方平台,请检查授权状态 +// 10016 不支持微信开放平台的Appid,请使用公众号Appid +func translateError(errCode int) error { + errors := map[int]error{ + 0: nil, + -1: errors.New("系统繁忙,此时请开发者稍候再试"), + 40001: errors.New("AppSecret错误或者AppSecret不属于这个公众号,请开发者确认AppSecret的正确性"), + 40002: errors.New("请确保grant_type字段值为client_credential"), + 40164: errors.New("调用接口的IP地址不在白名单中,请在接口IP白名单中进行设置"), + 40243: errors.New("AppSecret已被冻结,请登录MP解冻后再次调用"), + 89503: errors.New("此IP调用需要管理员确认,请联系管理员"), + 89501: errors.New("此IP正在等待管理员确认,请联系管理员"), + 89506: errors.New("24小时内该IP被管理员拒绝调用两次,24小时内不可再使用该IP调用"), + 89507: errors.New("1小时内该IP被管理员拒绝调用一次,1小时内不可再使用该IP调用"), + 10003: errors.New("redirect_uri域名与后台配置不一致"), + 10004: errors.New("此公众号被封禁"), + 10005: errors.New("此公众号并没有这些scope的权限"), + 10006: errors.New("必须关注此测试号"), + 10009: errors.New("操作太频繁了,请稍后重试"), + 10010: errors.New("scope不能为空"), + 10011: errors.New("redirect_uri不能为空"), + 10012: errors.New("appid不能为空"), + 10013: errors.New("state不能为空"), + 10015: errors.New("公众号未授权第三方平台,请检查授权状态"), + 10016: errors.New("不支持微信开放平台的Appid,请使用公众号Appid"), + } + + if err, ok := errors[errCode]; ok { + return err + } + return nil +} diff --git a/backend/providers/wechat/funcs.go b/backend/providers/wechat/funcs.go new file mode 100644 index 0000000..89a9699 --- /dev/null +++ b/backend/providers/wechat/funcs.go @@ -0,0 +1,14 @@ +package wechat + +import "math/rand" + +// RandomString generate random size string +func randomString(size int) (string, error) { + // generate size string [0-9a-zA-Z] + const chars = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + b := make([]byte, size) + for i := range b { + b[i] = chars[rand.Intn(len(chars))] + } + return string(b), nil +} diff --git a/backend/providers/wechat/options.go b/backend/providers/wechat/options.go new file mode 100644 index 0000000..fbf74eb --- /dev/null +++ b/backend/providers/wechat/options.go @@ -0,0 +1,69 @@ +package wechat + +import ( + "net/url" + + "github.com/imroc/req/v3" +) + +type Options func(*Client) + +func WithAppID(appID string) Options { + return func(we *Client) { + we.appID = appID + } +} + +// WithAppSecret sets the app secret +func WithAppSecret(appSecret string) Options { + return func(we *Client) { + we.appSecret = appSecret + } +} + +// WithToken sets the token +func WithToken(token string) Options { + return func(we *Client) { + we.token = token + } +} + +// WithAESKey sets the AES key +func WithAESKey(aesKey string) Options { + return func(we *Client) { + we.aesKey = aesKey + } +} + +// WithClient sets the http client +func WithClient(client *req.Client) Options { + return func(we *Client) { + we.client = client + } +} + +type ScopeAuthorizeURLOptions func(url.Values) + +func ScopeAuthorizeURLWithScope(scope AuthScope) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("scope", scope.String()) + } +} + +func ScopeAuthorizeURLWithRedirectURI(uri string) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("redirect_uri", uri) + } +} + +func ScopeAuthorizeURLWithState(state string) ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("state", state) + } +} + +func ScopeAuthorizeURLWithForcePopup() ScopeAuthorizeURLOptions { + return func(v url.Values) { + v.Set("forcePopup", "true") + } +} diff --git a/backend/providers/wechat/provider.go b/backend/providers/wechat/provider.go new file mode 100644 index 0000000..a0d9c92 --- /dev/null +++ b/backend/providers/wechat/provider.go @@ -0,0 +1,43 @@ +package wechat + +import ( + "git.ipao.vip/rogeecn/atom/container" + "git.ipao.vip/rogeecn/atom/utils/opt" +) + +func DefaultProvider() container.ProviderContainer { + return container.ProviderContainer{ + Provider: Provide, + Options: []opt.Option{}, + } +} + +type Config struct { + AppID string + AppSecret string + Token string + AesKey string + DevMode bool +} + +func Provide(opts ...opt.Option) error { + o := opt.New(opts...) + var config Config + if err := o.UnmarshalConfig(&config); err != nil { + return err + } + + return container.Container.Provide(func() (*Client, error) { + httpClient := DefaultClient + if config.DevMode { + httpClient = httpClient.DevMode() + } + return New( + WithAppID(config.AppID), + WithAppSecret(config.AppSecret), + WithAESKey(config.AesKey), + WithToken(config.Token), + WithClient(httpClient), + ), nil + }, o.DiOptions()...) +} diff --git a/backend/providers/wechat/response.go b/backend/providers/wechat/response.go new file mode 100644 index 0000000..cea6324 --- /dev/null +++ b/backend/providers/wechat/response.go @@ -0,0 +1,16 @@ +package wechat + +type Response struct { + ErrCode int `json:"errcode"` + ErrMsg int `json:"errmsg"` + ErrDescribe int `json:"-"` +} + +func (r *Response) Error() error { + return translateError(r.ErrCode) +} + +type AccessTokenResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` // seconds +} diff --git a/backend/providers/wechat/wechat.go b/backend/providers/wechat/wechat.go new file mode 100644 index 0000000..1874f62 --- /dev/null +++ b/backend/providers/wechat/wechat.go @@ -0,0 +1,182 @@ +package wechat + +import ( + "crypto/sha1" + "encoding/hex" + "net/url" + "sort" + "strings" + + "github.com/imroc/req/v3" + "github.com/pkg/errors" +) + +const BaseURL = "https://api.weixin.qq.com/" + +var DefaultClient = req. + NewClient(). + SetBaseURL(BaseURL). + SetCommonHeader("Content-Type", "application/json") + +const ( + ScopeBase = "snsapi_base" + ScopeUserInfo = "snsapi_userinfo" +) + +type AuthScope string + +func (s AuthScope) String() string { + return string(s) +} + +type Client struct { + client *req.Client + + appID string + appSecret string + token string + aesKey string +} + +func New(options ...Options) *Client { + we := &Client{ + client: DefaultClient, + } + + for _, opt := range options { + opt(we) + } + + return we +} + +func (we *Client) Verify(signature, timestamp, nonce string) error { + params := []string{signature, timestamp, nonce, we.token} + sort.Strings(params) + str := strings.Join(params, "") + hash := sha1.Sum([]byte(str)) + hashStr := hex.EncodeToString(hash[:]) + + if hashStr == signature { + return errors.New("Signature verification failed") + } + + return nil +} + +func (we *Client) wrapParams(params map[string]string) map[string]string { + if params == nil { + params = make(map[string]string) + } + + params["appid"] = we.appID + params["secret"] = we.appSecret + + return params +} + +func (we *Client) GetAccessToken() (*AccessTokenResponse, error) { + params := map[string]string{ + "grant_type": "client_credential", + } + + var data AccessTokenResponse + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/cgi-bin/token") + if err != nil { + return nil, errors.Wrap(err, "call /cgi-bin/token failed") + } + + return &data, nil +} + +// ScopeAuthorizeURL +func (we *Client) ScopeAuthorizeURL(opts ...ScopeAuthorizeURLOptions) (*url.URL, error) { + params := url.Values{} + params.Add("appid", we.appID) + params.Add("response_type", "code") + + for _, opt := range opts { + opt(params) + } + + if params.Get("scope") == "" { + params.Add("scope", ScopeBase) + } + + u, err := url.Parse("https://open.weixin.qq.com/connect/oauth2/authorize") + if err != nil { + return nil, errors.Wrap(err, "parse url failed") + } + + u.Fragment = "wechat_redirect" + u.RawQuery = url.Values(params).Encode() + + return u, nil +} + +type AuthorizeAccessToken struct { + AccessToken string `json:"access_token"` + ExpiresIn int64 `json:"expires_in"` + IsSnapshotuser int64 `json:"is_snapshotuser"` + Openid string `json:"openid"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` + Unionid string `json:"unionid"` +} + +func (we *Client) AuthorizeCode2Token(code string) (*AuthorizeAccessToken, error) { + params := we.wrapParams(map[string]string{ + "code": code, + "grant_type": "authorization_code", + }) + + var data AuthorizeAccessToken + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/oauth2/access_token") + if err != nil { + return nil, errors.Wrap(err, "call /sns/oauth2/access_token failed") + } + + return &data, nil +} + +func (we *Client) AuthorizeRefreshAccessToken(accessToken string) (*AuthorizeAccessToken, error) { + params := we.wrapParams(map[string]string{ + "refresh_token": accessToken, + "grant_type": "refresh_token", + }) + + var data AuthorizeAccessToken + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/oauth2/refresh_token") + if err != nil { + return nil, errors.Wrap(err, "call /sns/oauth2/refresh_token failed") + } + + return &data, nil +} + +type AuthorizeUserInfo struct { + City string `json:"city"` + Country string `json:"country"` + Headimgurl string `json:"headimgurl"` + Nickname string `json:"nickname"` + Openid string `json:"openid"` + Privilege []string `json:"privilege"` + Province string `json:"province"` + Sex int64 `json:"sex"` + Unionid string `json:"unionid"` +} + +func (we *Client) AuthorizeUserInfo(accessToken, openID string) (*AuthorizeUserInfo, error) { + params := (map[string]string{ + "access_token": accessToken, + "openid": openID, + }) + + var data AuthorizeUserInfo + _, err := we.client.R().SetSuccessResult(&data).SetQueryParams(params).Get("/sns/userinfo") + if err != nil { + return nil, errors.Wrap(err, "call /sns/userinfo failed") + } + + return &data, nil +} diff --git a/backend/providers/wechat/wechat_test.go b/backend/providers/wechat/wechat_test.go new file mode 100644 index 0000000..73afae4 --- /dev/null +++ b/backend/providers/wechat/wechat_test.go @@ -0,0 +1,86 @@ +package wechat + +import ( + "testing" + + log "github.com/sirupsen/logrus" + . "github.com/smartystreets/goconvey/convey" +) + +const ( + WechatAppID = "wx45745a8c51091ae0" + WechatAppSecret = "2ab33bc79d9b47efa4abef19d66e1977" + WechatToken = "W8Xhw5TivYBgY" + WechatAesKey = "F6AqCxAV4W1eCrY6llJ2zapphKK49CQN3RgtPDrjhnI" +) + +func init() { + log.SetLevel(log.DebugLevel) +} + +func getClient() *Client { + return New( + WithAppID(WechatAppID), + WithAppSecret(WechatAppSecret), + WithAESKey(WechatAesKey), + WithToken(WechatToken), + WithClient(DefaultClient.DevMode()), + ) +} + +func TestWechatClient_GetAccessToken(t *testing.T) { + Convey("Test GetAccessToken", t, func() { + token, err := getClient().GetAccessToken() + So(err, ShouldBeNil) + So(token.AccessToken, ShouldNotBeEmpty) + So(token.ExpiresIn, ShouldBeGreaterThan, 0) + + t.Log("Access Token:", token.AccessToken) + }) +} + +func TestClient_ScopeAuthorizeURL(t *testing.T) { + Convey("Test ScopeAuthorizeURL", t, func() { + url, err := getClient().ScopeAuthorizeURL( + ScopeAuthorizeURLWithScope(ScopeBase), + ScopeAuthorizeURLWithRedirectURI("https://qvyun.mp.jdwan.com/"), + ) + So(err, ShouldBeNil) + So(url, ShouldNotBeEmpty) + t.Log("URL:", url) + }) +} + +func TestClient_AuthorizeCode2Token(t *testing.T) { + code := "011W1sll2Xv4Ae4OjUnl2I7jvd2W1slX" + + Convey("Test AuthorizeCode2Token", t, func() { + token, err := getClient().AuthorizeCode2Token(code) + So(err, ShouldBeNil) + + t.Logf("token: %+v", token) + }) +} + +func TestClient_AuthorizeRefreshAccessToken(t *testing.T) { + token := "86_m_EAHq0RKlo6RzzGAsY8gVmiCqHqIiAJufxhm8mK8imyIW6yoE4NTcIr2vaukp7dexPWId0JWP1iZWYaLpXT_MJv1N7YQW8Qt3zOZDpJY90" + + Convey("Test AuthorizeCode2Token", t, func() { + token, err := getClient().AuthorizeRefreshAccessToken(token) + So(err, ShouldBeNil) + + t.Logf("token: %+v", token) + }) +} + +func TestClient_AuthorizeUserInfo(t *testing.T) { + token := "86_ZxJa8mIwbml5mDlHHbIUle_UKW8LA75nOuB0wqiome8AX5LlMWU8JwRKMZykdLEjDnKX8EJavz5GeQn3T1ot7TwpULp8imQvNIgFIjC4er8" + openID := "oMLa5tyJ2vRHa-HI4CMEkHztq3eU" + + Convey("Test AuthorizeUserInfo", t, func() { + user, err := getClient().AuthorizeUserInfo(token, openID) + So(err, ShouldBeNil) + + t.Logf("user: %+v", user) + }) +}