批量更新
This commit is contained in:
@@ -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 在线设备列表
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user