Bläddra i källkod

企业微信登录初步调通

gsw945 3 år sedan
förälder
incheckning
0671b0cd40

+ 1 - 0
.gitignore

@@ -20,6 +20,7 @@ _cgo_export.*
 _testmain.go
 
 *.exe
+*.exe~
 mindoc
 database
 *.test

+ 2 - 0
README.md

@@ -56,6 +56,8 @@ go build -ldflags "-w"
 ./mindoc install
 # 执行
 ./mindoc
+# 开发阶段运行
+bee run
 ```
 
 MinDoc 如果使用MySQL储存数据,则编码必须是`utf8mb4_general_ci`。请在安装前,把数据库配置填充到项目目录下的 `conf/app.conf` 中。

+ 2 - 0
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{})

+ 14 - 2
conf/app.conf.example

@@ -231,7 +231,19 @@ dingtalk_qr_key="${MINDOC_DINGTALK_QRKEY}"
 # 钉钉扫码登录Secret
 dingtalk_qr_secret="${MINDOC_DINGTALK_QRSECRET}"
 
-# i18n config
-default_lang="zh-cn"
+########企业微信登录配置##############
+
+# 企业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"

+ 27 - 0
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
+}

+ 478 - 5
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()

+ 74 - 0
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
+}

+ 85 - 39
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)
-
-				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)
-					},
+				logs.Error("target: ", target)
+
+				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")

+ 222 - 0
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
+}

+ 55 - 36
views/account/login.tpl

@@ -14,6 +14,23 @@
     <link href="{{cdncss "/static/bootstrap/css/bootstrap.min.css"}}" rel="stylesheet">
     <link href="{{cdncss "/static/font-awesome/css/font-awesome.min.css"}}" rel="stylesheet">
     <link href="{{cdncss "/static/css/main.css" "version"}}" rel="stylesheet">
+    {{if .CanLoginWorkWeixin}}
+    <style type="text/css">
+        #wxwork-login-line > a {
+            display: block;
+            text-align: center;
+            border: 1px solid #ccc;
+            border-radius: 0.3em;
+            padding-top: 0.8em;
+            padding-bottom: 0.75em;
+        }
+        #wxwork-login-line > a:hover {
+            color: #fff;
+            background-color: #5cb85c;
+            border-color: #4cae4c;
+        }
+    </style>
+    {{end}}
     <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
     <script src="{{cdnjs "/static/jquery/1.12.4/jquery.min.js"}}"></script>
 </head>
@@ -82,6 +99,13 @@
                 </div>
                 {{end}}
                 {{end}}
+                {{if .CanLoginWorkWeixin}}
+                <div class="form-group">
+                    <div id="wxwork-login-line">
+                        <a href="{{ .workweixin_login_url }}" title="手机企业微信-扫码登录">手机企业微信-扫码登录</a>
+                    </div>
+                </div>
+                {{end}}
             </form>
             <div class="form-group dingtalk-container" style="display: none;">
                 <div id="dingtalk-qr-container"></div>
@@ -98,6 +122,7 @@
 <script src="{{cdnjs "/static/js/dingtalk-jsapi.js"}}" type="text/javascript"></script>
 <script src="{{cdnjs "/static/js/dingtalk-ddlogin.js"}}" type="text/javascript"></script>
 
+{{if .ENABLE_QR_DINGTALK}}
 <script type="text/javascript">
     if (dd.env.platform !== "notInDingTalk"){
         dd.ready(function() {
@@ -135,37 +160,41 @@
         });
     }
 
-</script>
+    $(document).ready(function () {
+        var url = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid={{.dingtalk_qr_key}}&response_type=code&scope=snsapi_login&state=1&redirect_uri={{ urlfor "AccountController.QRLogin" ":app" "dingtalk"}}'
+        var obj = DDLogin({
+            id:"dingtalk-qr-container",
+            goto: encodeURIComponent(url), 
+            style: "border:none;background-color:#FFFFFF;",
+            width : "338",
+            height: "300"
+        });
+        $(window).on('message', function (event) {
+            var origin = event.origin;
+            if( origin == "https://login.dingtalk.com" ) { //判断是否来自ddLogin扫码事件。
+                layer.load(1, { shade: [0.1, '#fff'] })
+                var loginTmpCode = event.data; 
+                //获取到loginTmpCode后就可以在这里构造跳转链接进行跳转了
+                console.log("loginTmpCode", loginTmpCode);
+                url = url + "&loginTmpCode=" + loginTmpCode
+                window.location = url
+            }
+        });
+        $("#btn-dingtalk-qr").on('click', function(){
+            $('form').hide()
+            $(".dingtalk-container").show()
+        })
 
-<script type="text/javascript">
-    var url = 'https://oapi.dingtalk.com/connect/oauth2/sns_authorize?appid={{.dingtalk_qr_key}}&response_type=code&scope=snsapi_login&state=1&redirect_uri={{ urlfor "AccountController.QRLogin" ":app" "dingtalk"}}'
-    var obj = DDLogin({
-        id:"dingtalk-qr-container",
-        goto: encodeURIComponent(url), 
-        style: "border:none;background-color:#FFFFFF;",
-        width : "338",
-        height: "300"
+        $(".btn-dingtalk").on('click', function(){
+            $('form').show()
+            $(".dingtalk-container").hide()
+        })
     });
-    var handleMessage = function (event) {
-        var origin = event.origin;
-        if( origin == "https://login.dingtalk.com" ) { //判断是否来自ddLogin扫码事件。
-            layer.load(1, { shade: [0.1, '#fff'] })
-            var loginTmpCode = event.data; 
-            //获取到loginTmpCode后就可以在这里构造跳转链接进行跳转了
-            console.log("loginTmpCode", loginTmpCode);
-            url = url + "&loginTmpCode=" + loginTmpCode
-            window.location = url
-        }
-    };
-    if (typeof window.addEventListener != 'undefined') {
-        window.addEventListener('message', handleMessage, false);
-    } else if (typeof window.attachEvent != 'undefined') {
-        window.attachEvent('onmessage', handleMessage);
-    }
 </script>
+{{end}}
 
 <script type="text/javascript">
-    $(function () {
+    $(document).ready(function () {
         $("#account,#password,#code").on('focus', function () {
             $(this).tooltip('destroy').parents('.form-group').removeClass('has-error');
         });
@@ -177,16 +206,6 @@
             }
         });
 
-        $("#btn-dingtalk-qr").on('click', function(){
-            $('form').hide()
-            $(".dingtalk-container").show()
-        })
-
-        $(".btn-dingtalk").on('click', function(){
-            $('form').show()
-            $(".dingtalk-container").hide()
-        })
-
         $("#btn-login").on('click', function () {
             $(this).tooltip('destroy').parents('.form-group').removeClass('has-error');
             var $btn = $(this).button('loading');

+ 208 - 0
views/account/workweixin-login-callback.tpl

@@ -0,0 +1,208 @@
+<!DOCTYPE html>
+<html lang="zh-cn">
+<head>
+    <meta charset="utf-8">
+    <link rel="shortcut icon" href="{{cdnimg "/favicon.ico"}}">
+    <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1" />
+    <meta name="renderer" content="webkit" />
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <meta name="author" content="MinDoc" />
+    <title>用户登录 - Powered by MinDoc</title>
+    <meta name="keywords" content="MinDoc,文档在线管理系统,WIKI,wiki,wiki在线,文档在线管理,接口文档在线管理,接口文档管理">
+    <meta name="description" content="MinDoc文档在线管理系统 {{.site_description}}">
+    <!-- Bootstrap -->
+    <link href="{{cdncss "/static/bootstrap/css/bootstrap.min.css"}}" rel="stylesheet">
+    <link href="{{cdncss "/static/font-awesome/css/font-awesome.min.css"}}" rel="stylesheet">
+    <link href="{{cdncss "/static/css/main.css" "version"}}" rel="stylesheet">
+    <style type="text/css">
+        .login > .login-body {
+            text-align: center;
+            padding-top: 1.5em;
+        }
+        .login > .login-body > a > strong:hover {
+            border-bottom: 1px solid #337ab7;
+        }
+        .login > .login-body > a > strong {
+            font-size: 1.5em;
+            vertical-align: middle;
+            padding: 0.5em;
+        }
+        .bind-existed-form > .form-group {
+            margin: auto 1.5em;
+            margin-top: 1em;
+        }
+    </style>
+    <!-- jQuery (necessary for Bootstrap's JavaScript plugins) -->
+    <script src="{{cdnjs "/static/jquery/1.12.4/jquery.min.js"}}"></script>
+    <script type="text/javascript">
+        window.bind_existed = {{ .bind_existed }};
+        window.user_info_json = {{ .user_info_json }};
+        window.server_error_msg = "{{ .error_msg }}";
+        window.home_url = "{{ .BaseUrl }}";
+        window.workweixin_login_bind = "{{urlfor "AccountController.WorkWeixinLoginBind"}}";
+        window.workweixin_login_ignore = "{{urlfor "AccountController.WorkWeixinLoginIgnore"}}";
+    </script>
+</head>
+<body class="manual-container">
+<header class="navbar navbar-static-top smart-nav navbar-fixed-top manual-header" role="banner">
+    <div class="container">
+        <div class="navbar-header col-sm-12 col-md-6 col-lg-5">
+            <a href="{{.BaseUrl}}" class="navbar-brand">{{.SITE_NAME}}</a>
+        </div>
+    </div>
+</header>
+<div class="container manual-body">
+    <div class="row login">
+        <div class="login-body">
+            返回 <a href="{{ .BaseUrl }}"><strong>首页</strong></a>
+        </div>
+    </div>
+    <div class="clearfix"></div>
+    <script type="text/x-template" id="bind-existed-template">
+        <div role="form" class="bind-existed-form">
+            {{ .xsrfdata }}
+            <div class="form-group">
+                <div class="input-group">
+                    <div class="input-group-addon">
+                        <i class="fa fa-user"></i>
+                    </div>
+                    <input type="text" class="form-control" placeholder="邮箱 / 用户名" name="account" id="account" autocomplete="off">
+                </div>
+            </div>
+            <div class="form-group">
+                <div class="input-group">
+                    <div class="input-group-addon">
+                        <i class="fa fa-lock"></i>
+                    </div>
+                    <input type="password" class="form-control" placeholder="密码" name="password" id="password" autocomplete="off">
+                </div>
+            </div>
+        </div>
+    </script>
+</div>
+{{template "widgets/footer.tpl" .}}
+<!-- Include all compiled plugins (below), or include individual files as needed -->
+<script src="{{cdnjs "/static/bootstrap/js/bootstrap.min.js"}}" type="text/javascript"></script>
+<script src="{{cdnjs "/static/layer/layer.js"}}" type="text/javascript"></script>
+<script type="text/javascript">
+    function showBindAccount() {
+        layer.confirm([
+            '检测到当前登录企业微信未绑定已有账户, 是否需要绑定已有账户?<br />',
+            '<ul style="padding-left: 1.2em;">',
+                '<li>若已有账户, 请 <strong>去绑定</strong></li>',
+                '<li>若没有现有账户, 请 <strong>忽略绑定</strong></li>',
+            '</ul>'
+        ].join(''), {
+            title: "WIKI-绑定提示",
+            move: false,
+            area: 'auto',
+            offset: 'auto',
+            icon: 3,
+            btn: ['去绑定','忽略绑定'],
+        }, function(index, layero){
+            // layer.close(index);
+            // layer.msg(window.home_url);
+            // TODO: 现有账户[用户名+密码]查询现有账户 依据Session[user_info]绑定更新现有账户
+            console.log("yes");
+            layer.open({
+                title: "绑定已有账户",
+                type: 1,
+                move: false,
+                area: 'auto',
+                offset: 'auto',
+                content: $('#bind-existed-template').html(),
+                btn: ['绑定','取消'],
+                yes: function(index, layero){
+                    $.ajax({
+                        url: window.workweixin_login_bind,
+                        type: 'POST',
+                        beforeSend: function(request) {
+                            request.setRequestHeader("X-Xsrftoken", $('.bind-existed-form input[name="_xsrf"]').val());
+                        },
+                        data: {
+                            account: $('#account').val(),
+                            password: $('#password').val()
+                        },
+                        dataType: 'json',
+                        success: function(data) {
+                            if(data.errcode == 0) {
+                                layer.close(index);
+                                // layer.msg(JSON.stringify(data), {icon: 1, time: 15500});
+                                window.location.href = window.home_url;
+                            }
+                            else {
+                                layer.msg(data.message, {icon: 5, time: 3500});
+                            }
+                        },
+                        error: function(data) {
+                            console.log(data);
+                        }
+                    });
+                    return false;
+                },
+                cancel: function(index, layero){ 
+                    // return false; // 不关闭
+                    layer.close(index);
+                    window.location.href = window.home_url;
+                }
+            });
+        }, function(index){
+            /*
+            // TODO: 依据Session[user_info]创建新账户
+            console.log("no");
+            var msg = '';
+            // msg = "<pre>" + JSON.stringify(window.location, null, 4) + "</pre>";
+            msg = "<pre>" + JSON.stringify(window.user_info_json, null, 4) + "</pre>";
+            // msg = "<pre>" + window.user_info_json + "</pre>";
+            layer.open({
+                title: "Degug-UserInfo",
+                type: 1,
+                skin: 'layui-layer-rim',
+                move: false,
+                area: 'auto',
+                offset: 'auto',
+                content: msg
+            });
+            */
+            $.ajax({
+                url: window.workweixin_login_ignore,
+                type: 'GET',
+                beforeSend: function(request) {
+                    request.setRequestHeader("X-Xsrftoken", $('.bind-existed-form input[name="_xsrf"]').val());
+                },
+                data: {},
+                dataType: 'json',
+                success: function(data) {
+                    if(data.errcode == 0) {
+                        layer.close(index);
+                        layer.msg(JSON.stringify(data), {icon: 1, time: 15500});
+                        window.location.href = window.home_url;
+                    }
+                    else {
+                        layer.msg(data.message, {icon: 5, time: 3500});
+                    }
+                },
+                error: function(data) {
+                    console.log(data);
+                }
+            });
+            return false;
+        });
+    }
+    $(document).ready(function () {
+        $('#debug-panel').val($('html').html());
+        if (!!window.server_error_msg && window.server_error_msg.length > 0) {
+            layer.msg(window.server_error_msg, {icon: 5, time: 3500});
+        } else {
+            if (window.bind_existed === false) {
+                showBindAccount();
+            } else {
+                // alert(typeof window.bind_existed);
+                // alert('_' + window.bind_existed + '_');
+                window.location.href = window.home_url;
+            }
+        }
+    });
+</script>
+</body>
+</html>