diff --git a/backend/app/http/auth.go b/backend/app/http/auth.go new file mode 100644 index 0000000..12be18f --- /dev/null +++ b/backend/app/http/auth.go @@ -0,0 +1,112 @@ +package http + +import ( + "fmt" + "net/url" + "time" + + "quyun/app/models" + "quyun/database/fields" + "quyun/database/schemas/public/model" + "quyun/providers/jwt" + "quyun/providers/wechat" + + "github.com/go-jet/jet/v2/qrm" + "github.com/gofiber/fiber/v3" + gonanoid "github.com/matoous/go-nanoid/v2" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" +) + +const ( + StatePrefix = "sns_basic_auth" + salt = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" +) + +// @provider +type auth struct { + wechat *wechat.Client + jwt *jwt.JWT +} + +// @Router /auth/login [get] +// @Bind code query +// @Bind state query +// @Bind redirect query +func (ctl *auth) Login(ctx fiber.Ctx, code, state, redirect string) error { + log.Debugf("code: %s, state: %s", code, state) + + // get the openid + token, err := ctl.wechat.AuthorizeCode2Token(code) + if err != nil { + return errors.Wrap(err, "failed to get openid") + } + log.Debugf("tokenInfo %+v", token) + + authUserInfo, err := ctl.wechat.AuthorizeUserInfo(token.AccessToken, token.Openid) + if err != nil { + return errors.Wrap(err, "failed to get user info") + } + + log.Debugf("Auth User Info: %+v", authUserInfo) + + user, err := models.Users.GetUserByOpenID(ctx.Context(), token.Openid) + if err != nil { + if errors.Is(err, qrm.ErrNoRows) { + // Create User + model := &model.Users{ + Status: fields.UserStatusOk, + OpenID: token.GetOpenID(), + Username: fmt.Sprintf("u_%s", gonanoid.MustGenerate(salt, 8)), + Avatar: nil, + } + if err := models.Users.Create(ctx.Context(), model); err != nil { + return errors.Wrap(err, "failed to create user") + } + } else { + return errors.Wrap(err, "failed to get user") + } + } + + jwtToken, err := ctl.jwt.CreateToken(ctl.jwt.CreateClaims(jwt.BaseClaims{UserID: user.ID})) + if err != nil { + return errors.Wrap(err, "failed to create token") + } + + ctx.Cookie(&fiber.Cookie{ + Name: "token", + Value: jwtToken, + Expires: time.Now().Add(6 * time.Hour), + HTTPOnly: true, + }) + + return ctx.Redirect().To(redirect) +} + +// @Router /auth/wechat [get] +// @Bind redirect query +func (ctl *auth) Wechat(ctx fiber.Ctx, redirect string) error { + log.Debugf("%s, query: %v", ctx.OriginalURL(), ctx.Queries()) + + // 添加 redirect 参数 + u, err := url.Parse(string(ctx.Request().URI().FullURI())) + if err != nil { + return err + } + query := u.Query() + query.Set("redirect", redirect) + u.RawQuery = query.Encode() + u.Path = "/auth/login" + fullUrl := u.String() + + log.Debug("redirect_uri: ", fullUrl) + + to, err := ctl.wechat.ScopeAuthorizeURL( + wechat.ScopeAuthorizeURLWithRedirectURI(fullUrl), + ) + if err != nil { + return errors.Wrap(err, "failed to get wechat auth url") + } + + return ctx.Redirect().To(to.String()) +} diff --git a/backend/app/http/pays.go b/backend/app/http/pays.go index 32a3476..f853797 100644 --- a/backend/app/http/pays.go +++ b/backend/app/http/pays.go @@ -15,6 +15,7 @@ import ( log "github.com/sirupsen/logrus" ) +// @provider type pays struct { wepay *wepay.Client job *job.Job diff --git a/backend/app/http/posts.go b/backend/app/http/posts.go index e06e1fe..ab413b1 100644 --- a/backend/app/http/posts.go +++ b/backend/app/http/posts.go @@ -27,7 +27,8 @@ type posts struct { // @Router /posts [get] // @Bind pagination query // @Bind query query -func (ctl *posts) List(ctx fiber.Ctx, pagination *requests.Pagination, query *ListQuery) (*requests.Pager, error) { +// @Bind user local +func (ctl *posts) List(ctx fiber.Ctx, pagination *requests.Pagination, query *ListQuery, user *model.Users) (*requests.Pager, error) { cond := models.Posts.BuildConditionWithKey(query.Keyword) return models.Posts.List(ctx.Context(), pagination, cond) } diff --git a/backend/app/http/provider.gen.go b/backend/app/http/provider.gen.go index 3ac7a60..118fcc4 100755 --- a/backend/app/http/provider.gen.go +++ b/backend/app/http/provider.gen.go @@ -1,6 +1,9 @@ package http import ( + "quyun/providers/job" + "quyun/providers/jwt" + "quyun/providers/wechat" "quyun/providers/wepay" "go.ipao.vip/atom" @@ -10,6 +13,32 @@ import ( ) func Provide(opts ...opt.Option) error { + if err := container.Container.Provide(func( + jwt *jwt.JWT, + wechat *wechat.Client, + ) (*auth, error) { + obj := &auth{ + jwt: jwt, + wechat: wechat, + } + + return obj, nil + }); err != nil { + return err + } + if err := container.Container.Provide(func( + job *job.Job, + wepay *wepay.Client, + ) (*pays, error) { + obj := &pays{ + job: job, + wepay: wepay, + } + + return obj, nil + }); err != nil { + return err + } if err := container.Container.Provide(func( wepay *wepay.Client, ) (*posts, error) { @@ -22,10 +51,12 @@ func Provide(opts ...opt.Option) error { return err } if err := container.Container.Provide(func( + auth *auth, pays *pays, posts *posts, ) (contracts.HttpRoute, error) { obj := &Routes{ + auth: auth, pays: pays, posts: posts, } diff --git a/backend/app/http/routes.gen.go b/backend/app/http/routes.gen.go index 0207fde..0a81761 100644 --- a/backend/app/http/routes.gen.go +++ b/backend/app/http/routes.gen.go @@ -9,11 +9,13 @@ import ( _ "go.ipao.vip/atom/contracts" . "go.ipao.vip/atom/fen" "quyun/app/requests" + "quyun/database/schemas/public/model" ) // @provider contracts.HttpRoute atom.GroupRoutes type Routes struct { log *log.Entry `inject:"false"` + auth *auth pays *pays posts *posts } @@ -28,6 +30,19 @@ func (r *Routes) Name() string { } func (r *Routes) Register(router fiber.Router) { + // 注册路由组: auth + router.Get("/auth/login", Func3( + r.auth.Login, + QueryParam[string]("code"), + QueryParam[string]("state"), + QueryParam[string]("redirect"), + )) + + router.Get("/auth/wechat", Func1( + r.auth.Wechat, + QueryParam[string]("redirect"), + )) + // 注册路由组: pays router.Get("/pay/callback/:channel", Func1( r.pays.Callback, @@ -35,10 +50,11 @@ func (r *Routes) Register(router fiber.Router) { )) // 注册路由组: posts - router.Get("/posts", DataFunc2( + router.Get("/posts", DataFunc3( r.posts.List, Query[requests.Pagination]("pagination"), Query[ListQuery]("query"), + Local[*model.Users]("user"), )) router.Get("/show/:id", DataFunc1( diff --git a/backend/app/middlewares/mid_auth.go b/backend/app/middlewares/mid_auth.go new file mode 100644 index 0000000..3047f25 --- /dev/null +++ b/backend/app/middlewares/mid_auth.go @@ -0,0 +1,57 @@ +package middlewares + +import ( + "net/url" + "strings" + + "quyun/app/models" + + "github.com/gofiber/fiber/v3" + "github.com/gofiber/fiber/v3/log" +) + +func (f *Middlewares) Auth(ctx fiber.Ctx) error { + if strings.HasPrefix(ctx.Path(), "/admin/") { + return ctx.Next() + } + + if strings.HasPrefix(ctx.Path(), "/auth/") { + return ctx.Next() + } + + fullUrl := string(ctx.Request().URI().FullURI()) + u, err := url.Parse(fullUrl) + if err != nil { + return err + } + query := u.Query() + query.Set("redirect", fullUrl) + u.RawQuery = query.Encode() + u.Path = "/auth/wechat" + fullUrl = u.String() + + // check cookie exists + cookie := ctx.Cookies("token") + log.Infof("cookie: %s", cookie) + if cookie == "" { + log.Infof("auth redirect_uri: %s", fullUrl) + return ctx.Redirect().To(fullUrl) + } + + jwt, err := f.jwt.Parse(cookie) + if err != nil { + // remove cookie + ctx.ClearCookie("token") + return ctx.Redirect().To(fullUrl) + } + + user, err := models.Users.GetByID(ctx.Context(), jwt.UserID) + if err != nil { + // remove cookie + ctx.ClearCookie("token") + return ctx.Redirect().To(fullUrl) + } + ctx.Locals("user", user) + + return ctx.Next() +} diff --git a/backend/app/middlewares/mid_debug.go b/backend/app/middlewares/mid_debug.go index ecb33af..e86c3f7 100644 --- a/backend/app/middlewares/mid_debug.go +++ b/backend/app/middlewares/mid_debug.go @@ -2,8 +2,13 @@ package middlewares import ( "github.com/gofiber/fiber/v3" + log "github.com/sirupsen/logrus" ) -func (f *Middlewares) DebugMode(c fiber.Ctx) error { - return c.Next() +func (f *Middlewares) DebugMode(ctx fiber.Ctx) error { + log.Infof("c.Path: %s", ctx.Path()) + log.Infof("Request Method: %s", ctx.Method()) + log.Infof("FullURL: %s", ctx.Request().URI().FullURI()) + + return ctx.Next() } diff --git a/backend/app/middlewares/middlewares.go b/backend/app/middlewares/middlewares.go index 69e0e4c..f74cda0 100644 --- a/backend/app/middlewares/middlewares.go +++ b/backend/app/middlewares/middlewares.go @@ -1,12 +1,15 @@ package middlewares import ( + "quyun/providers/jwt" + log "github.com/sirupsen/logrus" ) // @provider type Middlewares struct { log *log.Entry `inject:"false"` + jwt *jwt.JWT } func (f *Middlewares) Prepare() error { diff --git a/backend/app/middlewares/provider.gen.go b/backend/app/middlewares/provider.gen.go index f84d36c..ab27e7c 100755 --- a/backend/app/middlewares/provider.gen.go +++ b/backend/app/middlewares/provider.gen.go @@ -1,13 +1,19 @@ package middlewares import ( + "quyun/providers/jwt" + "go.ipao.vip/atom/container" "go.ipao.vip/atom/opt" ) func Provide(opts ...opt.Option) error { - if err := container.Container.Provide(func() (*Middlewares, error) { - obj := &Middlewares{} + if err := container.Container.Provide(func( + jwt *jwt.JWT, + ) (*Middlewares, error) { + obj := &Middlewares{ + jwt: jwt, + } if err := obj.Prepare(); err != nil { return nil, err } diff --git a/backend/app/models/users.go b/backend/app/models/users.go index c09123a..82982df 100644 --- a/backend/app/models/users.go +++ b/backend/app/models/users.go @@ -227,3 +227,23 @@ func (m *usersModel) PostList(ctx context.Context, userId int64, pagination *req Pagination: *pagination, }, nil } + +// GetUserIDByOpenID +func (m *usersModel) GetUserByOpenID(ctx context.Context, openID string) (*model.Users, error) { + tbl := table.Users + + stmt := tbl. + SELECT(tbl.AllColumns). + WHERE( + tbl.OpenID.EQ(String(openID)), + ) + m.log.Infof("sql: %s", stmt.DebugSql()) + + var user model.Users + if err := stmt.QueryContext(ctx, db, &user); err != nil { + m.log.Errorf("error querying user by OpenID: %v", err) + return nil, err + } + + return &user, nil +} diff --git a/backend/app/service/http/http.go b/backend/app/service/http/http.go index 518f603..a080d09 100644 --- a/backend/app/service/http/http.go +++ b/backend/app/service/http/http.go @@ -6,6 +6,7 @@ import ( "quyun/app/errorx" appHttp "quyun/app/http" "quyun/app/jobs" + "quyun/app/middlewares" "quyun/app/models" "quyun/app/service" _ "quyun/docs" @@ -32,8 +33,8 @@ import ( func defaultProviders() container.Providers { return service.Default(container.Providers{ - wechat.DefaultProvider(), ali.DefaultProvider(), + wechat.DefaultProvider(), wepay.DefaultProvider(), http.DefaultProvider(), postgres.DefaultProvider(), @@ -53,6 +54,7 @@ func Command() atom.Option { With( jobs.Provide, models.Provide, + middlewares.Provide, ). WithProviders( appHttp.Providers(), @@ -66,10 +68,11 @@ type Service struct { Initials []contracts.Initial `group:"initials"` - App *app.Config - Job *job.Job - Http *http.Service - Routes []contracts.HttpRoute `group:"routes"` + App *app.Config + Job *job.Job + Http *http.Service + Middlewares *middlewares.Middlewares + Routes []contracts.HttpRoute `group:"routes"` } func Serve(cmd *cobra.Command, args []string) error { @@ -82,6 +85,9 @@ func Serve(cmd *cobra.Command, args []string) error { svc.Http.Engine.Get("/swagger/*", swagger.HandlerDefault) } svc.Http.Engine.Use(errorx.Middleware) + svc.Http.Engine.Use(svc.Middlewares.DebugMode) + svc.Http.Engine.Use(svc.Middlewares.Auth) + svc.Http.Engine.Use(favicon.New(favicon.Config{ Data: []byte{}, })) diff --git a/backend/config.toml b/backend/config.toml index 018d8d6..8312165 100644 --- a/backend/config.toml +++ b/backend/config.toml @@ -13,7 +13,7 @@ Password = "xixi0202" [JWT] ExpiresTime = "168h" -SigningKey = "Key" +SigningKey = "xixi@0202" [HashIDs] Salt = "Salt" @@ -37,11 +37,11 @@ AppID = "wx47649361b6eba174" AppSecret = "e9cdf19b006cd294a9dae7ad8ae08b72" Token = "W8Xhw5TivYBgY" EncodingAesKey = "OlgPgMvsl92zy5oErtEzRcziRT2txoN3jgEHV6RQZMY" -DevMode = true +DevMode = false [WeChat.Pay] NotifyURL="https://www.baidu.com/go.php" -MechID = "1702644947" +MchID = "1702644947" SerialNo = "4563EC584A35BC84FB27AA4100C934C9A91D59CA" MechName = "佳芃(北京)企业管理咨询有限公司" ApiV3Key="5UBDkxVDY44AKafkqN6YgYxgtkXP6Mw6" diff --git a/backend/database/migrations/20250322103119_create_users.sql b/backend/database/migrations/20250322103119_create_users.sql index c22e30c..2b6c9ca 100644 --- a/backend/database/migrations/20250322103119_create_users.sql +++ b/backend/database/migrations/20250322103119_create_users.sql @@ -8,7 +8,7 @@ CREATE TABLE users( status int2 NOT NULL DEFAULT 0, open_id varchar(128) NOT NULL UNIQUE, username varchar(128) NOT NULL, - avatar varchar(128) + avatar text ); -- +goose StatementEnd diff --git a/backend/go.mod b/backend/go.mod index b329388..a2a39e8 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -12,6 +12,7 @@ require ( github.com/aliyun/credentials-go v1.4.5 github.com/go-jet/jet/v2 v2.13.0 github.com/go-pay/gopay v1.5.110 + github.com/go-pay/util v0.0.4 github.com/gofiber/fiber/v3 v3.0.0-beta.4 github.com/gofiber/utils/v2 v2.0.0-beta.7 github.com/golang-jwt/jwt/v4 v4.5.1 @@ -86,7 +87,6 @@ require ( github.com/go-pay/crypto v0.0.1 // indirect github.com/go-pay/errgroup v0.0.3 // indirect github.com/go-pay/smap v0.0.2 // indirect - github.com/go-pay/util v0.0.4 // indirect github.com/go-pay/xlog v0.0.3 // indirect github.com/go-pay/xtime v0.0.2 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect diff --git a/backend/pkg/oauth/contracts.go b/backend/pkg/oauth/contracts.go new file mode 100644 index 0000000..85f5b99 --- /dev/null +++ b/backend/pkg/oauth/contracts.go @@ -0,0 +1,11 @@ +package oauth + +import "time" + +type OAuthInfo interface { + GetOpenID() string + GetUnionID() string + GetAccessToken() string + GetRefreshToken() string + GetExpiredAt() time.Time +} diff --git a/backend/pkg/oauth/wechat.go b/backend/pkg/oauth/wechat.go new file mode 100644 index 0000000..71e3c2a --- /dev/null +++ b/backend/pkg/oauth/wechat.go @@ -0,0 +1,39 @@ +package oauth + +import "time" + +var _ OAuthInfo = (*WechatOAuthInfo)(nil) + +type WechatOAuthInfo struct { + Scope string `json:"scope,omitempty"` + OpenID string `json:"openid,omitempty"` + UnionID string `json:"unionid,omitempty"` + AccessToken string `json:"access_token,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` +} + +// GetAccessToken implements OAuthInfo. +func (w *WechatOAuthInfo) GetAccessToken() string { + return w.AccessToken +} + +// GetExpiredAt implements OAuthInfo. +func (w *WechatOAuthInfo) GetExpiredAt() time.Time { + return time.Now().Add(time.Duration(w.ExpiresIn) * time.Second) +} + +// GetOpenID implements OAuthInfo. +func (w *WechatOAuthInfo) GetOpenID() string { + return w.OpenID +} + +// GetRefreshToken implements OAuthInfo. +func (w *WechatOAuthInfo) GetRefreshToken() string { + return w.RefreshToken +} + +// GetUnionID implements OAuthInfo. +func (w *WechatOAuthInfo) GetUnionID() string { + return w.UnionID +} diff --git a/backend/providers/wechat/config.go b/backend/providers/wechat/config.go index 55fa907..58691f4 100644 --- a/backend/providers/wechat/config.go +++ b/backend/providers/wechat/config.go @@ -5,18 +5,6 @@ import ( "go.ipao.vip/atom/opt" ) -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() (*Config, error) { - return &config, nil - }, o.DiOptions()...) -} - const DefaultPrefix = "WeChat" func DefaultProvider() container.ProviderContainer { @@ -28,6 +16,28 @@ func DefaultProvider() container.ProviderContainer { } } +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() (*Config, *Client, error) { + httpClient := DefaultClient + if config.DevMode { + httpClient = httpClient.DevMode() + } + return &config, New( + WithAppID(config.AppID), + WithAppSecret(config.AppSecret), + WithAESKey(config.EncodingAESKey), + WithToken(config.Token), + WithClient(httpClient), + ), nil + }, o.DiOptions()...) +} + type Config struct { AppID string AppSecret string diff --git a/backend/providers/wechat/errors.go b/backend/providers/wechat/errors.go new file mode 100644 index 0000000..e8da727 --- /dev/null +++ b/backend/providers/wechat/errors.go @@ -0,0 +1,59 @@ +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, msg string) error { + if errCode == 0 { + return nil + } + + errs := 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 := errs[errCode]; ok { + return err + } + return errors.New(msg) +} 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..68b0e01 --- /dev/null +++ b/backend/providers/wechat/options.go @@ -0,0 +1,76 @@ +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") + } +} + +func WithVerifySiteKeyPair(key, value string) Options { + return func(we *Client) { + we.verifyKey = key + we.verifyValue = value + } +} diff --git a/backend/providers/wechat/response.go b/backend/providers/wechat/response.go new file mode 100644 index 0000000..c6d6036 --- /dev/null +++ b/backend/providers/wechat/response.go @@ -0,0 +1,16 @@ +package wechat + +type ErrorResponse struct { + ErrCode int `json:"errcode,omitempty"` + ErrMsg string `json:"errmsg,omitempty"` +} + +func (r *ErrorResponse) Error() error { + return translateError(r.ErrCode, r.ErrMsg) +} + +type AccessTokenResponse struct { + ErrorResponse + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int `json:"expires_in,omitempty"` // seconds +} diff --git a/backend/providers/wechat/wechat.go b/backend/providers/wechat/wechat.go new file mode 100644 index 0000000..38a3496 --- /dev/null +++ b/backend/providers/wechat/wechat.go @@ -0,0 +1,245 @@ +package wechat + +import ( + "crypto/sha1" + "encoding/hex" + "net/url" + "sort" + "strings" + "time" + + "quyun/pkg/oauth" + + "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 + + verifyKey string + verifyValue string +} + +func New(options ...Options) *Client { + we := &Client{ + client: DefaultClient, + } + + for _, opt := range options { + opt(we) + } + + return we +} + +func (we *Client) VerifySite(key string) (string, error) { + if key == we.verifyKey { + return we.verifyValue, nil + } + return "", errors.New("verify failed") +} + +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 ErrorResponse + resp, 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") + } + + if data.ErrCode != 0 { + return nil, data.Error() + } + + var token AccessTokenResponse + if err := resp.Unmarshal(&token); err != nil { + return nil, errors.Wrap(err, "parse response failed") + } + + return &token, 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 +} + +var _ oauth.OAuthInfo = (*AuthorizeAccessToken)(nil) + +type AuthorizeAccessToken struct { + ErrorResponse + AccessToken string `json:"access_token,omitempty"` + ExpiresIn int64 `json:"expires_in,omitempty"` + IsSnapshotuser int64 `json:"is_snapshotuser,omitempty"` + Openid string `json:"openid,omitempty"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` + Unionid string `json:"unionid,omitempty"` +} + +// GetAccessToken implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetAccessToken() string { + return a.AccessToken +} + +// GetExpiredAt implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetExpiredAt() time.Time { + return time.Now().Add(time.Duration(a.ExpiresIn) * time.Second) +} + +// GetOpenID implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetOpenID() string { + return a.Openid +} + +// GetRefreshToken implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetRefreshToken() string { + return a.RefreshToken +} + +// GetUnionID implements oauth.OAuthInfo. +func (a *AuthorizeAccessToken) GetUnionID() string { + return a.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") + } + + if err := data.Error(); err != nil { + return nil, err + } + + 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") + } + + if err := data.Error(); err != nil { + return nil, err + } + + return &data, nil +} + +type AuthorizeUserInfo struct { + ErrorResponse + City string `json:"city,omitempty"` + Country string `json:"country,omitempty"` + Headimgurl string `json:"headimgurl,omitempty"` + Nickname string `json:"nickname,omitempty"` + Openid string `json:"openid,omitempty"` + Privilege []string `json:"privilege,omitempty"` + Province string `json:"province,omitempty"` + Sex int64 `json:"sex,omitempty"` + Unionid string `json:"unionid,omitempty"` +} + +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") + } + + if err := data.Error(); err != nil { + return nil, err + } + + 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) + }) +}