package auth import ( "encoding/base64" "encoding/json" "errors" "fmt" "net/url" "strings" "server/models" "server/pkg/jwtutil" authsvc "server/services/auth" "server/services/auth/idp" beego "github.com/beego/beego/v2/server/web" "github.com/beego/beego/v2/server/web/context" ) // authBase 认证中心对外基址(用于拼接第三方回调地址) func authBase(c *AuthThirdController) string { if iss := jwtutil.Issuer(); iss != "" { return iss } scheme := "https" if c.Ctx.Request.TLS == nil { scheme = "http" } return scheme + "://" + c.Ctx.Request.Host + "/auth" } // claimsFromCtxThird 从 Authorization 头解析登录态 func claimsFromCtxThird(ctx *context.Context) *jwtutil.Claims { header := ctx.Request.Header.Get("Authorization") if header == "" { return nil } parts := strings.SplitN(header, " ", 2) if len(parts) != 2 || parts[0] != "Bearer" { return nil } claims, err := jwtutil.ParseToken(strings.TrimSpace(parts[1])) if err != nil { return nil } return claims } // 第三方登录未绑定时的错误 var ErrThirdNotBound = errors.New("该第三方账号尚未绑定,请先用账号密码登录后绑定") // AuthThirdController 第三方登录(微信/钉钉/飞书/QQ/GitHub/Google) type AuthThirdController struct { beego.Controller } func (c *AuthThirdController) serveJSON(data map[string]interface{}) { c.Data["json"] = data _ = c.ServeJSON() } // thirdState 第三方授权流程中通过 state 透传的上下文(OIDC 原始参数) type thirdState struct { ClientID string `json:"client_id"` RedirectURI string `json:"redirect_uri"` CodeChallenge string `json:"code_challenge"` Method string `json:"code_challenge_method"` Scope string `json:"scope"` Nonce string `json:"nonce"` OIDCState string `json:"oidc_state"` Tid uint64 `json:"tid"` // Action:login=第三方登录(默认);bind=已登录用户绑定第三方账号 Action string `json:"action"` } // List 已开通的第三方登录方式(供登录页展示按钮) // GET /auth/third/list?tid= func (c *AuthThirdController) List() { tid, _ := c.GetInt64("tid", 0) list := make([]map[string]interface{}, 0) for _, p := range idp.Supported() { _, configured := idp.LoadConfig(p, uint64(tid)) list = append(list, map[string]interface{}{ "provider": p, "name": providerName(p), "enabled": configured, }) } c.serveJSON(map[string]interface{}{"code": 200, "data": list}) } // Authorize 跳转到第三方授权页 // GET /auth/third/:provider/authorize?client_id=&redirect_uri=&code_challenge=&state=&tid= func (c *AuthThirdController) Authorize() { provider := strings.TrimSpace(c.Ctx.Input.Param(":provider")) if !idp.IsSupported(provider) { c.Ctx.Output.SetStatus(400) _, _ = c.Ctx.ResponseWriter.Write([]byte("不支持的登录方式: " + provider)) return } tid, _ := c.GetInt64("tid", 0) cfg, configured := idp.LoadConfig(provider, uint64(tid)) if !configured || cfg.AppID == "" || cfg.AppSecret == "" { c.Ctx.Output.SetStatus(400) _, _ = c.Ctx.ResponseWriter.Write([]byte("该登录方式尚未开通,请联系管理员配置")) return } st := thirdState{ ClientID: c.GetString("client_id"), RedirectURI: c.GetString("redirect_uri"), CodeChallenge: c.GetString("code_challenge"), Method: c.GetString("code_challenge_method"), Scope: c.GetString("scope"), Nonce: c.GetString("nonce"), OIDCState: c.GetString("state"), Tid: uint64(tid), Action: strings.TrimSpace(c.GetString("action")), } // 登录页场景:参数封装在 redirect(base64 编码的 authorize URL)里, // 这里解出来,使第三方登录完成后能回到原来的 OIDC 授权流程。 if enc := c.GetString("redirect"); enc != "" && st.ClientID == "" { if raw, err := base64.RawURLEncoding.DecodeString(enc); err == nil { if u, err := url.Parse(string(raw)); err == nil { q := u.Query() st.ClientID = q.Get("client_id") st.RedirectURI = q.Get("redirect_uri") st.CodeChallenge = q.Get("code_challenge") st.Method = q.Get("code_challenge_method") st.Scope = q.Get("scope") st.Nonce = q.Get("nonce") st.OIDCState = q.Get("state") } } } // 第三方登录仅面向租户域应用:平台端(platform.yunzer.cn)不走认证中心, // 这里再校验一次 realm,避免平台域应用被误接入。 if st.ClientID != "" { if client, err := findClient(st.ClientID); err == nil && client.Realm != models.AuthRealmTenant { c.Ctx.Output.SetStatus(400) _, _ = c.Ctx.ResponseWriter.Write([]byte("该应用不支持第三方登录")) return } } raw, err := json.Marshal(st) if err != nil { c.Ctx.Output.SetStatus(500) return } state := base64.RawURLEncoding.EncodeToString(raw) // 注意:authBase 已包含 /auth 前缀(issuer = https://api.yunzer.cn/auth), // 这里不能再拼 /auth,否则回调地址会变成 /auth/auth/third/... callbackURI := authBase(c) + "/third/" + provider + "/callback" c.Redirect(idp.BuildAuthURL(cfg, callbackURI, state), 302) } // Callback 第三方回调:换取身份 → 建会话 → 回到 OIDC authorize 继续发码 // GET /auth/third/:provider/callback?code=&state= func (c *AuthThirdController) Callback() { provider := strings.TrimSpace(c.Ctx.Input.Param(":provider")) code := c.GetString("code") stateParam := c.GetString("state") fail := func(msg string) { c.Redirect("/auth/login?error="+url.QueryEscape(msg), 302) } if code == "" { fail("第三方登录失败:缺少授权码") return } var st thirdState if raw, err := base64.RawURLEncoding.DecodeString(stateParam); err == nil { _ = json.Unmarshal(raw, &st) } cfg, configured := idp.LoadConfig(provider, st.Tid) if !configured { fail("该登录方式尚未开通") return } info, err := idp.Exchange(cfg, code) if err != nil { fail(err.Error()) return } // 绑定场景:把第三方账号挂到当前登录身份上 if st.Action == "bind" { finishBind(c, provider, info) return } // 查找绑定关系:open_id 优先,其次 union_id bind, err := findThirdBinding(provider, info.OpenID, info.UnionID) if err != nil { fail(ErrThirdNotBound.Error()) return } // 校验身份状态 var identity models.AuthIdentity if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", bind.IdentityID).One(&identity); err != nil { fail("账号不存在") return } if identity.Status == models.AuthIdentityStatusDisabled { fail("账号已禁用") return } // 建会话(tid=0 表示待选企业,authorize 会引导用户选择) sess, err := authsvc.CreateSession(authsvc.SessionInfo{ IdentityID: identity.ID, Tid: authsvc.PendingTenantID, ClientID: st.ClientID, IP: c.Ctx.Input.IP(), UserAgent: c.Ctx.Request.UserAgent(), LoginType: authsvc.LoginTypeThird, Amr: provider, }) if err != nil { fail("创建会话失败: " + err.Error()) return } // 更新绑定信息(昵称/头像可能变化) update := map[string]interface{}{} if info.Nickname != "" { update["nickname"] = info.Nickname } if info.Avatar != "" { update["avatar"] = info.Avatar } if info.Raw != "" { update["raw"] = info.Raw } if len(update) > 0 { _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("id", bind.ID).Update(update) } setSessionCookieForCtx(c.Ctx, sess.Sid) // 回到 OIDC 授权流程继续签发授权码 if st.ClientID != "" && st.RedirectURI != "" { v := url.Values{} v.Set("client_id", st.ClientID) v.Set("redirect_uri", st.RedirectURI) v.Set("response_type", ResponseTypeCode) v.Set("code_challenge", st.CodeChallenge) v.Set("code_challenge_method", defaultStr(st.Method, "S256")) if st.Scope != "" { v.Set("scope", st.Scope) } if st.Nonce != "" { v.Set("nonce", st.Nonce) } if st.OIDCState != "" { v.Set("state", st.OIDCState) } c.Redirect("/auth/authorize?"+v.Encode(), 302) return } c.Redirect("/auth/login", 302) } // Bind 已登录用户绑定第三方账号 // POST /auth/third/bind { provider, code, tid } func (c *AuthThirdController) Bind() { claims := claimsFromCtxThird(c.Ctx) if claims == nil || claims.UserID <= 0 { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } var req struct { Provider string `json:"provider"` Code string `json:"code"` Tid uint64 `json:"tid"` } _ = json.Unmarshal(c.Ctx.Input.RequestBody, &req) req.Provider = strings.TrimSpace(req.Provider) if !idp.IsSupported(req.Provider) || req.Code == "" { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } cfg, configured := idp.LoadConfig(req.Provider, req.Tid) if !configured { c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式尚未开通"}) return } info, err := idp.Exchange(cfg, req.Code) if err != nil { c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()}) return } // 该第三方账号若已绑定到别的身份,拒绝重复绑定 if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("provider", req.Provider). Filter("open_id", info.OpenID).Exist(); exist { c.serveJSON(map[string]interface{}{"code": 400, "msg": "该第三方账号已被其他账号绑定"}) return } item := &models.AuthIdentityThird{ IdentityID: uint64(claims.UserID), Provider: req.Provider, OpenID: info.OpenID, Nickname: strPtr(info.Nickname), Avatar: strPtr(info.Avatar), Raw: strPtr(info.Raw), } if info.UnionID != "" { item.UnionID = &info.UnionID } if _, err := models.Orm.Insert(item); err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "绑定失败: " + err.Error()}) return } c.serveJSON(map[string]interface{}{"code": 200, "msg": "绑定成功"}) } // BoundList 当前登录身份已绑定的第三方账号 // GET /auth/third/bound func (c *AuthThirdController) BoundList() { claims := claimsFromCtxThird(c.Ctx) if claims == nil || claims.UserID <= 0 { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } var rows []models.AuthIdentityThird if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", uint64(claims.UserID)).All(&rows); err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"}) return } list := make([]map[string]interface{}, 0, len(rows)) for _, r := range rows { list = append(list, map[string]interface{}{ "id": r.ID, "provider": r.Provider, "name": providerName(r.Provider), "nickname": deref(r.Nickname), "bind_time": r.BindTime, }) } c.serveJSON(map[string]interface{}{"code": 200, "data": list}) } // Unbind 解绑第三方账号 // POST /auth/third/unbind { provider } func (c *AuthThirdController) Unbind() { claims := claimsFromCtxThird(c.Ctx) if claims == nil || claims.UserID <= 0 { c.Ctx.Output.SetStatus(401) c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"}) return } var req struct { Provider string `json:"provider"` } _ = json.Unmarshal(c.Ctx.Input.RequestBody, &req) if strings.TrimSpace(req.Provider) == "" { c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"}) return } // 至少保留一种登录方式:若未设置密码则不允许解绑最后一个第三方 var identity models.AuthIdentity // 未设置密码的用户,解绑最后一个第三方账号后将无法登录 if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", uint64(claims.UserID)).One(&identity); err == nil { hasPassword := identity.PasswordHash != nil && *identity.PasswordHash != "" if !hasPassword { cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", identity.ID).Count() if cnt <= 1 { c.serveJSON(map[string]interface{}{"code": 400, "msg": "未设置密码,不能解绑最后一个第三方账号"}) return } } } if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", uint64(claims.UserID)). Filter("provider", strings.TrimSpace(req.Provider)). Delete(); err != nil { c.serveJSON(map[string]interface{}{"code": 500, "msg": "解绑失败"}) return } c.serveJSON(map[string]interface{}{"code": 200, "msg": "已解绑"}) } // BindPage 第三方账号绑定管理页(浏览器访问,依赖认证中心会话 Cookie) // GET /auth/bind func (c *AuthThirdController) BindPage() { sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) if sid == "" { c.Redirect("/auth/login", 302) return } session, err := authsvc.GetSession(sid) if err != nil { c.Redirect("/auth/login", 302) return } // 已绑定列表 var bound []models.AuthIdentityThird _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", session.IdentityID).All(&bound) boundMap := map[string]models.AuthIdentityThird{} for _, b := range bound { boundMap[b.Provider] = b } // 全部平台(只展示已开通的) items := make([]map[string]interface{}, 0) for _, p := range idp.Supported() { _, configured := idp.LoadConfig(p, session.Tid) if !configured { continue } item := map[string]interface{}{ "provider": p, "name": providerName(p), "bound": false, "nickname": "", } if b, ok := boundMap[p]; ok { item["bound"] = true item["nickname"] = deref(b.Nickname) } items = append(items, item) } c.Data["Providers"] = items c.Data["Success"] = c.GetString("success") c.Data["Error"] = c.GetString("error") c.TplName = "auth/bind.tpl" } // UnbindPage 解绑(页面入口,用会话 Cookie 鉴权,解绑后回到绑定页) // GET /auth/third/:provider/unbind func (c *AuthThirdController) UnbindPage() { provider := strings.TrimSpace(c.Ctx.Input.Param(":provider")) sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) if sid == "" { c.Redirect("/auth/login", 302) return } session, err := authsvc.GetSession(sid) if err != nil { c.Redirect("/auth/login", 302) return } var identity models.AuthIdentity hasPassword := true if err := models.Orm.QueryTable(new(models.AuthIdentity)). Filter("id", session.IdentityID).One(&identity); err == nil { hasPassword = identity.PasswordHash != nil && *identity.PasswordHash != "" } if !hasPassword { cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", session.IdentityID).Count() if cnt <= 1 { c.Redirect("/auth/bind?error="+url.QueryEscape("未设置密码,不能解绑最后一个第三方账号"), 302) return } } if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", session.IdentityID). Filter("provider", provider).Delete(); err != nil { c.Redirect("/auth/bind?error="+url.QueryEscape("解绑失败"), 302) return } c.Redirect("/auth/bind?success="+url.QueryEscape("已解绑"), 302) } // finishBind 绑定场景的回调处理:把第三方账号挂到当前登录身份 func finishBind(c *AuthThirdController, provider string, info *idp.UserInfo) { sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName)) if sid == "" { c.Redirect("/auth/login", 302) return } session, err := authsvc.GetSession(sid) if err != nil { c.Redirect("/auth/login", 302) return } // 该第三方账号已绑定到别的身份 → 拒绝 if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("provider", provider). Filter("open_id", info.OpenID).Exist(); exist { c.Redirect("/auth/bind?error="+url.QueryEscape("该"+providerName(provider)+"账号已被其他账号绑定"), 302) return } // 同一身份重复绑定同一平台 → 先清掉旧的 _, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)). Filter("identity_id", session.IdentityID). Filter("provider", provider).Delete() item := &models.AuthIdentityThird{ IdentityID: session.IdentityID, Provider: provider, OpenID: info.OpenID, Nickname: strPtr(info.Nickname), Avatar: strPtr(info.Avatar), Raw: strPtr(info.Raw), } if info.UnionID != "" { item.UnionID = &info.UnionID } if _, err := models.Orm.Insert(item); err != nil { c.Redirect("/auth/bind?error="+url.QueryEscape("绑定失败"), 302) return } c.Redirect("/auth/bind?success="+url.QueryEscape("绑定成功"), 302) } // ---------------------------------------------------------------- 工具 // findThirdBinding 按 open_id 查找,其次按 union_id 查找 func findThirdBinding(provider, openID, unionID string) (*models.AuthIdentityThird, error) { var bind models.AuthIdentityThird qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", provider) if openID != "" { if err := qs.Filter("open_id", openID).One(&bind); err == nil { return &bind, nil } } if unionID != "" { if err := qs.Filter("union_id", unionID).One(&bind); err == nil { return &bind, nil } } return nil, fmt.Errorf("not bound") } func providerName(p string) string { switch p { case models.IdPWechat: return "微信" case models.IdPDingTalk: return "钉钉" case models.IdPFeishu: return "飞书" case models.IdPQQ: return "QQ" case models.IdPGitHub: return "GitHub" case models.IdPGoogle: return "Google" } return p } func defaultStr(v, def string) string { if strings.TrimSpace(v) == "" { return def } return v } func strPtr(s string) *string { if s == "" { return nil } return &s } func deref(p *string) string { if p == nil { return "" } return *p }