diff --git a/.gitignore b/.gitignore index b903c4b1..d85afc98 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ _cgo_export.* _testmain.go *.exe +*.exe~ mindoc database *.test diff --git a/README.md b/README.md index cc653a57..245ca89f 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ go build -ldflags "-w" ./mindoc install # 执行 ./mindoc +# 开发阶段运行 +bee run ``` MinDoc 如果使用MySQL储存数据,则编码必须是`utf8mb4_general_ci`。请在安装前,把数据库配置填充到项目目录下的 `conf/app.conf` 中。 diff --git a/commands/command.go b/commands/command.go index a3a42e55..1691371f 100644 --- a/commands/command.go +++ b/commands/command.go @@ -11,6 +11,7 @@ import ( "strconv" "strings" "time" + _ "time/tzdata" "bytes" "encoding/json" @@ -110,6 +111,7 @@ func RegisterModel() { new(models.TeamMember), new(models.TeamRelationship), new(models.Itemsets), + new(models.WorkWeixinAccount), ) gob.Register(models.Blog{}) gob.Register(models.Document{}) diff --git a/conf/app.conf.example b/conf/app.conf.example index 6dccf6f1..70e70018 100644 --- a/conf/app.conf.example +++ b/conf/app.conf.example @@ -231,7 +231,19 @@ dingtalk_qr_key="${MINDOC_DINGTALK_QRKEY}" # 钉钉扫码登录Secret dingtalk_qr_secret="${MINDOC_DINGTALK_QRSECRET}" +########企业微信登录配置############## + +# 企业ID +workweixin_corpid="${MINDOC_WORKWEIXIN_CORPID}" + +# 应用ID +workweixin_agentid="${MINDOC_WORKWEIXIN_AGENTID}" + +# 应用密钥 +workweixin_secret="${MINDOC_WORKWEIXIN_SECRET}" + +# 通讯录密钥 +workweixin_contact_secret="${MINDOC_WORKWEIXIN_CONTACT_SECRET}" + # i18n config default_lang="zh-cn" - - diff --git a/conf/workweixin.go b/conf/workweixin.go new file mode 100644 index 00000000..bae128f7 --- /dev/null +++ b/conf/workweixin.go @@ -0,0 +1,27 @@ +package conf + +import ( + "github.com/beego/beego/v2/server/web" +) + +type WorkWeixinConf struct { + CorpId string // 企业ID + AgentId string // 应用ID + Secret string // 应用密钥 + ContactSecret string // 通讯录密钥 +} + +func GetWorkWeixinConfig() *WorkWeixinConf { + corpid, _ := web.AppConfig.String("workweixin_corpid") + agentid, _ := web.AppConfig.String("workweixin_agentid") + secret, _ := web.AppConfig.String("workweixin_secret") + contact_secret, _ := web.AppConfig.String("workweixin_contact_secret") + + c := &WorkWeixinConf{ + CorpId: corpid, + AgentId: agentid, + Secret: secret, + ContactSecret: contact_secret, + } + return c +} diff --git a/controllers/AccountController.go b/controllers/AccountController.go index 04ef1d84..e270d399 100644 --- a/controllers/AccountController.go +++ b/controllers/AccountController.go @@ -1,24 +1,38 @@ package controllers import ( - "github.com/beego/i18n" + "encoding/json" + "fmt" + "html/template" + "math/rand" "net/url" + "reflect" "regexp" + "strconv" "strings" "time" - "html/template" - + "github.com/beego/beego/v2/client/orm" "github.com/beego/beego/v2/core/logs" "github.com/beego/beego/v2/server/web" + "github.com/beego/i18n" "github.com/lifei6671/gocaptcha" "github.com/mindoc-org/mindoc/conf" "github.com/mindoc-org/mindoc/mail" "github.com/mindoc-org/mindoc/models" "github.com/mindoc-org/mindoc/utils" "github.com/mindoc-org/mindoc/utils/dingtalk" + "github.com/mindoc-org/mindoc/utils/workweixin" ) +const ( + WorkWeixin_AuthorizeUrlBase = "https://open.weixin.qq.com/connect/oauth2/authorize" + WorkWeixin_QRConnectUrlBase = "https://open.work.weixin.qq.com/wwopen/sso/qrConnect" + SessionUserInfoKey = "session-user-info-key" +) + +var src = rand.New(rand.NewSource(time.Now().UnixNano())) + // AccountController 用户登录与注册 type AccountController struct { BaseController @@ -32,13 +46,24 @@ func (c *AccountController) referer() string { return u } +func (c *AccountController) IsInWorkWeixin() (is_in_workweixin bool) { + ua := c.Ctx.Input.UserAgent() + var wechatRule = regexp.MustCompile(`\bMicroMessenger\/\d+(\.\d+)*\b`) + var wxworkRule = regexp.MustCompile(`\bwxwork\/\d+(\.\d+)*\b`) + return wechatRule.MatchString(ua) && wxworkRule.MatchString(ua) +} + func (c *AccountController) Prepare() { c.BaseController.Prepare() c.EnableXSRF = web.AppConfig.DefaultBool("enablexsrf", true) c.Data["xsrfdata"] = template.HTML(c.XSRFFormHTML()) + + c.Data["CanLoginWorkWeixin"] = len(web.AppConfig.DefaultString("workweixin_corpid", "")) > 0 + c.Data["corpID"], _ = web.AppConfig.String("dingtalk_corpid") - if dtcorpid, _ := web.AppConfig.String("dingtalk_corpid"); dtcorpid != "" { + c.Data["CanLoginDingTalk"] = len(web.AppConfig.DefaultString("dingtalk_corpid", "")) > 0 + if reflect.ValueOf(c.Data["CanLoginDingTalk"]).Bool() { c.Data["ENABLE_QR_DINGTALK"] = true } c.Data["dingtalk_qr_key"], _ = web.AppConfig.String("dingtalk_qr_key") @@ -141,7 +166,40 @@ func (c *AccountController) Login() { c.JsonResult(500, i18n.Tr(c.Lang, "message.wrong_account_password"), nil) } } else { - c.Data["url"] = c.referer() + // 默认登录方式 + login_method := "AccountController.Login" + var redirect_uri string + // 企业微信登录检查 + canLoginWorkWeixin := reflect.ValueOf(c.Data["CanLoginWorkWeixin"]).Bool() + referer := c.referer() + if canLoginWorkWeixin { + // 企业微信登录方式 + login_method = "AccountController.WorkWeixinLogin" + u := c.GetString("url") + if u == "" { + u = referer + if u == "" { + u = conf.BaseUrl + } + } else { + var schemaRule = regexp.MustCompile(`^https?\:\/\/`) + if !schemaRule.MatchString(u) { + u = conf.BaseUrl + u + } + } + redirect_uri = conf.URLFor(login_method, "url", url.PathEscape(u)) + // 是否在企业微信内部打开 + isInWorkWeixin := c.IsInWorkWeixin() + c.Data["IsInWorkWeixin"] = isInWorkWeixin + if isInWorkWeixin { + // 客户端拥有微信标识和企业微信标识 + c.Redirect(redirect_uri, 302) + return + } else { + c.Data["workweixin_login_url"] = redirect_uri + } + } + c.Data["url"] = referer } } @@ -199,6 +257,417 @@ func (c *AccountController) DingTalkLogin() { c.JsonResult(0, "ok", username) } +// WorkWeixinLogin 用户企业微信登录 +func (c *AccountController) WorkWeixinLogin() { + c.Prepare() + + logs.Info("UserAgent: ", c.Ctx.Input.UserAgent()) // debug + + if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 { + u := c.GetString("url") + if u == "" { + u = c.Ctx.Request.Header.Get("Referer") + if u == "" { + u = conf.URLFor("HomeController.Index") + } + } + // session自动登录时刷新session内容 + member, err := models.NewMember().Find(member.MemberId) + if err != nil { + c.DelSession(conf.LoginSessionName) + c.SetMember(models.Member{}) + c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600) + } else { + c.SetMember(*member) + } + c.Redirect(u, 302) + } + var remember CookieRemember + // 如果 Cookie 中存在登录信息 + if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok { + if err := utils.Decode(cookie, &remember); err == nil { + if member, err := models.NewMember().Find(remember.MemberId); err == nil { + c.SetMember(*member) + c.LoggedIn(false) + c.StopRun() + } + } + } + + if c.Ctx.Input.IsPost() { + // account := c.GetString("account") + // password := c.GetString("password") + // captcha := c.GetString("code") + // isRemember := c.GetString("is_remember") + c.JsonResult(400, "request method not allowed", nil) + } else { + var callback_u string + u := c.GetString("url") + if u == "" { + u = c.referer() + } + if u != "" { + var schemaRule = regexp.MustCompile(`^https?\:\/\/`) + if !schemaRule.MatchString(u) { + u = strings.TrimRight(conf.BaseUrl, "/") + strings.TrimLeft(u, "/") + } + } + if u == "" { + callback_u = conf.URLFor("AccountController.WorkWeixinLoginCallback") + } else { + callback_u = conf.URLFor("AccountController.WorkWeixinLoginCallback", "url", url.PathEscape(u)) + } + logs.Info("callback_u: ", callback_u) // debug + + state := "mindoc" + workweixinConf := conf.GetWorkWeixinConfig() + appid := workweixinConf.CorpId + agentid := workweixinConf.AgentId + var redirect_uri string + + isInWorkWeixin := c.IsInWorkWeixin() + c.Data["IsInWorkWeixin"] = isInWorkWeixin + if isInWorkWeixin { + // 企业微信内-网页授权登录 + urlFmt := "%s?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_base&state=%s#wechat_redirect" + redirect_uri = fmt.Sprintf(urlFmt, WorkWeixin_AuthorizeUrlBase, appid, url.PathEscape(callback_u), state) + } else { + // 浏览器内-扫码授权登录 + urlFmt := "%s?appid=%s&agentid=%s&redirect_uri=%s&state=%s" + redirect_uri = fmt.Sprintf(urlFmt, WorkWeixin_QRConnectUrlBase, appid, agentid, url.PathEscape(callback_u), state) + } + logs.Info("redirect_uri: ", redirect_uri) // debug + c.Redirect(redirect_uri, 302) + } +} + +/* +思路: +1. 浏览器打开 + 用户名+密码 登录 与企业微信没有交集 + 手机企业微信登录->扫码页面->扫码后获取用户信息, 判断是否绑定了企业微信 + 已绑定,则读取用户信息,直接登录 + 未绑定,则弹窗提示[未绑定企业微信,请先在企业微信中打开,完成绑定] +2. 企业微信打开->自动登录->判断是否绑定了企业微信 + 已绑定,则读取用户信息,直接登录 + 未绑定,则弹窗提示 + 是否已有账户(用户名+密码方式) + 有: 弹窗输入[用户名+密码+验证码]校验 + 无: 直接以企业UserId作为用户名(小写),创建随机密码 +*/ + +// WorkWeixinLoginCallback 用户企业微信登录-回调 +func (c *AccountController) WorkWeixinLoginCallback() { + c.TplName = "account/workweixin-login-callback.tpl" + + if member, ok := c.GetSession(conf.LoginSessionName).(models.Member); ok && member.MemberId > 0 { + u := c.GetString("url") + if u == "" { + u = c.Ctx.Request.Header.Get("Referer") + } + if u == "" { + u = conf.URLFor("HomeController.Index") + } + member, err := models.NewMember().Find(member.MemberId) + if err != nil { + c.DelSession(conf.LoginSessionName) + c.SetMember(models.Member{}) + c.SetSecureCookie(conf.GetAppKey(), "login", "", -3600) + } else { + c.SetMember(*member) + } + c.Redirect(u, 302) + } + + var remember CookieRemember + // 如果 Cookie 中存在登录信息 + if cookie, ok := c.GetSecureCookie(conf.GetAppKey(), "login"); ok { + if err := utils.Decode(cookie, &remember); err == nil { + if member, err := models.NewMember().Find(remember.MemberId); err == nil { + c.SetMember(*member) + c.LoggedIn(false) + c.StopRun() + } + } + } + + // 请求参数获取 + req_code := c.GetString("code") + logs.Warning("req_code: ", req_code) + req_state := c.GetString("state") + logs.Warning("req_state: ", req_state) + var user_info_json string + var error_msg string + var bind_existed string + if len(req_code) > 0 && req_state == "mindoc" { + // 获取当前应用的access_token + access_token, ok := workweixin.GetAccessToken(false) + if ok { + logs.Warning("access_token: ", access_token) + // 获取当前请求的userid + user_id, ok := workweixin.RequestUserId(access_token, req_code) + if ok { + logs.Warning("user_id: ", user_id) + // 获取通讯录应用的access_token + contact_access_token, ok := workweixin.GetAccessToken(true) + if ok { + logs.Warning("contact_access_token: ", contact_access_token) + user_info, err_msg, ok := workweixin.RequestUserInfo(contact_access_token, user_id) + if ok { + // [-------所有字段-Debug---------- + // user_info.UserId + // user_info.Name + // user_info.HideMobile + // user_info.Mobile + // user_info.Department + // user_info.Email + // user_info.IsLeaderInDept + // user_info.IsLeader + // user_info.Avatar + // user_info.Alias + // user_info.Status + // user_info.MainDepartment + // -----------------------------] + // logs.Debug("user_info.UserId: ", user_info.UserId) + // logs.Debug("user_info.Name: ", user_info.Name) + json_info, _ := json.Marshal(user_info) + user_info_json = string(json_info) + // 查询系统现有数据,是否绑定了当前请求用户的企业微信 + member, err := models.NewWorkWeixinAccount().ExistedMember(user_info.UserId) + if err == nil { + member.LastLoginTime = time.Now() + _ = member.Update("last_login_time") + + c.SetMember(*member) + + var remember CookieRemember + remember.MemberId = member.MemberId + remember.Account = member.Account + remember.Time = time.Now() + v, err := utils.Encode(remember) + if err == nil { + c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix()) + } + bind_existed = "true" + error_msg = "" + u := c.GetString("url") + if u == "" { + u = conf.URLFor("HomeController.Index") + } + c.Redirect(u, 302) + } else { + if err == orm.ErrNoRows { + c.SetSession(SessionUserInfoKey, user_info) + bind_existed = "false" + error_msg = "" + } else { + logs.Error("Error: ", err) + error_msg = "数据库错误: " + err.Error() + } + } + // + } else { + error_msg = "获取用户信息失败: " + err_msg + } + } else { + error_msg = "通讯录访问凭据获取失败: " + contact_access_token + } + } else { + error_msg = "获取用户Id失败: " + user_id + } + } else { + error_msg = "应用凭据获取失败: " + access_token + } + } else { + error_msg = "参数错误" + } + if user_info_json == "" { + user_info_json = "{}" + } + if bind_existed == "" { + bind_existed = "null" + } + // refer & doc: + // - https://golang.org/pkg/html/template/#HTML + // - https://stackoverflow.com/questions/24411880/go-html-templates-can-i-stop-the-templates-package-inserting-quotes-around-stri + // - https://stackoverflow.com/questions/38035176/insert-javascript-snippet-inside-template-with-beego-golang + c.Data["bind_existed"] = template.JS(bind_existed) + logs.Debug("bind_existed: ", bind_existed) + c.Data["error_msg"] = template.JS(error_msg) + c.Data["user_info_json"] = template.JS(user_info_json) + /* + // 调试: 显示源码 + result, err := c.RenderString() + if err != nil { + logs.Error(err) + } else { + logs.Warning(result) + } + */ +} + +// WorkWeixinLoginBind 用户企业微信登录-绑定 +func (c *AccountController) WorkWeixinLoginBind() { + c.Prepare() + + if user_info, ok := c.GetSession(SessionUserInfoKey).(workweixin.WorkWeixinUserInfo); ok && len(user_info.UserId) > 0 { + req_account := c.GetString("account") + req_password := c.GetString("password") + if req_account == "" || req_password == "" { + c.JsonResult(400, "账号或密码不能为空") + } else { + member, err := models.NewMember().Login(req_account, req_password) + if err == nil { + account := models.NewWorkWeixinAccount() + account.MemberId = member.MemberId + account.WorkWeixin_UserId = user_info.UserId + member.CreateAt = 0 + ormer := orm.NewOrm() + o, err := ormer.Begin() + if err != nil { + logs.Error("开启事物时出错 -> ", err) + c.JsonResult(500, "开启事物时出错: ", err.Error()) + } + if err := account.AddBind(ormer); err != nil { + o.Rollback() + c.JsonResult(500, "绑定失败,数据库错误: "+err.Error()) + } else { + member.LastLoginTime = time.Now() + member.RealName = user_info.Name + member.Avatar = user_info.Avatar + if len(member.Avatar) < 1 { + member.Avatar = conf.GetDefaultAvatar() + } + member.Email = user_info.Email + member.Phone = user_info.Mobile + if _, err := ormer.Update(member, "last_login_time", "real_name", "avatar", "email", "phone"); err != nil { + o.Rollback() + logs.Error("保存用户信息失败=>", err) + c.JsonResult(500, "绑定失败,现有账户信息更新失败: "+err.Error()) + } else { + if err := o.Commit(); err != nil { + logs.Error("提交事物时出错 -> ", err) + c.JsonResult(500, "提交事物时出错: ", err.Error()) + } else { + c.DelSession(SessionUserInfoKey) + c.SetMember(*member) + + var remember CookieRemember + remember.MemberId = member.MemberId + remember.Account = member.Account + remember.Time = time.Now() + v, err := utils.Encode(remember) + if err == nil { + c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix()) + c.JsonResult(0, "绑定成功", nil) + } else { + c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil) + } + } + } + + } + + } else { + logs.Error("用户登录 ->", err) + c.JsonResult(500, "账号或密码错误", nil) + } + c.JsonResult(500, "TODO: 绑定以后账号功能开发中") + } + } else { + if ok { + c.DelSession(SessionUserInfoKey) + } + c.JsonResult(400, "请求错误, 请从首页重新登录") + } + +} + +// WorkWeixinLoginIgnore 用户企业微信登录-忽略 +func (c *AccountController) WorkWeixinLoginIgnore() { + if user_info, ok := c.GetSession(SessionUserInfoKey).(workweixin.WorkWeixinUserInfo); ok && len(user_info.UserId) > 0 { + c.DelSession(SessionUserInfoKey) + member := models.NewMember() + + if _, err := member.FindByAccount(user_info.UserId); err == nil && member.MemberId > 0 { + c.JsonResult(400, "账号已存在") + } + + ormer := orm.NewOrm() + o, err := ormer.Begin() + if err != nil { + logs.Error("开启事物时出错 -> ", err) + c.JsonResult(500, "开启事物时出错: ", err.Error()) + } + + member.Account = user_info.UserId + member.RealName = user_info.Name + var rnd = rand.New(src) + // fmt.Sprintf("%x", rnd.Uint64()) + // strconv.FormatUint(rnd.Uint64(), 16) + member.Password = user_info.UserId + strconv.FormatUint(rnd.Uint64(), 16) + member.Password = "pathea.2020" // 强制设置默认密码,不然无法修改密码(因为目前修改密码需要知道当前密码) + hash, err := utils.PasswordHash(member.Password) + if err != nil { + logs.Error("加密用户密码失败 =>", err) + c.JsonResult(500, "加密用户密码失败"+err.Error()) + } else { + logs.Error("member.Password: ", member.Password) + logs.Error("hash: ", hash) + member.Password = hash + } + member.Role = conf.MemberGeneralRole + member.Avatar = user_info.Avatar + if len(member.Avatar) < 1 { + member.Avatar = conf.GetDefaultAvatar() + } + member.CreateAt = 0 + member.Email = user_info.Email + member.Phone = user_info.Mobile + member.Status = 0 + if _, err = ormer.Insert(member); err != nil { + o.Rollback() + c.JsonResult(500, "注册失败,数据库错误: "+err.Error()) + } else { + account := models.NewWorkWeixinAccount() + account.MemberId = member.MemberId + account.WorkWeixin_UserId = user_info.UserId + member.CreateAt = 0 + if err := account.AddBind(ormer); err != nil { + o.Rollback() + c.JsonResult(500, "注册失败,数据库错误: "+err.Error()) + } else { + if err := o.Commit(); err != nil { + logs.Error("提交事物时出错 -> ", err) + c.JsonResult(500, "提交事物时出错: ", err.Error()) + } else { + member.LastLoginTime = time.Now() + _ = member.Update("last_login_time") + + c.SetMember(*member) + + var remember CookieRemember + remember.MemberId = member.MemberId + remember.Account = member.Account + remember.Time = time.Now() + v, err := utils.Encode(remember) + if err == nil { + c.SetSecureCookie(conf.GetAppKey(), "login", v, time.Now().Add(time.Hour*24*30*5).Unix()) + c.JsonResult(0, "绑定成功", nil) + } else { + c.JsonResult(500, "绑定成功, 但自动登录失败, 请返回首页重新登录", nil) + } + } + } + } + } else { + if ok { + c.DelSession(SessionUserInfoKey) + } + c.JsonResult(400, "请求错误, 请从首页重新登录") + } +} + // QR二维码登录 func (c *AccountController) QRLogin() { c.Prepare() @@ -266,6 +735,10 @@ func (c *AccountController) QRLogin() { } c.Redirect(conf.URLFor("AccountController.Login"), 302) + // 企业微信扫码登录 + case "workweixin": + // + default: c.Redirect(conf.URLFor("AccountController.Login"), 302) c.StopRun() diff --git a/models/WorkWeixinAccount.go b/models/WorkWeixinAccount.go new file mode 100644 index 00000000..8e3b04ce --- /dev/null +++ b/models/WorkWeixinAccount.go @@ -0,0 +1,74 @@ +// Package models . +package models + +import ( + "errors" + "time" + + "github.com/beego/beego/v2/client/orm" + "github.com/beego/beego/v2/core/logs" + "github.com/mindoc-org/mindoc/conf" +) + +type WorkWeixinAccount struct { + MemberId int `orm:"column(member_id);type(int);default(-1);index" json:"member_id"` + UserDbId int `orm:"pk;auto;unique;column(user_db_id)" json:"user_db_id"` + WorkWeixin_UserId string `orm:"size(100);unique;column(workweixin_user_id)" json:"workweixin_user_id"` + // WorkWeixin_Name string `orm:"size(255);column(workweixin_name)" json:"workweixin_name"` + // WorkWeixin_Phone string `orm:"size(25);column(workweixin_phone)" json:"workweixin_phone"` + // WorkWeixin_Email string `orm:"size(255);column(workweixin_email)" json:"workweixin_email"` + // WorkWeixin_Status int `orm:"type(int);column(status)" json:"status"` + // WorkWeixin_Avatar string `orm:"size(1024);column(avatar)" json:"avatar"` + CreateTime time.Time `orm:"type(datetime);column(create_time);auto_now_add" json:"create_time"` + CreateAt int `orm:"type(int);column(create_at)" json:"create_at"` + LastLoginTime time.Time `orm:"type(datetime);column(last_login_time);null" json:"last_login_time"` +} + +// TableName 获取对应数据库表名. +func (m *WorkWeixinAccount) TableName() string { + return "workweixin_accounts" +} + +// TableEngine 获取数据使用的引擎. +func (m *WorkWeixinAccount) TableEngine() string { + return "INNODB" +} + +func (m *WorkWeixinAccount) TableNameWithPrefix() string { + return conf.GetDatabasePrefix() + m.TableName() +} + +func NewWorkWeixinAccount() *WorkWeixinAccount { + return &WorkWeixinAccount{} +} + +func (a *WorkWeixinAccount) ExistedMember(workweixin_user_id string) (*Member, error) { + o := orm.NewOrm() + account := NewWorkWeixinAccount() + member := NewMember() + err := o.QueryTable(a.TableNameWithPrefix()).Filter("workweixin_user_id", workweixin_user_id).One(account) + if err == nil { + if member, err = member.Find(account.MemberId); err == nil { + return member, nil + } else { + return member, err + } + } else { + return member, err + } +} + +// Add 添加一个用户. +func (a *WorkWeixinAccount) AddBind(o orm.Ormer) error { + if c, err := o.QueryTable(a.TableNameWithPrefix()).Filter("member_id", a.MemberId).Count(); err == nil && c > 0 { + return errors.New("已绑定,不可重复绑定") + } + + _, err := o.Insert(a) + if err != nil { + logs.Error("保存用户数据到数据时失败 =>", err) + return errors.New("用户信息绑定失败, 数据库错误") + } + + return nil +} diff --git a/routers/router.go b/routers/router.go index d2eaf880..891cb15b 100644 --- a/routers/router.go +++ b/routers/router.go @@ -1,8 +1,8 @@ package routers import ( - "crypto/tls" - "log" + // "crypto/tls" + // "log" "net/http" "net/http/httputil" "net/url" @@ -11,65 +11,107 @@ import ( "github.com/beego/beego/v2/core/logs" "github.com/beego/beego/v2/server/web" "github.com/beego/beego/v2/server/web/context" - "github.com/mindoc-org/mindoc/conf" + // "github.com/mindoc-org/mindoc/conf" "github.com/mindoc-org/mindoc/controllers" ) -func rt(req *http.Request) (*http.Response, error) { - log.Printf("request received. url=%s", req.URL) - // req.Header.Set("Host", "httpbin.org") // <--- I set it here as well - defer log.Printf("request complete. url=%s", req.URL) - - return http.DefaultTransport.RoundTrip(req) -} - -// roundTripper makes func signature a http.RoundTripper -type roundTripper func(*http.Request) (*http.Response, error) - -func (f roundTripper) RoundTrip(req *http.Request) (*http.Response, error) { return f(req) } - type CorsTransport struct { http.RoundTripper } func (t *CorsTransport) RoundTrip(req *http.Request) (resp *http.Response, err error) { - // refer: - // - https://stackoverflow.com/questions/31535569/golang-how-to-read-response-body-of-reverseproxy/31536962#31536962 - // - https://gist.github.com/simon-cj/b4da0b2bca793ec3b8a5abe04c8fca41 + // refer: https://stackoverflow.com/questions/31535569/golang-how-to-read-response-body-of-reverseproxy/31536962#31536962 resp, err = t.RoundTripper.RoundTrip(req) - logs.Debug(resp) + // beego.Debug(resp) if err != nil { return nil, err } - resp.Header.Del("Access-Control-Request-Method") + /* + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + err = resp.Body.Close() + if err != nil { + return nil, err + } + b = bytes.Replace(b, []byte("server"), []byte("schmerver"), -1) + body := ioutil.NopCloser(bytes.NewReader(b)) + resp.Body = body + resp.ContentLength = int64(len(b)) + resp.Header.Set("Content-Length", strconv.Itoa(len(b))) + */ + // resp.Body.Close() + // resp.Header.Del("Access-Control-Request-Method") + // resp.Header.Del("Access-Control-Request-Headers") resp.Header.Set("Access-Control-Allow-Origin", "*") + resp.Header.Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE") + // resp.Header.Set("Access-Control-Allow-Headers", "Accept, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, X-Requested-With") + hs := "" + for name, values := range resp.Header { + hs = hs + name + ", " + _ = values + } + hs = strings.TrimRight(hs, " ") + hs = strings.TrimRight(hs, ",") + // beego.Debug(hs) + resp.Header.Set("Access-Control-Allow-Headers", hs) + resp.Header.Del("Mindoc-Version") + resp.Header.Del("Mindoc-Site") + resp.Header.Del("Server") + resp.Header.Del("X-Xss-Protection") return resp, nil } +func singleJoiningSlash(a, b string) string { + aslash := strings.HasSuffix(a, "/") + bslash := strings.HasPrefix(b, "/") + switch { + case aslash && bslash: + return a + b[1:] + case !aslash && !bslash: + return a + "/" + b + } + return a + b +} + func init() { + web.Any("/hello-any", func(ctx *context.Context) { + ctx.Output.Body([]byte("hello any demo")) + }) + web.Any("/cors-anywhere", func(ctx *context.Context) { u, _ := url.PathUnescape(ctx.Input.Query("url")) - logs.Error("ReverseProxy: ", u) if len(u) > 0 && strings.HasPrefix(u, "http") { - if strings.TrimRight(conf.BaseUrl, "/") == ctx.Input.Site() { - ctx.Redirect(302, u) + target, _ := url.Parse(u) + if target.Path == ctx.Request.URL.Path { + ctx.Output.Body([]byte("")) } else { - target, _ := url.Parse(u) - logs.Debug("target: ", target) + logs.Error("target: ", target) - proxy := &httputil.ReverseProxy{ - Transport: roundTripper(rt), - Director: func(req *http.Request) { - req.Header = ctx.Request.Header - req.URL.Scheme = target.Scheme - req.URL.Host = target.Host - req.URL.Path = target.Path - req.Header.Set("Host", target.Host) - }, + reverseProxy := httputil.NewSingleHostReverseProxy(target) + + reverseProxy.Director = func(req *http.Request) { + for name, values := range ctx.Request.Header { + for _, value := range values { + req.Header.Set(name, value) + } + } + req.Header.Add("X-Forwarded-Host", req.Host) + req.Header.Add("X-Origin-Host", target.Host) + req.URL.Scheme = target.Scheme + req.URL.Host = target.Host + + // proxyPath := singleJoiningSlash(target.Path, req.URL.Path) + proxyPath := target.Path + if strings.HasSuffix(proxyPath, "/") && len(proxyPath) > 1 { + proxyPath = proxyPath[:len(proxyPath)-1] + } + req.URL.Path = proxyPath } - - http.DefaultTransport.(*http.Transport).TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - proxy.ServeHTTP(ctx.ResponseWriter, ctx.Request) + reverseProxy.Transport = &CorsTransport{http.DefaultTransport} + reverseProxy.ServeHTTP(ctx.ResponseWriter, ctx.Request) + panic(web.ErrAbort) } } else { ctx.ResponseWriter.WriteHeader(http.StatusBadRequest) @@ -81,6 +123,10 @@ func init() { web.Router("/login", &controllers.AccountController{}, "*:Login") web.Router("/dingtalk_login", &controllers.AccountController{}, "*:DingTalkLogin") + web.Router("/workweixin-login", &controllers.AccountController{}, "*:WorkWeixinLogin") + web.Router("/workweixin-callback", &controllers.AccountController{}, "*:WorkWeixinLoginCallback") + web.Router("/workweixin-bind", &controllers.AccountController{}, "*:WorkWeixinLoginBind") + web.Router("/workweixin-ignore", &controllers.AccountController{}, "*:WorkWeixinLoginIgnore") web.Router("/qrlogin/:app", &controllers.AccountController{}, "*:QRLogin") web.Router("/logout", &controllers.AccountController{}, "*:Logout") web.Router("/register", &controllers.AccountController{}, "*:Register") diff --git a/utils/workweixin/workweixin.go b/utils/workweixin/workweixin.go new file mode 100644 index 00000000..72ea0e8a --- /dev/null +++ b/utils/workweixin/workweixin.go @@ -0,0 +1,222 @@ +package workweixin + +import ( + "context" + "crypto/tls" + // "encoding/json" + "net/http" + "time" + + "github.com/beego/beego/v2/client/httplib" + "github.com/beego/beego/v2/core/logs" + "github.com/mindoc-org/mindoc/cache" + "github.com/mindoc-org/mindoc/conf" +) + +// doc +// - 全局错误码: https://work.weixin.qq.com/api/doc/90000/90139/90313 + +const ( + AccessTokenCacheKey = "access-token-cache-key" + ContactAccessTokenCacheKey = "contact-access-token-cache-key" +) + +// 获取访问凭据-请求响应结构 +type AccessTokenResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` // 获取到的凭证,最长为512字节 + ExpiresIn int `json:"expires_in"` // 凭证的有效时间(秒) +} + +// 获取用户Id-请求响应结构 +type UserIdResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + UserId string `json:"UserId"` // 企业成员UserID + OpenId string `json:"OpenId"` // 非企业成员的标识,对当前企业唯一 + DeviceId string `json:"DeviceId"` // 设备号 +} + +// 获取用户信息-请求响应结构 +type UserInfoResponse struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + UserId string `json:"UserId"` // 企业成员UserID + Name string `json:"name"` // 成员名称 + HideMobile int `json:"hide_mobile"` // 是否隐藏了手机号码 + Mobile string `json:"mobile"` // 手机号码 + Department []int `json:"department"` // 成员所属部门id列表 + Email string `json:"email"` // 邮箱 + IsLeaderInDept []int `json:"is_leader_in_dept"` // 表示在所在的部门内是否为上级 + IsLeader int `json:"isleader"` // 是否是部门上级(领导) + Avatar string `json:"avatar"` // 头像url + Alias string `json:"alias"` // 别名 + Status int `json:"status"` // 激活状态: 1=已激活,2=已禁用,4=未激活,5=退出企业 + MainDepartment int `json:"main_department"` // 主部门 +} + +// 访问凭据缓存-结构 +type AccessTokenCache struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + UpdateTime time.Time `json:"update_time"` +} + +// 企业微信用户信息-结构 +type WorkWeixinUserInfo struct { + UserId string `json:"UserId"` // 企业成员UserID + Name string `json:"name"` // 成员名称 + HideMobile int `json:"hide_mobile"` // 是否隐藏了手机号码 + Mobile string `json:"mobile"` // 手机号码 + Department []int `json:"department"` // 成员所属部门id列表 + Email string `json:"email"` // 邮箱 + IsLeaderInDept []int `json:"is_leader_in_dept"` // 表示在所在的部门内是否为上级 + IsLeader int `json:"isleader"` // 是否是部门上级(领导) + Avatar string `json:"avatar"` // 头像url + Alias string `json:"alias"` // 别名 + Status int `json:"status"` // 激活状态: 1=已激活,2=已禁用,4=未激活,5=退出企业 + MainDepartment int `json:"main_department"` // 主部门 +} + +func httpFilter(next httplib.Filter) httplib.Filter { + return func(ctx context.Context, req *httplib.BeegoHTTPRequest) (*http.Response, error) { + r := req.GetRequest() + logs.Info("filter-url: ", r.URL) + // Never forget invoke this. Or the request will not be sent + return next(ctx, req) + } +} + +// 获取访问凭据-请求 +func RequestAccessToken(corpid string, secret string) (cache_token AccessTokenCache, ok bool) { + url := "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + req := httplib.Get(url) + req.Param("corpid", corpid) // 企业ID + req.Param("corpsecret", secret) // 应用的凭证密钥 + req.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: false}) + req.AddFilters(httpFilter) + resp, err := req.Response() + _ = resp + var token AccessTokenCache + if err != nil { + logs.Error(err) + return token, false + } + var atr AccessTokenResponse + err = req.ToJSON(&atr) + if err != nil { + logs.Error(err) + return token, false + } + token = AccessTokenCache{ + AccessToken: atr.AccessToken, + ExpiresIn: atr.ExpiresIn, + UpdateTime: time.Now(), + } + return token, true +} + +// 获取访问凭据 +func GetAccessToken(is_contact bool) (access_token string, ok bool) { + var cache_token AccessTokenCache + cache_key := AccessTokenCacheKey + if is_contact { + cache_key = ContactAccessTokenCacheKey + } + err := cache.Get(cache_key, &cache_token) + if err == nil { + logs.Info("AccessToken从缓存读取成功") + // TODO: access_token有效期判断, 刷新 + return cache_token.AccessToken, true + } else { + logs.Warning(err) + workweixinConfig := conf.GetWorkWeixinConfig() + logs.Debug("corp_id: ", workweixinConfig.CorpId) + logs.Debug("agent_id: ", workweixinConfig.AgentId) + logs.Debug("secret: ", workweixinConfig.Secret) + logs.Debug("contact_secret: ", workweixinConfig.ContactSecret) + secret := workweixinConfig.Secret + if is_contact { + secret = workweixinConfig.ContactSecret + } + new_token, ok := RequestAccessToken(workweixinConfig.CorpId, secret) + if ok { + logs.Debug(new_token) + if err = cache.Put(cache_key, new_token, time.Second*time.Duration(new_token.ExpiresIn)); err == nil { + logs.Info("AccessToken缓存写入成功") + return new_token.AccessToken, true + } + logs.Warning("AccessToken缓存写入失败") + return "", false + } + logs.Warning("AccessToken请求失败") + return "", false + } +} + +// 获取用户id-请求 +func RequestUserId(access_token string, code string) (user_id string, ok bool) { + url := "https://qyapi.weixin.qq.com/cgi-bin/user/getuserinfo" + req := httplib.Get(url) + req.Param("access_token", access_token) // 应用调用接口凭证 + req.Param("code", code) // 通过成员授权获取到的code + req.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: false}) + req.AddFilters(httpFilter) + resp, err := req.Response() + _ = resp + if err != nil { + logs.Error(err) + return "", false + } + var uir UserIdResponse + err = req.ToJSON(&uir) + if err != nil { + logs.Error(err) + return "", false + } + return uir.UserId, true +} + +// 获取用户详细信息-请求 +func RequestUserInfo(contact_access_token string, userid string) (user_info WorkWeixinUserInfo, error_msg string, ok bool) { + url := "https://qyapi.weixin.qq.com/cgi-bin/user/get" + req := httplib.Get(url) + req.Param("access_token", contact_access_token) // 通讯录应用调用接口凭证 + req.Param("userid", userid) // 成员UserID + req.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: false}) + req.AddFilters(httpFilter) + resp_str, err := req.String() + _ = resp_str + var info WorkWeixinUserInfo + if err != nil { + logs.Error(err) + return info, "请求失败", false + } else { + logs.Debug(resp_str) + } + var uir UserInfoResponse + err = req.ToJSON(&uir) + if err != nil { + logs.Error(err) + return info, "请求数据结果错误", false + } + if uir.ErrCode != 0 { + return info, uir.ErrMsg, false + } + info = WorkWeixinUserInfo{ + UserId: uir.UserId, + Name: uir.Name, + HideMobile: uir.HideMobile, + Mobile: uir.Mobile, + Department: uir.Department, + Email: uir.Email, + IsLeaderInDept: uir.IsLeaderInDept, + IsLeader: uir.IsLeader, + Avatar: uir.Avatar, + Alias: uir.Alias, + Status: uir.Status, + MainDepartment: uir.MainDepartment, + } + return info, "", true +} diff --git a/views/account/login.tpl b/views/account/login.tpl index 990e8623..1f090988 100644 --- a/views/account/login.tpl +++ b/views/account/login.tpl @@ -14,6 +14,23 @@ + {{if .CanLoginWorkWeixin}} + + {{end}} @@ -82,6 +99,13 @@ {{end}} {{end}} + {{if .CanLoginWorkWeixin}} +
+
+ 手机企业微信-扫码登录 +
+
+ {{end}}