Files
yunzerwebsiteallinone/go/controllers/auth/third.go
T
2026-09-20 00:19:08 +08:00

593 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}