批量更新

This commit is contained in:
2026-09-20 00:19:08 +08:00
parent fa281363fb
commit c5ed596008
47 changed files with 5385 additions and 1199 deletions
+17 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
authsvc "server/services/auth"
@@ -200,20 +201,33 @@ func (c *AuthLoginController) LogoutPage() {
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
// 走统一登出入口:吊销令牌并通知其他应用(单点登出)
if access != "" {
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
_ = authsvc.Logout(access, "")
}
clearSessionCookieForCtx(c.Ctx)
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
cid := strings.TrimSpace(c.GetString("client_id"))
if back != "" && cid != "" {
if client, err := findClient(cid); err == nil && allowRedirect(client, back) {
if client, err := findClient(cid); err == nil && allowLogoutRedirect(client, back) {
c.Redirect(back, 302)
return
}
// 回跳地址未登记时不要直接抛 JSON 给用户,退回登录页
target := "/auth/login?error=" + url.QueryEscape("登出回跳地址未登记,已返回登录页")
if cid != "" {
target += "&client_id=" + url.QueryEscape(cid)
}
c.Redirect(target, 302)
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
// 浏览器直接访问(无回跳参数):跳登录页,避免页面显示裸 JSON
target := "/auth/login"
if cid != "" {
target += "?client_id=" + url.QueryEscape(cid)
}
c.Redirect(target, 302)
}
// Sessions 在线设备列表
+70 -3
View File
@@ -10,6 +10,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
@@ -262,10 +263,36 @@ func (c *AuthOidcController) UserInfo() {
c.fail(500, "server_error")
return
}
// 业务接口与前端统一使用 identity_id 作为用户标识
account, name, groupID := "", "", uint64(0)
if bind, err := authsvc.GetTenantUser(identity.ID, uint64(claims.TenantId)); err == nil {
groupID = bind.GroupID
if bind.Account != nil {
account = *bind.Account
}
if bind.Name != nil {
name = *bind.Name
}
}
if account == "" {
account = profile.Mobile
}
if name == "" {
name = profile.Nickname
}
c.serveJSON(map[string]interface{}{
"sub": fmt.Sprintf("%d", identity.ID),
"union_id": identity.UnionID,
"tid": claims.TenantId,
"sub": fmt.Sprintf("%d", identity.ID),
"id": identity.ID,
"union_id": identity.UnionID,
"tid": claims.TenantId,
// 诊断用:业务接口按 user_type 判定权限(backend / app),
// 出现「无权访问」时可先看这里的值是否正确
"user_type": claims.UserType,
"group_id": groupID,
"account": account,
"name": name,
"nickname": profile.Nickname,
"mobile": profile.Mobile,
"email": profile.Email,
@@ -397,6 +424,46 @@ func allowRedirect(client *models.AuthClient, uri string) bool {
return false
}
// allowLogoutRedirect 登出回跳地址校验。
//
// 先精确匹配白名单,再按 origin(协议+主机+端口)放宽匹配:
// 实际使用中「末尾斜杠」「带 #/login 片段」等差异很常见,
// 只做精确匹配会导致明明同域却跳不回去,因此同域即放行。
func allowLogoutRedirect(client *models.AuthClient, uri string) bool {
raw := ""
if client.PostLogoutURIs != nil {
raw = *client.PostLogoutURIs
}
var list []string
if raw != "" {
_ = json.Unmarshal([]byte(raw), &list)
}
if len(list) == 0 {
return false
}
target := strings.TrimSpace(uri)
for _, item := range list {
if strings.TrimSpace(item) == target {
return true
}
}
targetURL, err := url.Parse(target)
if err != nil || targetURL.Scheme == "" || targetURL.Host == "" {
return false
}
for _, item := range list {
base, err := url.Parse(strings.TrimSpace(item))
if err != nil || base.Scheme == "" || base.Host == "" {
continue
}
if base.Scheme == targetURL.Scheme && base.Host == targetURL.Host {
return true
}
}
return false
}
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
plain, err := randomString(32)
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"net/url"
"strings"
"time"
"server/models"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// AuthSessionController 会话管理(用户自助:查看在线设备、踢下线)
type AuthSessionController struct {
beego.Controller
}
func (c *AuthSessionController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// DevicesPage 在线设备页(浏览器访问,依赖认证中心会话 Cookie)
// GET /auth/devices
func (c *AuthSessionController) DevicesPage() {
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
}
list, err := authsvc.ListActiveSessions(session.IdentityID)
if err != nil {
list = nil
}
items := make([]map[string]interface{}, 0, len(list))
now := time.Now()
for _, s := range list {
device := "未知设备"
if s.DeviceName != nil && strings.TrimSpace(*s.DeviceName) != "" {
device = *s.DeviceName
}
ip := "-"
if s.IP != nil {
ip = *s.IP
}
client := s.ClientID
if client == "" {
client = "-"
}
items = append(items, map[string]interface{}{
"Sid": s.Sid,
"Current": s.Sid == sid,
"Device": device,
"IP": ip,
"Client": client,
"LoginAt": s.LoginAt.Format("2006-01-02 15:04"),
"LastAt": s.LastAccessAt.Format("2006-01-02 15:04"),
"Expired": s.ExpiresAt.Before(now),
"Tid": s.Tid,
})
}
c.Data["Sessions"] = items
c.Data["Success"] = c.GetString("success")
c.Data["Error"] = c.GetString("error")
c.TplName = "auth/devices.tpl"
}
// KickPage 踢下线指定设备(页面入口,踢完后回到设备页)
// GET /auth/devices/kick?sid=
func (c *AuthSessionController) KickPage() {
target := strings.TrimSpace(c.GetString("sid"))
current := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if current == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(current)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
if target == "" {
c.Redirect("/auth/devices?error="+url.QueryEscape("参数错误"), 302)
return
}
// 只允许操作自己名下的会话
owned := false
if list, e := authsvc.ListActiveSessions(session.IdentityID); e == nil {
for _, s := range list {
if s.Sid == target {
owned = true
break
}
}
}
if !owned {
c.Redirect("/auth/devices?error="+url.QueryEscape("无权操作该设备"), 302)
return
}
if target == current {
c.Redirect("/auth/devices?error="+url.QueryEscape("不能踢掉当前设备,请直接登出"), 302)
return
}
if err := authsvc.RevokeSession(target, models.RevokeReasonAdmin); err != nil {
c.Redirect("/auth/devices?error="+url.QueryEscape("操作失败"), 302)
return
}
// 同步吊销该会话的刷新令牌,确保对方无法续期
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
Filter("sid", target).
Update(map[string]interface{}{"revoked": 1})
c.Redirect("/auth/devices?success="+url.QueryEscape("已将该设备下线"), 302)
}
+592
View File
@@ -0,0 +1,592 @@
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
}