批量更新

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
}
+391
View File
@@ -0,0 +1,391 @@
package controllers
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthClientController 统一认证中心 —— 接入应用(OIDC Client)管理。
//
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证,
// 无需改代码、无需手写 SQL。
type PlatformAuthClientController struct {
beego.Controller
}
func (c *PlatformAuthClientController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:接入应用属于平台级敏感配置,仅平台管理员可操作。
// 不依赖全局中间件(其处于 warn 观察模式时不拦截),这里主动校验。
func (c *PlatformAuthClientController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
// 平台端应用注册只允许平台管理员操作;租户管理员无权访问
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
type authClientPayload struct {
ClientID string `json:"client_id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
AppType int8 `json:"app_type"`
RedirectURIs *string `json:"redirect_uris"`
PostLogoutURIs *string `json:"post_logout_uris"`
BackchannelLogoutURI *string `json:"backchannel_logout_uri"`
GrantTypes string `json:"grant_types"`
Scope *string `json:"scope"`
AccessTTL int `json:"access_ttl"`
RefreshTTL int `json:"refresh_ttl"`
Realm string `json:"realm"`
Status *int8 `json:"status"`
}
func (c *PlatformAuthClientController) parsePayload() (authClientPayload, bool) {
var p authClientPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return p, false
}
}
return p, true
}
// List 接入应用列表(不返回 client_secret)
// GET /platform/authClient/list
func (c *PlatformAuthClientController) List() {
var rows []models.AuthClient
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
OrderBy("-id").All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
return
}
// 脱敏:只告知是否配置了密钥,不下发明文或哈希
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"client_id": r.ClientID,
"app_code": r.AppCode,
"name": r.Name,
"app_type": r.AppType,
"redirect_uris": derefStrAuth(r.RedirectURIs),
"post_logout_uris": derefStrAuth(r.PostLogoutURIs),
"backchannel_logout_uri": derefStrAuth(r.BackchannelLogoutURI),
"grant_types": r.GrantTypes,
"scope": derefStrAuth(r.Scope),
"access_ttl": r.AccessTTL,
"refresh_ttl": r.RefreshTTL,
"realm": r.Realm,
"status": r.Status,
"has_secret": r.ClientSecret != nil,
"create_time": r.CreateTime,
})
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
})
}
// Detail 应用详情
// GET /platform/authClient/detail/:id
func (c *PlatformAuthClientController) Detail() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
row.ClientSecret = nil
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": row})
}
// Create 新增应用
// POST /platform/authClient/create
func (c *PlatformAuthClientController) Create() {
p, ok := c.parsePayload()
if !ok {
return
}
p.ClientID = strings.TrimSpace(p.ClientID)
p.AppCode = strings.TrimSpace(p.AppCode)
p.Name = strings.TrimSpace(p.Name)
if p.ClientID == "" || p.AppCode == "" || p.Name == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id、应用编码、名称均不能为空"})
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
if exist := existsAuthClient("client_id", p.ClientID); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id 已存在"})
return
}
if exist := existsAuthClient("app_code", p.AppCode); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "应用编码已存在"})
return
}
item := &models.AuthClient{
ClientID: p.ClientID,
AppCode: p.AppCode,
Name: p.Name,
AppType: p.AppType,
RedirectURIs: p.RedirectURIs,
PostLogoutURIs: p.PostLogoutURIs,
BackchannelLogoutURI: p.BackchannelLogoutURI,
GrantTypes: defaultStrAuth(p.GrantTypes, "authorization_code,refresh_token"),
Scope: p.Scope,
AccessTTL: defaultIntAuth(p.AccessTTL, 1800),
RefreshTTL: defaultIntAuth(p.RefreshTTL, 2592000),
Realm: defaultStrAuth(p.Realm, models.AuthRealmTenant),
Status: 1,
}
if p.Status != nil {
item.Status = *p.Status
}
// 机密客户端(有后端的 Web 应用)才下发密钥;SPA/APP 走 PKCE,不持有密钥
secretPlain := ""
if p.AppType == models.AuthAppTypeWeb {
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
secretPlain = plain
item.ClientSecret = &hashed
}
id, err := models.Orm.Insert(item)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "创建成功" + map[bool]string{true: "(请妥善保存密钥,仅此一次显示)", false: ""}[secretPlain != ""],
"data": map[string]interface{}{"id": id, "client_secret": secretPlain},
})
}
// Edit 编辑应用
// POST /platform/authClient/edit/:id
func (c *PlatformAuthClientController) Edit() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
p, ok := c.parsePayload()
if !ok {
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
update := map[string]interface{}{}
if v := strings.TrimSpace(p.Name); v != "" {
update["name"] = v
}
if p.AppType > 0 {
update["app_type"] = p.AppType
}
if p.RedirectURIs != nil {
update["redirect_uris"] = *p.RedirectURIs
}
if p.PostLogoutURIs != nil {
update["post_logout_uris"] = *p.PostLogoutURIs
}
if p.BackchannelLogoutURI != nil {
update["backchannel_logout_uri"] = *p.BackchannelLogoutURI
}
if v := strings.TrimSpace(p.GrantTypes); v != "" {
update["grant_types"] = v
}
if p.Scope != nil {
update["scope"] = *p.Scope
}
if p.AccessTTL > 0 {
update["access_ttl"] = p.AccessTTL
}
if p.RefreshTTL > 0 {
update["refresh_ttl"] = p.RefreshTTL
}
if v := strings.TrimSpace(p.Realm); v != "" {
update["realm"] = v
}
if p.Status != nil {
update["status"] = *p.Status
}
if len(update) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无更新字段"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).Update(update); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// ResetSecret 重置密钥(仅机密客户端),返回新明文,仅此一次显示
// POST /platform/authClient/resetSecret/:id
func (c *PlatformAuthClientController) ResetSecret() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
if row.AppType != models.AuthAppTypeWeb {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅「Web 后端」类型的应用需要密钥,SPA/APP 使用 PKCE"})
return
}
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"client_secret": hashed}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "重置成功,请妥善保存新密钥",
"data": map[string]interface{}{"client_secret": plain},
})
}
// Delete 停用应用(逻辑删除:status=0,避免误删导致线上应用无法登录)
// DELETE /platform/authClient/delete/:id
func (c *PlatformAuthClientController) Delete() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"status": 0}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "停用失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已停用"})
}
// ---------------------------------------------------------------- 工具
func (c *PlatformAuthClientController) parseID() (uint64, error) {
var id uint64
_, err := fmt.Sscanf(c.Ctx.Input.Param(":id"), "%d", &id)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid id")
}
return id, nil
}
func existsAuthClient(field, value string) bool {
return models.Orm.QueryTable(new(models.AuthClient)).Filter(field, value).Exist()
}
// generateClientSecret 生成密钥:明文只在创建/重置时返回一次,库里存 sha256 哈希
func generateClientSecret() (plain, hashed string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
plain = "yzs_" + hex.EncodeToString(buf)
sum := sha256.Sum256([]byte(plain))
return plain, hex.EncodeToString(sum[:]), nil
}
// validateJSONStringArray 校验字段为合法的字符串数组(为空时跳过)
func validateJSONStringArray(p *string) error {
if p == nil || strings.TrimSpace(*p) == "" {
return nil
}
var arr []string
return json.Unmarshal([]byte(*p), &arr)
}
func derefStrAuth(p *string) string {
if p == nil {
return ""
}
return *p
}
func defaultStrAuth(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
func defaultIntAuth(v, def int) int {
if v <= 0 {
return def
}
return v
}
+240
View File
@@ -0,0 +1,240 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthConfigController 统一认证 —— 租户登录策略配置(平台端管理)
//
// 可配置:登录验证码方式、密码强度、会话时长、同账号最大在线设备数(1号1机)
// 以及超限策略(踢掉旧会话 / 拒绝新登录)等。
type PlatformAuthConfigController struct {
beego.Controller
}
func (c *PlatformAuthConfigController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:仅平台管理员可配置
func (c *PlatformAuthConfigController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
// Detail 查询租户登录策略;未配置时返回系统默认值
// GET /platform/authConfig/detail?tid=
func (c *PlatformAuthConfigController) Detail() {
tid, _ := c.GetInt64("tid", 0)
if tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
return
}
var cfg models.AuthTenantAuthConfig
err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).One(&cfg)
if err != nil {
// 未配置:返回默认值
policy := authsvc.GetTenantSessionPolicy(uint64(tid))
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"tid": tid,
"verify_type": "captcha",
"open_verify": 1,
"pwd_min_len": 8,
"pwd_complexity": 0,
"session_ttl": policy.SessionTTL,
"max_session": policy.MaxSession,
"kick_strategy": policy.KickStrategy,
"mfa_required": 0,
"ip_whitelist": "",
"allow_third": "",
"configured": false,
},
})
return
}
ipList := ""
if cfg.IPWhitelist != nil {
ipList = *cfg.IPWhitelist
}
allowThird := ""
if cfg.AllowThird != nil {
allowThird = *cfg.AllowThird
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"tid": cfg.Tid,
"verify_type": cfg.VerifyType,
"open_verify": cfg.OpenVerify,
"pwd_min_len": cfg.PwdMinLen,
"pwd_complexity": cfg.PwdComplexity,
"session_ttl": cfg.SessionTTL,
"max_session": cfg.MaxSession,
"kick_strategy": cfg.KickStrategy,
"mfa_required": cfg.MfaRequired,
"ip_whitelist": ipList,
"allow_third": allowThird,
"configured": true,
},
})
}
// Save 保存租户登录策略(不存在则创建)
// POST /platform/authConfig/save
func (c *PlatformAuthConfigController) Save() {
var p struct {
Tid uint64 `json:"tid"`
VerifyType string `json:"verify_type"`
OpenVerify *int8 `json:"open_verify"`
PwdMinLen int `json:"pwd_min_len"`
PwdComplexity int8 `json:"pwd_complexity"`
SessionTTL int `json:"session_ttl"`
MaxSession int `json:"max_session"`
KickStrategy int8 `json:"kick_strategy"`
MfaRequired int8 `json:"mfa_required"`
IPWhitelist string `json:"ip_whitelist"`
AllowThird string `json:"allow_third"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
}
if p.Tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
return
}
// 校验 IP 白名单为合法 JSON 数组
ip := strings.TrimSpace(p.IPWhitelist)
if ip != "" {
var arr []string
if err := json.Unmarshal([]byte(ip), &arr); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "IP白名单必须是 JSON 数组,如 [\"1.2.3.4\"]"})
return
}
}
cfg := models.AuthTenantAuthConfig{
Tid: p.Tid,
VerifyType: defaultStrAuth2(p.VerifyType, "captcha"),
OpenVerify: 1,
PwdMinLen: p.PwdMinLen,
PwdComplexity: p.PwdComplexity,
SessionTTL: p.SessionTTL,
MaxSession: p.MaxSession,
KickStrategy: p.KickStrategy,
MfaRequired: p.MfaRequired,
}
if p.OpenVerify != nil {
cfg.OpenVerify = *p.OpenVerify
}
if cfg.PwdMinLen <= 0 {
cfg.PwdMinLen = 8
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = authsvc.DefaultSessionTTL
}
if cfg.MaxSession <= 0 {
cfg.MaxSession = authsvc.DefaultMaxSession
}
if cfg.KickStrategy != models.KickStrategyReject {
cfg.KickStrategy = models.KickStrategyKickOld
}
if ip != "" {
cfg.IPWhitelist = &ip
}
if v := strings.TrimSpace(p.AllowThird); v != "" {
cfg.AllowThird = &v
}
exist := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).Filter("tid", p.Tid).Exist()
if exist {
fields := []string{"verify_type", "open_verify", "pwd_min_len", "pwd_complexity",
"session_ttl", "max_session", "kick_strategy", "mfa_required"}
if cfg.IPWhitelist != nil {
fields = append(fields, "ip_whitelist")
}
if cfg.AllowThird != nil {
fields = append(fields, "allow_third")
}
if _, err := models.Orm.Update(&cfg, fields...); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
return
}
} else {
if _, err := models.Orm.Insert(&cfg); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
return
}
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// Reset 恢复默认值(删除自定义配置)
// DELETE /platform/authConfig/reset/:tid
func (c *PlatformAuthConfigController) Reset() {
tid, err := strconv.ParseUint(c.Ctx.Input.Param(":tid"), 10, 64)
if err != nil || tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效 tid"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).Delete(); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已恢复默认"})
}
func defaultStrAuth2(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
+257
View File
@@ -0,0 +1,257 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthIdpController 统一认证 —— 第三方登录配置(平台端管理)
//
// 配置说明:
// - tid = 0 :平台全局配置,所有租户共用
// - tid > 0 :租户自带身份源,仅该租户可见(第10条需求:企业自带钉钉/飞书等)
//
// 配置对象只有「租户用户登录」,平台端自身不使用第三方登录。
type PlatformAuthIdpController struct {
beego.Controller
}
func (c *PlatformAuthIdpController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:仅平台管理员可维护第三方登录配置
func (c *PlatformAuthIdpController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
type authIdpPayload struct {
Tid uint64 `json:"tid"`
Provider string `json:"provider"`
Name string `json:"name"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
ProxyURL string `json:"proxy_url"`
Scopes string `json:"scopes"`
AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"`
UserinfoURL string `json:"userinfo_url"`
Status *int8 `json:"status"`
}
func (c *PlatformAuthIdpController) parsePayload() (authIdpPayload, bool) {
var p authIdpPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return p, false
}
}
return p, true
}
// List 配置列表
// GET /platform/authIdp/list?tid=0
func (c *PlatformAuthIdpController) List() {
tid, _ := c.GetInt64("tid", 0)
var rows []models.AuthTenantIdp
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).
Filter("tid", tid).OrderBy("id").All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
return
}
// 脱敏:不下发 AppSecret
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"tid": r.Tid,
"provider": r.Provider,
"name": derefAuthIdp(r.Name),
"app_id": derefAuthIdp(r.AppID),
"proxy_url": derefAuthIdp(r.ProxyURL),
"scopes": derefAuthIdp(r.Scopes),
"auth_url": derefAuthIdp(r.AuthURL),
"token_url": derefAuthIdp(r.TokenURL),
"userinfo_url": derefAuthIdp(r.UserinfoURL),
"status": r.Status,
"has_secret": r.AppSecret != nil && *r.AppSecret != "",
})
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
})
}
// Create 新增配置
// POST /platform/authIdp/create
func (c *PlatformAuthIdpController) Create() {
p, ok := c.parsePayload()
if !ok {
return
}
p.Provider = strings.TrimSpace(p.Provider)
if p.Provider == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "provider 不能为空"})
return
}
if exist := models.Orm.QueryTable(new(models.AuthTenantIdp)).
Filter("tid", p.Tid).Filter("provider", p.Provider).Exist(); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式已配置,请直接编辑"})
return
}
item := &models.AuthTenantIdp{
Tid: p.Tid,
Provider: p.Provider,
Status: 1,
}
fillAuthIdp(item, &p)
if p.Status != nil {
item.Status = *p.Status
}
id, err := models.Orm.Insert(item)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}})
}
// Edit 编辑配置(app_secret 留空表示不修改)
// POST /platform/authIdp/edit/:id
func (c *PlatformAuthIdpController) Edit() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
p, ok := c.parsePayload()
if !ok {
return
}
var row models.AuthTenantIdp
if err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
fillAuthIdp(&row, &p)
if p.Status != nil {
row.Status = *p.Status
}
if _, err := models.Orm.Update(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// Delete 删除配置
// DELETE /platform/authIdp/delete/:id
func (c *PlatformAuthIdpController) Delete() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).Delete(); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// CallbackHint 返回各平台需要填写的回调地址(方便复制)
// GET /platform/authIdp/callbackHint?provider=wechat
func (c *PlatformAuthIdpController) CallbackHint() {
provider := strings.TrimSpace(c.GetString("provider"))
base := jwtutil.Issuer()
if base == "" {
scheme := "https"
if c.Ctx.Request.TLS == nil {
scheme = "http"
}
base = fmt.Sprintf("%s://%s/auth", scheme, c.Ctx.Request.Host)
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"callback": fmt.Sprintf("%s/third/%s/callback", base, provider),
},
})
}
// ---------------------------------------------------------------- 工具
// fillAuthIdp 把请求参数写入模型;留空字段表示不修改
func fillAuthIdp(item *models.AuthTenantIdp, p *authIdpPayload) {
if v := strings.TrimSpace(p.Name); v != "" {
item.Name = &v
}
if v := strings.TrimSpace(p.AppID); v != "" {
item.AppID = &v
}
if v := strings.TrimSpace(p.AppSecret); v != "" {
item.AppSecret = &v
}
if v := strings.TrimSpace(p.ProxyURL); v != "" {
item.ProxyURL = &v
}
if v := strings.TrimSpace(p.Scopes); v != "" {
item.Scopes = &v
}
if v := strings.TrimSpace(p.AuthURL); v != "" {
item.AuthURL = &v
}
if v := strings.TrimSpace(p.TokenURL); v != "" {
item.TokenURL = &v
}
if v := strings.TrimSpace(p.UserinfoURL); v != "" {
item.UserinfoURL = &v
}
}
func derefAuthIdp(p *string) string {
if p == nil {
return ""
}
return *p
}