批量更新
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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
-- =============================================================
|
||||
-- OA 薪酬管理模块表结构
|
||||
--
|
||||
-- 说明:代码里有模型定义(models/backend_oa_compensation.go),
|
||||
-- 但此前缺少建表脚本,访问薪酬页面会因表不存在返回 500。
|
||||
-- 本脚本补齐三张表,仅新建、不改动任何既有数据。
|
||||
--
|
||||
-- 注意:不显式指定 COLLATE,沿用数据库默认排序规则,避免与既有表
|
||||
-- (utf8mb4_0900_ai_ci)在 JOIN 比较字符串时报 Illegal mix of collations。
|
||||
-- =============================================================
|
||||
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------------- 1. 员工薪酬方案 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_schemes` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
|
||||
`scheme_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '方案名称',
|
||||
`effective_date` DATE NOT NULL COMMENT '生效日期',
|
||||
`expiry_date` DATE DEFAULT NULL COMMENT '失效日期',
|
||||
`base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '基本工资',
|
||||
`post_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '岗位津贴',
|
||||
`performance_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '绩效工资',
|
||||
`transport_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '交通补贴',
|
||||
`meal_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '餐费补贴',
|
||||
`communication_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '通讯补贴',
|
||||
`social_insurance_base` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '社保基数',
|
||||
`housing_fund_base` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '公积金基数',
|
||||
`social_insurance_rate` DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '社保个人费率',
|
||||
`housing_fund_rate` DECIMAL(6,4) NOT NULL DEFAULT 0 COMMENT '公积金个人费率',
|
||||
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '1生效 0失效',
|
||||
`remark` TEXT,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_tid` (`tid`),
|
||||
KEY `idx_employee` (`employee_id`),
|
||||
KEY `idx_status` (`status`),
|
||||
KEY `idx_effective` (`effective_date`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-员工薪酬方案';
|
||||
|
||||
-- ---------------- 2. 月度薪资单主表 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_payrolls` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`employee_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '员工ID',
|
||||
`scheme_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '关联薪酬方案ID',
|
||||
`payroll_month` VARCHAR(7) NOT NULL DEFAULT '' COMMENT '薪资月份,格式 YYYY-MM',
|
||||
`employee_name` VARCHAR(50) NOT NULL DEFAULT '' COMMENT '员工姓名快照',
|
||||
`department` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '部门快照',
|
||||
`position` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '职位快照',
|
||||
`base_salary` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`post_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`performance_salary` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`transport_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`meal_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`communication_allowance` DECIMAL(12,2) NOT NULL DEFAULT 0,
|
||||
`overtime_pay` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '加班费',
|
||||
`bonus` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '奖金',
|
||||
`other_addition` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '其他加项',
|
||||
`leave_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '请假扣款',
|
||||
`late_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '迟到扣款',
|
||||
`other_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '其他扣款',
|
||||
`social_insurance` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '社保个人部分',
|
||||
`housing_fund` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '公积金个人部分',
|
||||
`personal_income_tax` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '个人所得税',
|
||||
`gross_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '应发合计',
|
||||
`total_deduction` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '扣款合计',
|
||||
`net_salary` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '实发工资',
|
||||
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '0草稿 1已确认 2已发放',
|
||||
`confirmed_at` DATETIME DEFAULT NULL,
|
||||
`paid_at` DATETIME DEFAULT NULL,
|
||||
`remark` TEXT,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tid_employee_month` (`tid`, `employee_id`, `payroll_month`),
|
||||
KEY `idx_tid_month` (`tid`, `payroll_month`),
|
||||
KEY `idx_status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-月度薪资单';
|
||||
|
||||
-- ---------------- 3. 薪资单自定义增减项 ----------------
|
||||
CREATE TABLE IF NOT EXISTS `yz_backend_oa_compensation_payroll_items` (
|
||||
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tid` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID',
|
||||
`payroll_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '薪资单ID',
|
||||
`item_name` VARCHAR(100) NOT NULL DEFAULT '' COMMENT '项目名称',
|
||||
`item_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1加项 2扣项',
|
||||
`amount` DECIMAL(12,2) NOT NULL DEFAULT 0 COMMENT '金额',
|
||||
`remark` VARCHAR(500) DEFAULT NULL,
|
||||
`sort_order` INT NOT NULL DEFAULT 0,
|
||||
`is_deleted` TINYINT NOT NULL DEFAULT 0,
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
`update_time` DATETIME DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
|
||||
`delete_time` DATETIME DEFAULT NULL,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_payroll` (`payroll_id`),
|
||||
KEY `idx_tid` (`tid`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='OA-薪资单增减项';
|
||||
@@ -42,6 +42,10 @@ type Claims struct {
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/...
|
||||
|
||||
// Events OIDC 事件声明,用于 Back-Channel Logout:
|
||||
// {"http://schemas.openid.net/event/backchannel-logout": {}}
|
||||
Events map[string]interface{} `json:"events,omitempty"`
|
||||
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
@@ -147,8 +151,9 @@ type TokenOptions struct {
|
||||
Amr string
|
||||
Subject string
|
||||
Audience []string
|
||||
Jti string // JWT ID,用于吊销(登出/踢下线)
|
||||
TTL time.Duration // 留空默认 30 分钟
|
||||
Jti string // JWT ID,用于吊销(登出/踢下线)
|
||||
Events map[string]interface{} // OIDC 事件声明(Back-Channel Logout 用)
|
||||
TTL time.Duration // 留空默认 30 分钟
|
||||
Kid string
|
||||
}
|
||||
|
||||
@@ -178,6 +183,7 @@ func SignToken(opt TokenOptions) (string, error) {
|
||||
Sid: opt.Sid,
|
||||
Scope: opt.Scope,
|
||||
Amr: opt.Amr,
|
||||
Events: opt.Events,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ID: opt.Jti,
|
||||
Subject: opt.Subject,
|
||||
|
||||
@@ -22,6 +22,19 @@ func Register() {
|
||||
beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect")
|
||||
beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke")
|
||||
|
||||
// ---- 第三方登录(微信/钉钉/飞书/QQ/GitHub/Google) ----
|
||||
beego.Router("/auth/third/list", &authctl.AuthThirdController{}, "get:List")
|
||||
beego.Router("/auth/third/:provider/authorize", &authctl.AuthThirdController{}, "get:Authorize")
|
||||
beego.Router("/auth/third/:provider/callback", &authctl.AuthThirdController{}, "get:Callback")
|
||||
beego.Router("/auth/third/bind", &authctl.AuthThirdController{}, "post:Bind")
|
||||
beego.Router("/auth/third/unbind", &authctl.AuthThirdController{}, "post:Unbind")
|
||||
beego.Router("/auth/third/bound", &authctl.AuthThirdController{}, "get:BoundList")
|
||||
// 自助页面(浏览器访问)
|
||||
beego.Router("/auth/bind", &authctl.AuthThirdController{}, "get:BindPage")
|
||||
beego.Router("/auth/devices", &authctl.AuthSessionController{}, "get:DevicesPage")
|
||||
beego.Router("/auth/devices/kick", &authctl.AuthSessionController{}, "get:KickPage")
|
||||
beego.Router("/auth/third/:provider/unbind", &authctl.AuthThirdController{}, "get:UnbindPage")
|
||||
|
||||
// ---- 登录与会话 ----
|
||||
// 注意:beego 同一路径重复 Router 会覆盖,故 /auth/logout 只注册一次
|
||||
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
|
||||
|
||||
@@ -14,6 +14,26 @@ func Register() {
|
||||
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
|
||||
RegisterWechatMpRoutes()
|
||||
|
||||
// 统一认证中心 - 租户登录策略配置
|
||||
beego.Router("/platform/authConfig/detail", &controllers.PlatformAuthConfigController{}, "get:Detail")
|
||||
beego.Router("/platform/authConfig/save", &controllers.PlatformAuthConfigController{}, "post:Save")
|
||||
beego.Router("/platform/authConfig/reset/:tid", &controllers.PlatformAuthConfigController{}, "delete:Reset")
|
||||
|
||||
// 统一认证中心 - 第三方登录配置(tid=0 全局 / tid>0 租户自带)
|
||||
beego.Router("/platform/authIdp/list", &controllers.PlatformAuthIdpController{}, "get:List")
|
||||
beego.Router("/platform/authIdp/create", &controllers.PlatformAuthIdpController{}, "post:Create")
|
||||
beego.Router("/platform/authIdp/edit/:id", &controllers.PlatformAuthIdpController{}, "post:Edit")
|
||||
beego.Router("/platform/authIdp/delete/:id", &controllers.PlatformAuthIdpController{}, "delete:Delete")
|
||||
beego.Router("/platform/authIdp/callbackHint", &controllers.PlatformAuthIdpController{}, "get:CallbackHint")
|
||||
|
||||
// 统一认证中心 - 接入应用(OIDC Client)管理
|
||||
beego.Router("/platform/authClient/list", &controllers.PlatformAuthClientController{}, "get:List")
|
||||
beego.Router("/platform/authClient/detail/:id", &controllers.PlatformAuthClientController{}, "get:Detail")
|
||||
beego.Router("/platform/authClient/create", &controllers.PlatformAuthClientController{}, "post:Create")
|
||||
beego.Router("/platform/authClient/edit/:id", &controllers.PlatformAuthClientController{}, "post:Edit")
|
||||
beego.Router("/platform/authClient/resetSecret/:id", &controllers.PlatformAuthClientController{}, "post:ResetSecret")
|
||||
beego.Router("/platform/authClient/delete/:id", &controllers.PlatformAuthClientController{}, "delete:Delete")
|
||||
|
||||
// 平台登录相关
|
||||
beego.Router("/platform/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
|
||||
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser")
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
// 全量迁移:把业务表中的老 uid(yz_system_tenant_user.uid)统一替换为
|
||||
// 认证中心的 identity_id(yz_auth_identity.id)。
|
||||
//
|
||||
// 背景:统一认证上线后,令牌里的 user_id 是 identity_id;而业务表的
|
||||
// uid / user_id / create_user_id / owner_user_id / uploader_id 等字段存的仍是
|
||||
// 老表 uid。不迁移的话用户会「找不到自己的数据」,因此做全量替换而非兼容层。
|
||||
//
|
||||
// 用法(需在 go/ 目录运行,读取 conf/app.conf):
|
||||
//
|
||||
// go run scripts/uidmigrate/migrate_uid.go -check 预检:列出受影响的表/列/行数
|
||||
// go run scripts/uidmigrate/migrate_uid.go -apply 执行迁移(记录变更日志,可回滚)
|
||||
// go run scripts/uidmigrate/migrate_uid.go -rollback 按变更日志回滚
|
||||
//
|
||||
// 说明:本脚本直接使用 database/sql,避免 beego ORM 的 Raw 取值差异。
|
||||
// 执行前请先自行备份数据库(mysqldump)。
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
beego "github.com/beego/beego/v2/server/web"
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
// tempOffset 中间值偏移:先把旧值搬到远离目标值的区间,避免新旧值重叠导致错改
|
||||
const tempOffset = 1000000000
|
||||
|
||||
// 需要处理的列名
|
||||
var uidColumns = []string{
|
||||
"uid", "user_id", "create_user_id", "update_user_id",
|
||||
"owner_user_id", "uploader_id", "operator_id", "tuid",
|
||||
}
|
||||
|
||||
// 不参与迁移的表
|
||||
var skipTables = map[string]bool{
|
||||
"yz_system_tenant_user": true,
|
||||
"yz_users": true,
|
||||
"yz_uid_migration_log": true,
|
||||
}
|
||||
|
||||
type colRef struct {
|
||||
Table string
|
||||
Col string
|
||||
}
|
||||
|
||||
var db *sql.DB
|
||||
|
||||
func main() {
|
||||
check := flag.Bool("check", false, "预检,不写数据")
|
||||
apply := flag.Bool("apply", false, "执行迁移")
|
||||
rollback := flag.Bool("rollback", false, "按变更日志回滚")
|
||||
flag.Parse()
|
||||
|
||||
if !*check && !*apply && !*rollback {
|
||||
fmt.Println("请指定 -check / -apply / -rollback")
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := beego.LoadAppConfig("ini", "conf/app.conf"); err != nil {
|
||||
log.Printf("加载 conf/app.conf 失败(若已自动加载可忽略): %v", err)
|
||||
}
|
||||
|
||||
var err error
|
||||
db, err = openDB()
|
||||
if err != nil {
|
||||
log.Fatalf("连接数据库失败: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
switch {
|
||||
case *check:
|
||||
runCheck()
|
||||
case *apply:
|
||||
runApply()
|
||||
case *rollback:
|
||||
runRollback()
|
||||
}
|
||||
}
|
||||
|
||||
func openDB() (*sql.DB, error) {
|
||||
user, _ := beego.AppConfig.String("mysqluser")
|
||||
pass, _ := beego.AppConfig.String("mysqlpass")
|
||||
urls, _ := beego.AppConfig.String("mysqlurls")
|
||||
name, _ := beego.AppConfig.String("mysqldb")
|
||||
if user == "" || urls == "" || name == "" {
|
||||
return nil, fmt.Errorf("数据库配置(mysqluser/mysqlurls/mysqldb) 未正确设置")
|
||||
}
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", user, pass, urls, name)
|
||||
return sql.Open("mysql", dsn)
|
||||
}
|
||||
|
||||
// buildMapping 建立「老 uid → identity_id」映射:同企业(tid) 下账号/手机/邮箱 一致。
|
||||
//
|
||||
// 注意:老表是 utf8mb4_0900_ai_ci、新表是 utf8mb4_unicode_ci,直接 JOIN 比较字符串
|
||||
// 会触发 Illegal mix of collations,因此改为在 Go 侧匹配。
|
||||
func buildMapping() (map[uint64]uint64, error) {
|
||||
legacy := make([]struct {
|
||||
uid uint64
|
||||
tid uint64
|
||||
account, phone string
|
||||
email string
|
||||
}, 0)
|
||||
rows, err := db.Query(
|
||||
"SELECT uid, tid, IFNULL(account,''), IFNULL(phone,''), IFNULL(email,'') FROM yz_system_tenant_user WHERE delete_time IS NULL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows.Next() {
|
||||
var it struct {
|
||||
uid uint64
|
||||
tid uint64
|
||||
account, phone string
|
||||
email string
|
||||
}
|
||||
if err := rows.Scan(&it.uid, &it.tid, &it.account, &it.phone, &it.email); err != nil {
|
||||
continue
|
||||
}
|
||||
legacy = append(legacy, it)
|
||||
}
|
||||
rows.Close()
|
||||
|
||||
index := map[string]uint64{}
|
||||
rows2, err := db.Query(
|
||||
"SELECT identity_id, tid, IFNULL(account,''), IFNULL(phone,''), IFNULL(email,'') FROM yz_auth_tenant_user WHERE delete_time IS NULL")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for rows2.Next() {
|
||||
var identity, tid uint64
|
||||
var account, phone, email string
|
||||
if err := rows2.Scan(&identity, &tid, &account, &phone, &email); err != nil {
|
||||
continue
|
||||
}
|
||||
if key := mergeKey(tid, account, phone, email); key != "" {
|
||||
index[key] = identity
|
||||
}
|
||||
}
|
||||
rows2.Close()
|
||||
|
||||
mapping := map[uint64]uint64{}
|
||||
for _, it := range legacy {
|
||||
key := mergeKey(it.tid, it.account, it.phone, it.email)
|
||||
if key == "" {
|
||||
continue
|
||||
}
|
||||
if id, ok := index[key]; ok && id > 0 {
|
||||
mapping[it.uid] = id
|
||||
}
|
||||
}
|
||||
return mapping, nil
|
||||
}
|
||||
|
||||
// mergeKey 归并键:企业ID + (手机号 > 邮箱 > 账号)
|
||||
func mergeKey(tid uint64, account, phone, email string) string {
|
||||
base := fmt.Sprintf("%d:", tid)
|
||||
if v := strings.TrimSpace(phone); v != "" {
|
||||
return base + "p:" + v
|
||||
}
|
||||
if v := strings.TrimSpace(email); v != "" {
|
||||
return base + "e:" + v
|
||||
}
|
||||
if v := strings.TrimSpace(account); v != "" {
|
||||
return base + "a:" + v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func findColumns() ([]colRef, error) {
|
||||
name, _ := beego.AppConfig.String("mysqldb")
|
||||
quoted := make([]string, 0, len(uidColumns))
|
||||
for _, c := range uidColumns {
|
||||
quoted = append(quoted, "'"+c+"'")
|
||||
}
|
||||
rows, err := db.Query(fmt.Sprintf(`
|
||||
SELECT TABLE_NAME, COLUMN_NAME
|
||||
FROM information_schema.COLUMNS
|
||||
WHERE TABLE_SCHEMA = '%s' AND COLUMN_NAME IN (%s)
|
||||
ORDER BY TABLE_NAME, COLUMN_NAME`, name, strings.Join(quoted, ",")))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make([]colRef, 0)
|
||||
total := 0
|
||||
for rows.Next() {
|
||||
var t, c string
|
||||
if err := rows.Scan(&t, &c); err != nil {
|
||||
continue
|
||||
}
|
||||
total++
|
||||
if skipTables[t] || strings.Contains(t, "_bak") || strings.HasPrefix(t, "yz_auth_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, colRef{Table: t, Col: c})
|
||||
}
|
||||
log.Printf("扫描到 %d 个承载用户ID的列(跳过 %d 个)", len(out), total-len(out))
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func runCheck() {
|
||||
mapping, err := buildMapping()
|
||||
if err != nil {
|
||||
log.Fatalf("建立映射失败: %v", err)
|
||||
}
|
||||
if len(mapping) == 0 {
|
||||
log.Fatal("没有建立任何 uid → identity_id 映射,请确认认证中心数据是否已迁移")
|
||||
}
|
||||
log.Printf("映射关系: %d 个老 uid", len(mapping))
|
||||
for old, newUID := range mapping {
|
||||
log.Printf(" uid %d → identity %d", old, newUID)
|
||||
}
|
||||
|
||||
cols, err := findColumns()
|
||||
if err != nil {
|
||||
log.Fatalf("扫描列失败: %v", err)
|
||||
}
|
||||
|
||||
in := joinKeys(mapping)
|
||||
total := int64(0)
|
||||
affected := 0
|
||||
for _, c := range cols {
|
||||
var cnt int64
|
||||
err := db.QueryRow(fmt.Sprintf(
|
||||
"SELECT COUNT(*) FROM `%s` WHERE `%s` IN (%s)", c.Table, c.Col, in)).Scan(&cnt)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if cnt > 0 {
|
||||
log.Printf(" %-42s %-18s %d 行", c.Table, c.Col, cnt)
|
||||
total += cnt
|
||||
affected++
|
||||
}
|
||||
}
|
||||
log.Printf("合计:%d 张表的列需要更新,约 %d 行(预检完成,未写入任何数据)", affected, total)
|
||||
}
|
||||
|
||||
func runApply() {
|
||||
mapping, err := buildMapping()
|
||||
if err != nil {
|
||||
log.Fatalf("建立映射失败: %v", err)
|
||||
}
|
||||
cols, err := findColumns()
|
||||
if err != nil {
|
||||
log.Fatalf("扫描列失败: %v", err)
|
||||
}
|
||||
ensureLogTable()
|
||||
|
||||
batch := time.Now().Format("20060102150405")
|
||||
changed := 0
|
||||
for _, c := range cols {
|
||||
for oldUID, newUID := range mapping {
|
||||
if oldUID == newUID {
|
||||
continue
|
||||
}
|
||||
// 阶段一:旧值 → 临时值;阶段二:临时值 → 新值
|
||||
cnt := execUpdate(c.Table, c.Col, oldUID, oldUID+tempOffset)
|
||||
if cnt == 0 {
|
||||
continue
|
||||
}
|
||||
execUpdate(c.Table, c.Col, oldUID+tempOffset, newUID)
|
||||
saveLog(batch, c.Table, c.Col, oldUID, newUID, cnt)
|
||||
changed++
|
||||
log.Printf(" %s.%s: %d → %d(%d 行)", c.Table, c.Col, oldUID, newUID, cnt)
|
||||
}
|
||||
}
|
||||
log.Printf("迁移完成:%d 处替换,批次号 %s", changed, batch)
|
||||
log.Println("如需回滚:go run scripts/uidmigrate/migrate_uid.go -rollback")
|
||||
}
|
||||
|
||||
func runRollback() {
|
||||
rows, err := db.Query(
|
||||
"SELECT table_name, column_name, old_value, new_value FROM yz_uid_migration_log ORDER BY id DESC")
|
||||
if err != nil {
|
||||
log.Fatalf("读取变更日志失败: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
type item struct {
|
||||
table, col string
|
||||
oldV, newV uint64
|
||||
}
|
||||
list := make([]item, 0)
|
||||
for rows.Next() {
|
||||
var it item
|
||||
if err := rows.Scan(&it.table, &it.col, &it.oldV, &it.newV); err != nil {
|
||||
continue
|
||||
}
|
||||
list = append(list, it)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
log.Println("没有可回滚的记录")
|
||||
return
|
||||
}
|
||||
for _, it := range list {
|
||||
// 反向:新值 → 临时值 → 旧值
|
||||
execUpdate(it.table, it.col, it.newV, it.newV+tempOffset)
|
||||
cnt := execUpdate(it.table, it.col, it.newV+tempOffset, it.oldV)
|
||||
log.Printf(" 回滚 %s.%s: %d → %d(%d 行)", it.table, it.col, it.newV, it.oldV, cnt)
|
||||
}
|
||||
log.Println("回滚完成")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 工具
|
||||
|
||||
func execUpdate(table, col string, from, to uint64) int64 {
|
||||
res, err := db.Exec(fmt.Sprintf(
|
||||
"UPDATE `%s` SET `%s` = ? WHERE `%s` = ?", table, col, col), to, from)
|
||||
if err != nil {
|
||||
log.Printf("更新失败 %s.%s (%d→%d): %v", table, col, from, to, err)
|
||||
return 0
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n
|
||||
}
|
||||
|
||||
func saveLog(batch, table, col string, oldV, newV uint64, rows int64) {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO yz_uid_migration_log (batch_no, table_name, column_name, old_value, new_value, row_count, create_time) VALUES (?,?,?,?,?,?,NOW())",
|
||||
batch, table, col, oldV, newV, rows)
|
||||
if err != nil {
|
||||
log.Printf("记录变更日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureLogTable() {
|
||||
_, _ = db.Exec(`CREATE TABLE IF NOT EXISTS yz_uid_migration_log (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
batch_no VARCHAR(32) NOT NULL,
|
||||
table_name VARCHAR(128) NOT NULL,
|
||||
column_name VARCHAR(64) NOT NULL,
|
||||
old_value BIGINT UNSIGNED NOT NULL,
|
||||
new_value BIGINT UNSIGNED NOT NULL,
|
||||
row_count BIGINT NOT NULL DEFAULT 0,
|
||||
create_time DATETIME NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_batch (batch_no)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`)
|
||||
}
|
||||
|
||||
func joinKeys(m map[uint64]uint64) string {
|
||||
parts := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
parts = append(parts, fmt.Sprintf("%d", k))
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return "0"
|
||||
}
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
+72
-73
@@ -1,73 +1,72 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
func NormalizeAccount(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func CreateAdminUser(account, password string, name, phone, email, qq, avatar *string, sex uint8, roleID uint64, status uint8) (uint64, error) {
|
||||
hashed, err := passwordutil.Hash(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
u := &models.AdminUser{
|
||||
Account: NormalizeAccount(account),
|
||||
Password: hashed,
|
||||
Name: name,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
Qq: qq,
|
||||
Avatar: avatar,
|
||||
Sex: sex,
|
||||
RoleID: roleID,
|
||||
Status: status,
|
||||
}
|
||||
id, err := models.Orm.Insert(u)
|
||||
return uint64(id), err
|
||||
}
|
||||
|
||||
func GetAdminUserByID(id uint64) (*models.AdminUser, error) {
|
||||
u := &models.AdminUser{ID: id}
|
||||
if err := models.Orm.Read(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func UpdateAdminUser(id uint64, fields map[string]interface{}) error {
|
||||
_, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Update(fields)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAdminUser(id uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func ChangeAdminUserPassword(id uint64, newPassword string) error {
|
||||
hashed, err := passwordutil.Hash(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Update(map[string]interface{}{
|
||||
"password": hashed,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ListAdminUsers() ([]models.AdminUser, int64, error) {
|
||||
var rows []models.AdminUser
|
||||
total, err := models.Orm.QueryTable(new(models.AdminUser)).Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.AdminUser)).OrderBy("-id").All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
func NormalizeAccount(s string) string {
|
||||
return strings.TrimSpace(s)
|
||||
}
|
||||
|
||||
func CreateAdminUser(account, password string, name, phone, email, qq, avatar *string, sex uint8, roleID uint64, status uint8) (uint64, error) {
|
||||
hashed, err := passwordutil.Hash(password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
u := &models.AdminUser{
|
||||
Account: NormalizeAccount(account),
|
||||
Password: hashed,
|
||||
Name: name,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
Qq: qq,
|
||||
Avatar: avatar,
|
||||
Sex: sex,
|
||||
RoleID: roleID,
|
||||
Status: status,
|
||||
}
|
||||
id, err := models.Orm.Insert(u)
|
||||
return uint64(id), err
|
||||
}
|
||||
|
||||
func GetAdminUserByID(id uint64) (*models.AdminUser, error) {
|
||||
u := &models.AdminUser{ID: id}
|
||||
if err := models.Orm.Read(u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func UpdateAdminUser(id uint64, fields map[string]interface{}) error {
|
||||
_, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Update(fields)
|
||||
return err
|
||||
}
|
||||
|
||||
func DeleteAdminUser(id uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
func ChangeAdminUserPassword(id uint64, newPassword string) error {
|
||||
hashed, err := passwordutil.Hash(newPassword)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.AdminUser)).Filter("id", id).Update(map[string]interface{}{
|
||||
"password": hashed,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func ListAdminUsers() ([]models.AdminUser, int64, error) {
|
||||
var rows []models.AdminUser
|
||||
total, err := models.Orm.QueryTable(new(models.AdminUser)).Count()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.AdminUser)).OrderBy("-id").All(&rows)
|
||||
return rows, total, err
|
||||
}
|
||||
|
||||
@@ -119,6 +119,25 @@ func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
|
||||
return &bind, nil
|
||||
}
|
||||
|
||||
// FindIdentityIDByAccount 按「企业 + 账号」查找认证中心身份ID。
|
||||
// 账号可以是企业内账号、手机号或邮箱(老登录入口按账号登录时使用)。
|
||||
func FindIdentityIDByAccount(tid uint64, account string) uint64 {
|
||||
account = strings.TrimSpace(account)
|
||||
if tid == 0 || account == "" {
|
||||
return 0
|
||||
}
|
||||
base := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("delete_time__isnull", true)
|
||||
var bind models.AuthTenantUser
|
||||
for _, field := range []string{"account", "phone", "email"} {
|
||||
if err := base.Filter(field, account).One(&bind); err == nil {
|
||||
return bind.IdentityID
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// BuildProfile 组装身份概要(含可选企业列表)
|
||||
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
|
||||
tenants, err := ListTenantOptions(m.ID)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Package idp 第三方身份源(微信/钉钉/飞书/QQ/GitHub/Google)统一适配层。
|
||||
//
|
||||
// 各平台 OAuth2 流程基本一致,差异只在端点地址、参数名与用户字段,
|
||||
// 因此用一份通用实现 + 预设配置覆盖,新增平台只需加一条 preset。
|
||||
package idp
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// UserInfo 第三方返回的标准化用户信息
|
||||
type UserInfo struct {
|
||||
OpenID string // 该平台内的唯一 ID(必填)
|
||||
UnionID string // 跨平台唯一 ID(微信/钉钉有,GitHub/Google 用 OpenID 代替)
|
||||
Nickname string
|
||||
Avatar string
|
||||
Raw string // 原始 JSON,便于排查
|
||||
}
|
||||
|
||||
// Config 一个第三方身份源的配置
|
||||
type Config struct {
|
||||
Provider string
|
||||
AuthURL string
|
||||
TokenURL string
|
||||
UserinfoURL string
|
||||
Scopes []string
|
||||
AppID string
|
||||
AppSecret string
|
||||
ProxyURL string // GitHub/Google 在国内服务器需要代理时填写
|
||||
|
||||
// 参数风格:微信/QQ 用 appid+secret,其余用 client_id+client_secret
|
||||
UseAppIDStyle bool
|
||||
// 用户信息接口风格:query=拼在 URL 上(微信/QQ),bearer=放 Authorization 头
|
||||
UserinfoStyle string
|
||||
|
||||
// 用户信息字段映射(一级 JSON 字段)
|
||||
OpenIDField string
|
||||
UnionIDField string
|
||||
NicknameField string
|
||||
AvatarField string
|
||||
}
|
||||
|
||||
// presets 各平台预设(端点与字段映射)
|
||||
var presets = map[string]Config{
|
||||
models.IdPWechat: {
|
||||
Provider: models.IdPWechat,
|
||||
AuthURL: "https://open.weixin.qq.com/connect/oauth2/authorize",
|
||||
TokenURL: "https://api.weixin.qq.com/sns/oauth2/access_token",
|
||||
UserinfoURL: "https://api.weixin.qq.com/sns/userinfo",
|
||||
Scopes: []string{"snsapi_userinfo"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
UnionIDField: "unionid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "headimgurl",
|
||||
},
|
||||
models.IdPQQ: {
|
||||
Provider: models.IdPQQ,
|
||||
AuthURL: "https://graph.qq.com/oauth2.0/authorize",
|
||||
TokenURL: "https://graph.qq.com/oauth2.0/token",
|
||||
UserinfoURL: "https://graph.qq.com/user/get_user_info",
|
||||
Scopes: []string{"get_user_info"},
|
||||
UseAppIDStyle: true,
|
||||
UserinfoStyle: "query",
|
||||
OpenIDField: "openid",
|
||||
NicknameField: "nickname",
|
||||
AvatarField: "figureurl_qq_2",
|
||||
},
|
||||
models.IdPGitHub: {
|
||||
Provider: models.IdPGitHub,
|
||||
AuthURL: "https://github.com/login/oauth/authorize",
|
||||
TokenURL: "https://github.com/login/oauth/access_token",
|
||||
UserinfoURL: "https://api.github.com/user",
|
||||
Scopes: []string{"read:user"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "id",
|
||||
NicknameField: "login",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
models.IdPGoogle: {
|
||||
Provider: models.IdPGoogle,
|
||||
AuthURL: "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
TokenURL: "https://oauth2.googleapis.com/token",
|
||||
UserinfoURL: "https://openidconnect.googleapis.com/v1/userinfo",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "sub",
|
||||
NicknameField: "name",
|
||||
AvatarField: "picture",
|
||||
},
|
||||
models.IdPDingTalk: {
|
||||
Provider: models.IdPDingTalk,
|
||||
AuthURL: "https://login.dingtalk.com/oauth2/auth",
|
||||
TokenURL: "https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
|
||||
UserinfoURL: "https://api.dingtalk.com/v1.0/contact/users/me",
|
||||
Scopes: []string{"openid", "profile"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "unionId",
|
||||
UnionIDField: "unionId",
|
||||
NicknameField: "nick",
|
||||
AvatarField: "avatarUrl",
|
||||
},
|
||||
models.IdPFeishu: {
|
||||
Provider: models.IdPFeishu,
|
||||
AuthURL: "https://open.feishu.cn/open-apis/authen/v1/authorize",
|
||||
TokenURL: "https://open.feishu.cn/open-apis/authen/v2/oauth/token",
|
||||
UserinfoURL: "https://open.feishu.cn/open-apis/authen/v1/user_info",
|
||||
Scopes: []string{"contact:user.base:readonly"},
|
||||
UserinfoStyle: "bearer",
|
||||
OpenIDField: "open_id",
|
||||
UnionIDField: "union_id",
|
||||
NicknameField: "name",
|
||||
AvatarField: "avatar_url",
|
||||
},
|
||||
}
|
||||
|
||||
// Supported 返回支持的第三方平台列表
|
||||
func Supported() []string {
|
||||
return []string{
|
||||
models.IdPWechat, models.IdPDingTalk, models.IdPFeishu,
|
||||
models.IdPQQ, models.IdPGitHub, models.IdPGoogle,
|
||||
}
|
||||
}
|
||||
|
||||
// IsSupported 是否为已知平台
|
||||
func IsSupported(provider string) bool {
|
||||
_, ok := presets[provider]
|
||||
return ok
|
||||
}
|
||||
|
||||
// LoadConfig 读取身份源配置:
|
||||
// - tid > 0 时优先取租户自带身份源(yz_auth_tenant_idp)
|
||||
// - 取不到或 tid=0 时取平台全局配置(tid=0 的记录)
|
||||
//
|
||||
// 未配置任何记录时返回 false,调用方应提示「该登录方式未开通」。
|
||||
func LoadConfig(provider string, tid uint64) (Config, bool) {
|
||||
base, ok := presets[provider]
|
||||
if !ok {
|
||||
return Config{}, false
|
||||
}
|
||||
|
||||
var row models.AuthTenantIdp
|
||||
qs := models.Orm.QueryTable(new(models.AuthTenantIdp)).
|
||||
Filter("provider", provider).
|
||||
Filter("status", 1)
|
||||
|
||||
if tid > 0 {
|
||||
if err := qs.Filter("tid", tid).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
}
|
||||
// 回落到平台全局配置(tid=0)
|
||||
if err := qs.Filter("tid", 0).One(&row); err == nil {
|
||||
return applyDBConfig(base, &row), true
|
||||
}
|
||||
return base, false
|
||||
}
|
||||
|
||||
func applyDBConfig(base Config, row *models.AuthTenantIdp) Config {
|
||||
if row.AppID != nil && strings.TrimSpace(*row.AppID) != "" {
|
||||
base.AppID = strings.TrimSpace(*row.AppID)
|
||||
}
|
||||
if row.AppSecret != nil && strings.TrimSpace(*row.AppSecret) != "" {
|
||||
base.AppSecret = strings.TrimSpace(*row.AppSecret)
|
||||
}
|
||||
if row.ProxyURL != nil && strings.TrimSpace(*row.ProxyURL) != "" {
|
||||
base.ProxyURL = strings.TrimSpace(*row.ProxyURL)
|
||||
}
|
||||
if row.AuthURL != nil && strings.TrimSpace(*row.AuthURL) != "" {
|
||||
base.AuthURL = strings.TrimSpace(*row.AuthURL)
|
||||
}
|
||||
if row.TokenURL != nil && strings.TrimSpace(*row.TokenURL) != "" {
|
||||
base.TokenURL = strings.TrimSpace(*row.TokenURL)
|
||||
}
|
||||
if row.UserinfoURL != nil && strings.TrimSpace(*row.UserinfoURL) != "" {
|
||||
base.UserinfoURL = strings.TrimSpace(*row.UserinfoURL)
|
||||
}
|
||||
if row.Scopes != nil && strings.TrimSpace(*row.Scopes) != "" {
|
||||
base.Scopes = splitList(*row.Scopes)
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func splitList(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if v := strings.TrimSpace(p); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BuildAuthURL 生成跳转到第三方授权页的地址
|
||||
func BuildAuthURL(cfg Config, redirectURI, state string) string {
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
}
|
||||
v.Set("redirect_uri", redirectURI)
|
||||
v.Set("response_type", "code")
|
||||
v.Set("scope", strings.Join(cfg.Scopes, ","))
|
||||
v.Set("state", state)
|
||||
|
||||
addr := cfg.AuthURL + "?" + v.Encode()
|
||||
if cfg.Provider == models.IdPWechat {
|
||||
// 微信要求在 hash 后带 #wechat_redirect
|
||||
return addr + "#wechat_redirect"
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// Exchange 用授权码换取用户信息
|
||||
func Exchange(cfg Config, code string) (*UserInfo, error) {
|
||||
if cfg.AppID == "" || cfg.AppSecret == "" {
|
||||
return nil, errors.New("该登录方式尚未配置,请联系管理员")
|
||||
}
|
||||
|
||||
// 1. code 换 access_token
|
||||
v := url.Values{}
|
||||
if cfg.UseAppIDStyle {
|
||||
v.Set("appid", cfg.AppID)
|
||||
v.Set("secret", cfg.AppSecret)
|
||||
} else {
|
||||
v.Set("client_id", cfg.AppID)
|
||||
v.Set("client_secret", cfg.AppSecret)
|
||||
}
|
||||
v.Set("code", code)
|
||||
v.Set("grant_type", "authorization_code")
|
||||
|
||||
tokenBody, err := post(cfg, cfg.TokenURL, v, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tokenMap := parseMap(tokenBody)
|
||||
accessToken := firstStr(tokenMap, "access_token")
|
||||
if accessToken == "" {
|
||||
return nil, fmt.Errorf("换取令牌失败: %s", truncate(tokenBody, 200))
|
||||
}
|
||||
openID := firstStr(tokenMap, "openid", "unionId", "open_id", "sub", "id")
|
||||
|
||||
// 2. 取用户信息
|
||||
var infoMap map[string]interface{}
|
||||
if cfg.UserinfoStyle == "query" {
|
||||
q := url.Values{}
|
||||
q.Set("access_token", accessToken)
|
||||
if openID != "" {
|
||||
q.Set("openid", openID)
|
||||
}
|
||||
// QQ 需要额外带 oauth_consumer_key
|
||||
if cfg.Provider == models.IdPQQ {
|
||||
q.Set("oauth_consumer_key", cfg.AppID)
|
||||
}
|
||||
body, err := get(cfg, cfg.UserinfoURL+"?"+q.Encode(), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
} else {
|
||||
body, err := get(cfg, cfg.UserinfoURL, map[string]string{
|
||||
"Authorization": "Bearer " + accessToken,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
infoMap = parseMap(body)
|
||||
// 飞书把数据包在 data 里
|
||||
if d, ok := infoMap["data"].(map[string]interface{}); ok {
|
||||
infoMap = d
|
||||
}
|
||||
}
|
||||
|
||||
info := &UserInfo{Raw: truncate(mustJSON(infoMap), 2000)}
|
||||
info.OpenID = strField(infoMap, cfg.OpenIDField)
|
||||
if cfg.UnionIDField != "" {
|
||||
info.UnionID = strField(infoMap, cfg.UnionIDField)
|
||||
}
|
||||
info.Nickname = strField(infoMap, cfg.NicknameField)
|
||||
info.Avatar = strField(infoMap, cfg.AvatarField)
|
||||
|
||||
if info.OpenID == "" && openID != "" {
|
||||
info.OpenID = openID
|
||||
}
|
||||
if info.OpenID == "" {
|
||||
return nil, errors.New("未能获取第三方账号标识")
|
||||
}
|
||||
if info.UnionID == "" {
|
||||
info.UnionID = info.OpenID
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- HTTP
|
||||
|
||||
func client(cfg Config) *http.Client {
|
||||
c := &http.Client{Timeout: 15 * time.Second}
|
||||
if cfg.ProxyURL != "" {
|
||||
if u, err := url.Parse(cfg.ProxyURL); err == nil {
|
||||
c.Transport = &http.Transport{Proxy: http.ProxyURL(u)}
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func post(cfg Config, target string, form url.Values, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("POST", target, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
if cfg.Provider == models.IdPGitHub {
|
||||
req.Header.Set("Accept", "application/json")
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
func get(cfg Config, target string, headers map[string]string) (string, error) {
|
||||
req, err := http.NewRequest("GET", target, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for k, v := range headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
resp, err := client(cfg).Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("请求第三方失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("第三方返回异常(%d): %s", resp.StatusCode, truncate(string(body), 200))
|
||||
}
|
||||
return string(body), nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 解析工具
|
||||
|
||||
// parseMap 兼容 JSON 与 querystring 两种响应(微信/QQ 早期接口返回 form 格式)
|
||||
func parseMap(body string) map[string]interface{} {
|
||||
out := map[string]interface{}{}
|
||||
if err := json.Unmarshal([]byte(body), &out); err == nil {
|
||||
return out
|
||||
}
|
||||
if values, err := url.ParseQuery(body); err == nil {
|
||||
for k, v := range values {
|
||||
if len(v) > 0 {
|
||||
out[k] = v[0]
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstStr(m map[string]interface{}, keys ...string) string {
|
||||
for _, k := range keys {
|
||||
if v, ok := m[k]; ok {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
if t != "" {
|
||||
return t
|
||||
}
|
||||
case float64:
|
||||
return fmt.Sprintf("%.0f", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func strField(m map[string]interface{}, field string) string {
|
||||
if field == "" {
|
||||
return ""
|
||||
}
|
||||
return firstStr(m, field)
|
||||
}
|
||||
|
||||
func mustJSON(m map[string]interface{}) string {
|
||||
b, _ := json.Marshal(m)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func truncate(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
)
|
||||
|
||||
@@ -127,9 +128,9 @@ func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginRes
|
||||
ClientID: clientID,
|
||||
Sid: sess.Sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
Amr: AmrPwd,
|
||||
AccessTTL: accessTTL,
|
||||
// 不指定 UserType:由 IssueTokens 按应用编码给出 backend / app
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -184,7 +185,6 @@ func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *Identit
|
||||
ClientID: clientID,
|
||||
Sid: sid,
|
||||
Username: profile.Nickname,
|
||||
UserType: "tenant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
@@ -262,7 +262,32 @@ func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, l
|
||||
return err
|
||||
}
|
||||
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌
|
||||
// Logout 登出:吊销刷新令牌、会话与访问令牌,并通知其他应用(单点登出)
|
||||
func Logout(accessToken, refreshToken string) error {
|
||||
return RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||
err := RevokeTokenPair(refreshToken, accessToken, models.RevokeReasonLogout)
|
||||
|
||||
// 单点登出:通知该用户在其他应用的会话(如后台登出后 APP 同步下线)
|
||||
identityID := uint64(0)
|
||||
if claims, e := jwtutil.ParseTokenRaw(accessToken); e == nil && claims.UserID > 0 {
|
||||
identityID = uint64(claims.UserID)
|
||||
}
|
||||
if identityID == 0 && refreshToken != "" {
|
||||
if rt, e := findRefreshToken(refreshToken); e == nil {
|
||||
identityID = rt.IdentityID
|
||||
}
|
||||
}
|
||||
if identityID > 0 {
|
||||
NotifyBackchannelLogout(identityID)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// findRefreshToken 按明文查找刷新令牌记录
|
||||
func findRefreshToken(plain string) (*models.AuthRefreshToken, error) {
|
||||
var rt models.AuthRefreshToken
|
||||
if err := models.Orm.QueryTable(new(models.AuthRefreshToken)).
|
||||
Filter("token_hash", hashToken(plain)).One(&rt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &rt, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
)
|
||||
|
||||
// BackchannelLogoutEvent OIDC 标准登出事件声明
|
||||
const BackchannelLogoutEvent = "http://schemas.openid.net/event/backchannel-logout"
|
||||
|
||||
// logoutTarget 需要收到登出通知的应用
|
||||
type logoutTarget struct {
|
||||
ClientID string
|
||||
Endpoint string
|
||||
Sid string
|
||||
Identity uint64
|
||||
}
|
||||
|
||||
// NotifyBackchannelLogout 单点登出:通知该用户已登录的所有应用。
|
||||
//
|
||||
// 流程:查库收集目标应用(同步)→ 异步逐个 POST logout_token。
|
||||
// 各应用收到后应清除本地会话,否则用户在这边登出了,其他应用仍显示已登录。
|
||||
//
|
||||
// 注意:查库必须在请求上下文内同步完成(beego 全局 Ormer 不适合跨 goroutine 使用),
|
||||
// 异步部分只做 HTTP 通知,不再触碰数据库。
|
||||
func NotifyBackchannelLogout(identityID uint64) {
|
||||
targets, err := collectLogoutTargets(identityID)
|
||||
if err != nil || len(targets) == 0 {
|
||||
return
|
||||
}
|
||||
go func() {
|
||||
for _, t := range targets {
|
||||
if err := sendLogoutToken(t); err != nil {
|
||||
log.Printf("[auth] 单点登出通知失败 client=%s: %v", t.ClientID, err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// collectLogoutTargets 收集该用户当前活跃会话涉及的应用(按 client_id 去重)
|
||||
func collectLogoutTargets(identityID uint64) ([]logoutTarget, error) {
|
||||
var sessions []models.AuthSession
|
||||
if _, err := models.Orm.QueryTable(new(models.AuthSession)).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("revoked", 0).
|
||||
All(&sessions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
targets := make([]logoutTarget, 0)
|
||||
for _, s := range sessions {
|
||||
if s.ClientID == "" || seen[s.ClientID] {
|
||||
continue
|
||||
}
|
||||
var client models.AuthClient
|
||||
if err := models.Orm.QueryTable(new(models.AuthClient)).
|
||||
Filter("client_id", s.ClientID).One(&client); err != nil {
|
||||
continue
|
||||
}
|
||||
if client.BackchannelLogoutURI == nil || strings.TrimSpace(*client.BackchannelLogoutURI) == "" {
|
||||
continue
|
||||
}
|
||||
seen[s.ClientID] = true
|
||||
targets = append(targets, logoutTarget{
|
||||
ClientID: s.ClientID,
|
||||
Endpoint: strings.TrimSpace(*client.BackchannelLogoutURI),
|
||||
Sid: s.Sid,
|
||||
Identity: identityID,
|
||||
})
|
||||
}
|
||||
return targets, nil
|
||||
}
|
||||
|
||||
// sendLogoutToken 按 OIDC Back-Channel Logout 规范发送 logout_token
|
||||
func sendLogoutToken(t logoutTarget) error {
|
||||
token, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(t.Identity),
|
||||
Subject: fmt.Sprintf("%d", t.Identity),
|
||||
Audience: []string{t.ClientID},
|
||||
ClientID: t.ClientID,
|
||||
Sid: t.Sid,
|
||||
Jti: uuid.NewString(),
|
||||
Events: map[string]interface{}{BackchannelLogoutEvent: map[string]interface{}{}},
|
||||
TTL: 5 * time.Minute,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("logout_token", token)
|
||||
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.PostForm(t.Endpoint, form)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("应用返回状态码 %d", resp.StatusCode)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -46,6 +47,10 @@ type TokenIssue struct {
|
||||
|
||||
// IssueTokens 签发访问令牌与刷新令牌。
|
||||
// 刷新令牌明文只在本次返回,库中仅存哈希。
|
||||
//
|
||||
// 令牌中的 user_id 为认证中心 identity_id(yz_auth_identity.id)。
|
||||
// 业务表(文件/客户/合同/日程等)中的 uid 已由 scripts/uidmigrate 全量迁移为
|
||||
// 同一套 ID,因此无需任何兼容换算。
|
||||
func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
accessTTL := opt.AccessTTL
|
||||
if accessTTL <= 0 {
|
||||
@@ -56,13 +61,27 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) {
|
||||
refreshTTL = 2592000
|
||||
}
|
||||
|
||||
// 全量迁移后,令牌中的 user_id 统一为认证中心的 identity_id
|
||||
userID := int(opt.IdentityID)
|
||||
|
||||
// userType 需与现有业务接口的判定保持一致:
|
||||
// 后端接口普遍要求 user_type 为 "backend"(租户后台)或 "app"(移动端),
|
||||
// 若签发 "tenant" 会被判为无权访问。这里按应用编码给出默认值。
|
||||
userType := opt.UserType
|
||||
if userType == "" {
|
||||
userType = "backend"
|
||||
if opt.ClientID == "yz-uniapp" {
|
||||
userType = "app"
|
||||
}
|
||||
}
|
||||
|
||||
jti := uuid.NewString()
|
||||
access, err := jwtutil.SignToken(jwtutil.TokenOptions{
|
||||
Alg: jwtutil.AlgRS256,
|
||||
UserID: int(opt.IdentityID),
|
||||
UserID: userID,
|
||||
Username: opt.Username,
|
||||
TenantID: int(opt.Tid),
|
||||
UserType: opt.UserType,
|
||||
UserType: userType,
|
||||
ClientID: opt.ClientID,
|
||||
Sid: opt.Sid,
|
||||
Amr: opt.Amr,
|
||||
@@ -159,6 +178,46 @@ func RefreshTokens(plain, clientID string) (*TokenPair, error) {
|
||||
return pair, nil
|
||||
}
|
||||
|
||||
// FindLegacyUID 通过「身份 + 企业」在老表 yz_system_tenant_user 中找到对应 uid。
|
||||
//
|
||||
// 双轨期(老登录与统一认证并行)下业务接口仍以老 uid 识别用户,
|
||||
// 因此签发令牌、返回用户信息时都要换算回老 uid。
|
||||
// 匹配顺序:账号 → 手机号 → 邮箱;都匹配不到返回 0。
|
||||
func FindLegacyUID(tid, identityID uint64) uint64 {
|
||||
if tid == 0 || identityID == 0 {
|
||||
return 0
|
||||
}
|
||||
var bind models.AuthTenantUser
|
||||
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("identity_id", identityID).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&bind); err != nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
qs := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("tid", tid)
|
||||
match := func(field string, value *string) uint64 {
|
||||
if value == nil || strings.TrimSpace(*value) == "" {
|
||||
return 0
|
||||
}
|
||||
var row models.SystemTenantUser
|
||||
if err := qs.Filter(field, strings.TrimSpace(*value)).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&row); err == nil {
|
||||
return row.Uid
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if uid := match("account", bind.Account); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
if uid := match("phone", bind.Phone); uid > 0 {
|
||||
return uid
|
||||
}
|
||||
return match("email", bind.Email)
|
||||
}
|
||||
|
||||
// RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
|
||||
func RevokeTokenPair(plain, accessToken, reason string) error {
|
||||
if plain != "" {
|
||||
|
||||
@@ -19,10 +19,10 @@ import (
|
||||
|
||||
// McpSession 一次已建立的 MCP 连接会话
|
||||
type McpSession struct {
|
||||
Server models.BackendMcpServer
|
||||
Client *mcpclient.Client
|
||||
Tools []models.McpToolInfo
|
||||
Finger string // 配置指纹,配置变更时自动重连
|
||||
Server models.BackendMcpServer
|
||||
Client *mcpclient.Client
|
||||
Tools []models.McpToolInfo
|
||||
Finger string // 配置指纹,配置变更时自动重连
|
||||
}
|
||||
|
||||
// McpManager MCP 客户端管理器(全局单例,带连接缓存)
|
||||
|
||||
@@ -832,14 +832,14 @@ type OaDocSaveParams struct {
|
||||
Tags string
|
||||
Summary string
|
||||
// Content 在线制作文档的编辑源(富文本 HTML),SetContent 为 true 时才覆盖
|
||||
SetContent bool
|
||||
Content string
|
||||
Status int8
|
||||
Version int
|
||||
OwnerID uint64
|
||||
OwnerName string
|
||||
IsStar int8
|
||||
Visibility int8 // 0-租户公开 1-私密
|
||||
SetContent bool
|
||||
Content string
|
||||
Status int8
|
||||
Version int
|
||||
OwnerID uint64
|
||||
OwnerName string
|
||||
IsStar int8
|
||||
Visibility int8 // 0-租户公开 1-私密
|
||||
}
|
||||
|
||||
// OaDocCreate 新增文档。
|
||||
|
||||
+50
-51
@@ -1,51 +1,50 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// CheckUserPermission 校验用户是否拥有指定权限标识。
|
||||
// 兼容 rights 为 JSON 数组 / 逗号分隔字符串;解析失败时默认放行,避免历史数据阻断请求。
|
||||
func CheckUserPermission(userID int, permission string) (bool, error) {
|
||||
if permission == "" || userID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", userID).One(&user); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var role models.AdminRole
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).Filter("id", user.RoleID).One(&role); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if role.Rights == nil || strings.TrimSpace(*role.Rights) == "" {
|
||||
return true, nil
|
||||
}
|
||||
rightsRaw := strings.TrimSpace(*role.Rights)
|
||||
|
||||
// 1) JSON 数组格式
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(rightsRaw), &arr); err == nil {
|
||||
for _, p := range arr {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 2) 逗号分隔字符串
|
||||
for _, p := range strings.Split(rightsRaw, ",") {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// CheckUserPermission 校验用户是否拥有指定权限标识。
|
||||
// 兼容 rights 为 JSON 数组 / 逗号分隔字符串;解析失败时默认放行,避免历史数据阻断请求。
|
||||
func CheckUserPermission(userID int, permission string) (bool, error) {
|
||||
if permission == "" || userID <= 0 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var user models.AdminUser
|
||||
if err := models.Orm.QueryTable(new(models.AdminUser)).Filter("id", userID).One(&user); err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var role models.AdminRole
|
||||
if err := models.Orm.QueryTable(new(models.AdminRole)).Filter("id", user.RoleID).One(&role); err != nil {
|
||||
return false, err
|
||||
}
|
||||
if role.Rights == nil || strings.TrimSpace(*role.Rights) == "" {
|
||||
return true, nil
|
||||
}
|
||||
rightsRaw := strings.TrimSpace(*role.Rights)
|
||||
|
||||
// 1) JSON 数组格式
|
||||
var arr []string
|
||||
if err := json.Unmarshal([]byte(rightsRaw), &arr); err == nil {
|
||||
for _, p := range arr {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// 2) 逗号分隔字符串
|
||||
for _, p := range strings.Split(rightsRaw, ",") {
|
||||
if strings.TrimSpace(p) == permission {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"server/models"
|
||||
"server/pkg/jwtutil"
|
||||
"server/pkg/passwordutil"
|
||||
authsvc "server/services/auth"
|
||||
)
|
||||
|
||||
type PlatformLoginUser struct {
|
||||
@@ -133,7 +134,14 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
|
||||
|
||||
tenantID := int(tenant.ID)
|
||||
const userType = "backend"
|
||||
token, err := jwtutil.GenerateToken(int(tenantUser.Uid), account, tenantID, userType)
|
||||
|
||||
// 令牌中的 user_id 统一使用认证中心 identity_id(与统一登录签发的一致)。
|
||||
// 业务表的 uid 已全量迁移为同一套 ID,因此老登录入口无需停用也不会错位。
|
||||
userID := int(tenantUser.Uid)
|
||||
if identityID := authsvc.FindIdentityIDByAccount(tenant.ID, account); identityID > 0 {
|
||||
userID = int(identityID)
|
||||
}
|
||||
token, err := jwtutil.GenerateToken(userID, account, tenantID, userType)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
|
||||
+220
-220
@@ -1,220 +1,220 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SMTPConfig 发送邮件所需参数(与 yz_system_email 字段对应)
|
||||
type SMTPConfig struct {
|
||||
FromAddress string
|
||||
FromName string
|
||||
Host string
|
||||
Port uint
|
||||
Password string
|
||||
Encryption string // ssl / tls / none
|
||||
Timeout uint // 秒
|
||||
}
|
||||
|
||||
// SendTestEmailSMTP 发送一封简单测试邮件(纯文本 UTF-8)
|
||||
func SendTestEmailSMTP(cfg SMTPConfig, to string) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("收件人不能为空")
|
||||
}
|
||||
if cfg.Host == "" || cfg.FromAddress == "" {
|
||||
return fmt.Errorf("SMTP 主机或发件人不能为空")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 465
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
d := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10))
|
||||
enc := strings.ToLower(strings.TrimSpace(cfg.Encryption))
|
||||
if enc == "" {
|
||||
enc = "ssl"
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
var err error
|
||||
|
||||
switch enc {
|
||||
case "ssl":
|
||||
conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
case "tls":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
_ = client.Close()
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP 认证失败: %w", err)
|
||||
}
|
||||
if err = client.Mail(cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("MAIL FROM 失败: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO 失败: %w", err)
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA 失败: %w", err)
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
subject := "平台邮箱测试"
|
||||
body := "这是一封来自管理后台「邮箱管理」的测试邮件。\r\nThis is a test email from the platform email settings.\r\n"
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||
formatFromHeader(fromName, cfg.FromAddress), to, subject)
|
||||
if _, err = wc.Write([]byte(headers + body)); err != nil {
|
||||
return fmt.Errorf("写入邮件内容失败: %w", err)
|
||||
}
|
||||
if err = wc.Close(); err != nil {
|
||||
return fmt.Errorf("结束 DATA 失败: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// SendHTMLEmailSMTP 发送一封 HTML 格式邮件
|
||||
func SendHTMLEmailSMTP(cfg SMTPConfig, to string, subject string, htmlBody string) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("收件人不能为空")
|
||||
}
|
||||
if cfg.Host == "" || cfg.FromAddress == "" {
|
||||
return fmt.Errorf("SMTP 主机或发件人不能为空")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 465
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
d := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10))
|
||||
enc := strings.ToLower(strings.TrimSpace(cfg.Encryption))
|
||||
if enc == "" {
|
||||
enc = "ssl"
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
var err error
|
||||
|
||||
switch enc {
|
||||
case "ssl":
|
||||
conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
case "tls":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
_ = client.Close()
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP 认证失败: %w", err)
|
||||
}
|
||||
if err = client.Mail(cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("MAIL FROM 失败: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO 失败: %w", err)
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA 失败: %w", err)
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||
formatFromHeader(fromName, cfg.FromAddress), to, subject)
|
||||
if _, err = wc.Write([]byte(headers + htmlBody)); err != nil {
|
||||
return fmt.Errorf("写入邮件内容失败: %w", err)
|
||||
}
|
||||
if err = wc.Close(); err != nil {
|
||||
return fmt.Errorf("结束 DATA 失败: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func formatFromHeader(name, addr string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return addr
|
||||
}
|
||||
return fmt.Sprintf("%s <%s>", name, addr)
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/smtp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SMTPConfig 发送邮件所需参数(与 yz_system_email 字段对应)
|
||||
type SMTPConfig struct {
|
||||
FromAddress string
|
||||
FromName string
|
||||
Host string
|
||||
Port uint
|
||||
Password string
|
||||
Encryption string // ssl / tls / none
|
||||
Timeout uint // 秒
|
||||
}
|
||||
|
||||
// SendTestEmailSMTP 发送一封简单测试邮件(纯文本 UTF-8)
|
||||
func SendTestEmailSMTP(cfg SMTPConfig, to string) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("收件人不能为空")
|
||||
}
|
||||
if cfg.Host == "" || cfg.FromAddress == "" {
|
||||
return fmt.Errorf("SMTP 主机或发件人不能为空")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 465
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
d := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10))
|
||||
enc := strings.ToLower(strings.TrimSpace(cfg.Encryption))
|
||||
if enc == "" {
|
||||
enc = "ssl"
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
var err error
|
||||
|
||||
switch enc {
|
||||
case "ssl":
|
||||
conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
case "tls":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
_ = client.Close()
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP 认证失败: %w", err)
|
||||
}
|
||||
if err = client.Mail(cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("MAIL FROM 失败: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO 失败: %w", err)
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA 失败: %w", err)
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
subject := "平台邮箱测试"
|
||||
body := "这是一封来自管理后台「邮箱管理」的测试邮件。\r\nThis is a test email from the platform email settings.\r\n"
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/plain; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||
formatFromHeader(fromName, cfg.FromAddress), to, subject)
|
||||
if _, err = wc.Write([]byte(headers + body)); err != nil {
|
||||
return fmt.Errorf("写入邮件内容失败: %w", err)
|
||||
}
|
||||
if err = wc.Close(); err != nil {
|
||||
return fmt.Errorf("结束 DATA 失败: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// SendHTMLEmailSMTP 发送一封 HTML 格式邮件
|
||||
func SendHTMLEmailSMTP(cfg SMTPConfig, to string, subject string, htmlBody string) error {
|
||||
to = strings.TrimSpace(to)
|
||||
if to == "" {
|
||||
return fmt.Errorf("收件人不能为空")
|
||||
}
|
||||
if cfg.Host == "" || cfg.FromAddress == "" {
|
||||
return fmt.Errorf("SMTP 主机或发件人不能为空")
|
||||
}
|
||||
if cfg.Port == 0 {
|
||||
cfg.Port = 465
|
||||
}
|
||||
timeout := cfg.Timeout
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
d := net.Dialer{Timeout: time.Duration(timeout) * time.Second}
|
||||
addr := net.JoinHostPort(cfg.Host, strconv.FormatUint(uint64(cfg.Port), 10))
|
||||
enc := strings.ToLower(strings.TrimSpace(cfg.Encryption))
|
||||
if enc == "" {
|
||||
enc = "ssl"
|
||||
}
|
||||
|
||||
var client *smtp.Client
|
||||
var err error
|
||||
|
||||
switch enc {
|
||||
case "ssl":
|
||||
conn, derr := tls.DialWithDialer(&d, "tcp", addr, &tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12})
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
case "tls":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: cfg.Host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
_ = client.Close()
|
||||
return fmt.Errorf("STARTTLS 失败: %w", err)
|
||||
}
|
||||
}
|
||||
case "none":
|
||||
conn, derr := d.Dial("tcp", addr)
|
||||
if derr != nil {
|
||||
return fmt.Errorf("连接 SMTP 失败: %w", derr)
|
||||
}
|
||||
defer conn.Close()
|
||||
client, err = smtp.NewClient(conn, cfg.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SMTP 握手失败: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("不支持的加密方式: %s", cfg.Encryption)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", cfg.FromAddress, cfg.Password, cfg.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("SMTP 认证失败: %w", err)
|
||||
}
|
||||
if err = client.Mail(cfg.FromAddress); err != nil {
|
||||
return fmt.Errorf("MAIL FROM 失败: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("RCPT TO 失败: %w", err)
|
||||
}
|
||||
wc, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("DATA 失败: %w", err)
|
||||
}
|
||||
fromName := strings.TrimSpace(cfg.FromName)
|
||||
headers := fmt.Sprintf("From: %s\r\nTo: %s\r\nSubject: %s\r\nMIME-Version: 1.0\r\nContent-Type: text/html; charset=UTF-8\r\nContent-Transfer-Encoding: 8bit\r\n\r\n",
|
||||
formatFromHeader(fromName, cfg.FromAddress), to, subject)
|
||||
if _, err = wc.Write([]byte(headers + htmlBody)); err != nil {
|
||||
return fmt.Errorf("写入邮件内容失败: %w", err)
|
||||
}
|
||||
if err = wc.Close(); err != nil {
|
||||
return fmt.Errorf("结束 DATA 失败: %w", err)
|
||||
}
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
func formatFromHeader(name, addr string) string {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return addr
|
||||
}
|
||||
return fmt.Sprintf("%s <%s>", name, addr)
|
||||
}
|
||||
|
||||
+135
-135
@@ -1,135 +1,135 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// ListSystemEmails 返回从 yz_platform_normal_setting 组装的邮箱配置(切片,通常仅一条)
|
||||
func ListSystemEmails() ([]models.SystemEmail, error) {
|
||||
enabledStr := models.GetPlatformSettingValue("email_enabled", "0")
|
||||
fromAddress := models.GetPlatformSettingValue("email_from_address", "")
|
||||
fromName := models.GetPlatformSettingValue("email_from_name", "")
|
||||
host := models.GetPlatformSettingValue("email_host", "")
|
||||
portStr := models.GetPlatformSettingValue("email_port", "465")
|
||||
password := models.GetPlatformSettingValue("email_password", "")
|
||||
encryption := models.GetPlatformSettingValue("email_encryption", "ssl")
|
||||
timeoutStr := models.GetPlatformSettingValue("email_timeout", "30")
|
||||
|
||||
status := int8(0)
|
||||
if enabledStr == "1" {
|
||||
status = 1
|
||||
}
|
||||
portVal, _ := strconv.ParseUint(portStr, 10, 32)
|
||||
timeoutVal, _ := strconv.ParseUint(timeoutStr, 10, 32)
|
||||
|
||||
row := models.SystemEmail{
|
||||
ID: 1,
|
||||
FromAddress: fromAddress,
|
||||
Host: host,
|
||||
Port: uint(portVal),
|
||||
Password: password,
|
||||
Encryption: encryption,
|
||||
Timeout: uint(timeoutVal),
|
||||
Status: status,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
if fromName != "" {
|
||||
row.FromName = &fromName
|
||||
}
|
||||
|
||||
return []models.SystemEmail{row}, nil
|
||||
}
|
||||
|
||||
// UpsertFirstSystemEmail 将邮箱配置保存到 yz_platform_normal_setting 表中
|
||||
func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, port uint, password string, encryption string, timeout uint, status int8, remark *string) error {
|
||||
if encryption == "" {
|
||||
encryption = "ssl"
|
||||
}
|
||||
if port == 0 {
|
||||
port = 465
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
fromAddress = strings.TrimSpace(fromAddress)
|
||||
host = strings.TrimSpace(host)
|
||||
|
||||
fn := ""
|
||||
if fromName != nil {
|
||||
fn = *fromName
|
||||
}
|
||||
|
||||
statusStr := "0"
|
||||
if status == 1 {
|
||||
statusStr = "1"
|
||||
}
|
||||
|
||||
settings := []struct {
|
||||
code string
|
||||
name string
|
||||
value string
|
||||
remark string
|
||||
}{
|
||||
{"email_enabled", "邮件服务启用状态", statusStr, "0为关闭,1为开启"},
|
||||
{"email_from_address", "发件人邮箱", fromAddress, ""},
|
||||
{"email_from_name", "发件人名称", fn, ""},
|
||||
{"email_host", "SMTP 服务器地址", host, ""},
|
||||
{"email_port", "SMTP 端口", strconv.FormatUint(uint64(port), 10), ""},
|
||||
{"email_encryption", "邮件加密方式", encryption, "支持 ssl/tls/none"},
|
||||
{"email_timeout", "邮件发送超时时间", strconv.FormatUint(uint64(timeout), 10), ""},
|
||||
}
|
||||
|
||||
// 如果传入了新密码,或者目前还没有保存过密码,才更新密码
|
||||
if strings.TrimSpace(password) != "" {
|
||||
settings = append(settings, struct {
|
||||
code string
|
||||
name string
|
||||
value string
|
||||
remark string
|
||||
}{"email_password", "邮件授权码/密码", strings.TrimSpace(password), ""})
|
||||
} else {
|
||||
// 校验:如果完全没有配置过密码,必须填写密码
|
||||
existingPass := models.GetPlatformSettingValue("email_password", "")
|
||||
if existingPass == "" {
|
||||
return fmt.Errorf("首次保存必须填写授权码/密码")
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range settings {
|
||||
var setting models.PlatformNormalSetting
|
||||
err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
|
||||
Filter("code", item.code).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&setting)
|
||||
if err == nil {
|
||||
setting.Value = item.value
|
||||
setting.Name = item.name
|
||||
setting.Remark = item.remark
|
||||
now := time.Now()
|
||||
setting.UpdateTime = &now
|
||||
_, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
newSetting := models.PlatformNormalSetting{
|
||||
Name: item.name,
|
||||
Code: item.code,
|
||||
Value: item.value,
|
||||
Remark: item.remark,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
_, err = models.Orm.Insert(&newSetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// ListSystemEmails 返回从 yz_platform_normal_setting 组装的邮箱配置(切片,通常仅一条)
|
||||
func ListSystemEmails() ([]models.SystemEmail, error) {
|
||||
enabledStr := models.GetPlatformSettingValue("email_enabled", "0")
|
||||
fromAddress := models.GetPlatformSettingValue("email_from_address", "")
|
||||
fromName := models.GetPlatformSettingValue("email_from_name", "")
|
||||
host := models.GetPlatformSettingValue("email_host", "")
|
||||
portStr := models.GetPlatformSettingValue("email_port", "465")
|
||||
password := models.GetPlatformSettingValue("email_password", "")
|
||||
encryption := models.GetPlatformSettingValue("email_encryption", "ssl")
|
||||
timeoutStr := models.GetPlatformSettingValue("email_timeout", "30")
|
||||
|
||||
status := int8(0)
|
||||
if enabledStr == "1" {
|
||||
status = 1
|
||||
}
|
||||
portVal, _ := strconv.ParseUint(portStr, 10, 32)
|
||||
timeoutVal, _ := strconv.ParseUint(timeoutStr, 10, 32)
|
||||
|
||||
row := models.SystemEmail{
|
||||
ID: 1,
|
||||
FromAddress: fromAddress,
|
||||
Host: host,
|
||||
Port: uint(portVal),
|
||||
Password: password,
|
||||
Encryption: encryption,
|
||||
Timeout: uint(timeoutVal),
|
||||
Status: status,
|
||||
CreateTime: time.Now(),
|
||||
UpdateTime: time.Now(),
|
||||
}
|
||||
if fromName != "" {
|
||||
row.FromName = &fromName
|
||||
}
|
||||
|
||||
return []models.SystemEmail{row}, nil
|
||||
}
|
||||
|
||||
// UpsertFirstSystemEmail 将邮箱配置保存到 yz_platform_normal_setting 表中
|
||||
func UpsertFirstSystemEmail(fromAddress string, fromName *string, host string, port uint, password string, encryption string, timeout uint, status int8, remark *string) error {
|
||||
if encryption == "" {
|
||||
encryption = "ssl"
|
||||
}
|
||||
if port == 0 {
|
||||
port = 465
|
||||
}
|
||||
if timeout == 0 {
|
||||
timeout = 30
|
||||
}
|
||||
fromAddress = strings.TrimSpace(fromAddress)
|
||||
host = strings.TrimSpace(host)
|
||||
|
||||
fn := ""
|
||||
if fromName != nil {
|
||||
fn = *fromName
|
||||
}
|
||||
|
||||
statusStr := "0"
|
||||
if status == 1 {
|
||||
statusStr = "1"
|
||||
}
|
||||
|
||||
settings := []struct {
|
||||
code string
|
||||
name string
|
||||
value string
|
||||
remark string
|
||||
}{
|
||||
{"email_enabled", "邮件服务启用状态", statusStr, "0为关闭,1为开启"},
|
||||
{"email_from_address", "发件人邮箱", fromAddress, ""},
|
||||
{"email_from_name", "发件人名称", fn, ""},
|
||||
{"email_host", "SMTP 服务器地址", host, ""},
|
||||
{"email_port", "SMTP 端口", strconv.FormatUint(uint64(port), 10), ""},
|
||||
{"email_encryption", "邮件加密方式", encryption, "支持 ssl/tls/none"},
|
||||
{"email_timeout", "邮件发送超时时间", strconv.FormatUint(uint64(timeout), 10), ""},
|
||||
}
|
||||
|
||||
// 如果传入了新密码,或者目前还没有保存过密码,才更新密码
|
||||
if strings.TrimSpace(password) != "" {
|
||||
settings = append(settings, struct {
|
||||
code string
|
||||
name string
|
||||
value string
|
||||
remark string
|
||||
}{"email_password", "邮件授权码/密码", strings.TrimSpace(password), ""})
|
||||
} else {
|
||||
// 校验:如果完全没有配置过密码,必须填写密码
|
||||
existingPass := models.GetPlatformSettingValue("email_password", "")
|
||||
if existingPass == "" {
|
||||
return fmt.Errorf("首次保存必须填写授权码/密码")
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range settings {
|
||||
var setting models.PlatformNormalSetting
|
||||
err := models.Orm.QueryTable(new(models.PlatformNormalSetting)).
|
||||
Filter("code", item.code).
|
||||
Filter("delete_time__isnull", true).
|
||||
One(&setting)
|
||||
if err == nil {
|
||||
setting.Value = item.value
|
||||
setting.Name = item.name
|
||||
setting.Remark = item.remark
|
||||
now := time.Now()
|
||||
setting.UpdateTime = &now
|
||||
_, err = models.Orm.Update(&setting, "Value", "Name", "Remark", "UpdateTime")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
newSetting := models.PlatformNormalSetting{
|
||||
Name: item.name,
|
||||
Code: item.code,
|
||||
Value: item.value,
|
||||
Remark: item.remark,
|
||||
CreateTime: time.Now(),
|
||||
}
|
||||
_, err = models.Orm.Insert(&newSetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
+532
-532
File diff suppressed because it is too large
Load Diff
+144
-144
@@ -1,144 +1,144 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值)
|
||||
func BindTenantUser(tid, uid uint64, account, name, phone, email *string, sex *uint8, birth *string, password *string, isDefault, status int8, remark *string) (uint64, error) {
|
||||
var existed models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", uid).
|
||||
One(&existed)
|
||||
if err == nil {
|
||||
update := map[string]interface{}{
|
||||
"account": account,
|
||||
"name": name,
|
||||
"phone": phone,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"status": status,
|
||||
"is_default": isDefault,
|
||||
"remark": remark,
|
||||
}
|
||||
if sex != nil {
|
||||
update["sex"] = *sex
|
||||
}
|
||||
if birth != nil {
|
||||
trimmedBirth := strings.TrimSpace(*birth)
|
||||
if trimmedBirth == "" {
|
||||
update["birth"] = nil
|
||||
} else {
|
||||
update["birth"] = trimmedBirth
|
||||
}
|
||||
}
|
||||
_, uErr := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", existed.ID).Update(update)
|
||||
return existed.ID, uErr
|
||||
}
|
||||
|
||||
m := &models.SystemTenantUser{
|
||||
Tid: tid,
|
||||
Uid: uid,
|
||||
Account: account,
|
||||
Name: name,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
Password: password,
|
||||
IsDefault: isDefault,
|
||||
Status: status,
|
||||
Remark: remark,
|
||||
}
|
||||
if sex != nil {
|
||||
m.Sex = *sex
|
||||
}
|
||||
if birth != nil {
|
||||
trimmedBirth := strings.TrimSpace(*birth)
|
||||
if trimmedBirth != "" {
|
||||
m.Birth = &trimmedBirth
|
||||
}
|
||||
}
|
||||
id, iErr := models.Orm.Insert(m)
|
||||
return uint64(id), iErr
|
||||
}
|
||||
|
||||
// UnbindTenantUser 删除绑定关系
|
||||
func UnbindTenantUser(id uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTenantUsersByTid 根据租户ID查询绑定关系
|
||||
func ListTenantUsersByTid(tid uint64) ([]models.SystemTenantUser, error) {
|
||||
var rows []models.SystemTenantUser
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
OrderBy("-is_default", "-id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListTenantBindingsByUid 根据用户ID查询绑定关系
|
||||
func ListTenantBindingsByUid(uid uint64) ([]models.SystemTenantUser, error) {
|
||||
var rows []models.SystemTenantUser
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
OrderBy("-is_default", "-id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetTenantUserByUidAndTid 根据用户ID和租户ID查询租户用户绑定关系
|
||||
func GetTenantUserByUidAndTid(uid, tid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", tid).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// GetTenantUserByUid 根据用户ID查询默认/最新租户用户绑定关系
|
||||
func GetTenantUserByUid(uid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
OrderBy("-is_default", "-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// GetTenantByID 根据租户ID查询租户信息
|
||||
func GetTenantByID(id uint64) (*models.SystemTenant, error) {
|
||||
var row models.SystemTenant
|
||||
err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("id", id).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// SetDefaultTenant 设置用户默认租户(同一用户仅一个默认)
|
||||
func SetDefaultTenant(uid, tid uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("uid", uid).Update(map[string]interface{}{
|
||||
"is_default": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", tid).
|
||||
Update(map[string]interface{}{"is_default": 1})
|
||||
return err
|
||||
}
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"server/models"
|
||||
)
|
||||
|
||||
// BindTenantUser 绑定用户到租户(若已存在则更新状态/默认值)
|
||||
func BindTenantUser(tid, uid uint64, account, name, phone, email *string, sex *uint8, birth *string, password *string, isDefault, status int8, remark *string) (uint64, error) {
|
||||
var existed models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
Filter("uid", uid).
|
||||
One(&existed)
|
||||
if err == nil {
|
||||
update := map[string]interface{}{
|
||||
"account": account,
|
||||
"name": name,
|
||||
"phone": phone,
|
||||
"email": email,
|
||||
"password": password,
|
||||
"status": status,
|
||||
"is_default": isDefault,
|
||||
"remark": remark,
|
||||
}
|
||||
if sex != nil {
|
||||
update["sex"] = *sex
|
||||
}
|
||||
if birth != nil {
|
||||
trimmedBirth := strings.TrimSpace(*birth)
|
||||
if trimmedBirth == "" {
|
||||
update["birth"] = nil
|
||||
} else {
|
||||
update["birth"] = trimmedBirth
|
||||
}
|
||||
}
|
||||
_, uErr := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", existed.ID).Update(update)
|
||||
return existed.ID, uErr
|
||||
}
|
||||
|
||||
m := &models.SystemTenantUser{
|
||||
Tid: tid,
|
||||
Uid: uid,
|
||||
Account: account,
|
||||
Name: name,
|
||||
Phone: phone,
|
||||
Email: email,
|
||||
Password: password,
|
||||
IsDefault: isDefault,
|
||||
Status: status,
|
||||
Remark: remark,
|
||||
}
|
||||
if sex != nil {
|
||||
m.Sex = *sex
|
||||
}
|
||||
if birth != nil {
|
||||
trimmedBirth := strings.TrimSpace(*birth)
|
||||
if trimmedBirth != "" {
|
||||
m.Birth = &trimmedBirth
|
||||
}
|
||||
}
|
||||
id, iErr := models.Orm.Insert(m)
|
||||
return uint64(id), iErr
|
||||
}
|
||||
|
||||
// UnbindTenantUser 删除绑定关系
|
||||
func UnbindTenantUser(id uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("id", id).Delete()
|
||||
return err
|
||||
}
|
||||
|
||||
// ListTenantUsersByTid 根据租户ID查询绑定关系
|
||||
func ListTenantUsersByTid(tid uint64) ([]models.SystemTenantUser, error) {
|
||||
var rows []models.SystemTenantUser
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("tid", tid).
|
||||
OrderBy("-is_default", "-id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// ListTenantBindingsByUid 根据用户ID查询绑定关系
|
||||
func ListTenantBindingsByUid(uid uint64) ([]models.SystemTenantUser, error) {
|
||||
var rows []models.SystemTenantUser
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
OrderBy("-is_default", "-id").
|
||||
All(&rows)
|
||||
return rows, err
|
||||
}
|
||||
|
||||
// GetTenantUserByUidAndTid 根据用户ID和租户ID查询租户用户绑定关系
|
||||
func GetTenantUserByUidAndTid(uid, tid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", tid).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// GetTenantUserByUid 根据用户ID查询默认/最新租户用户绑定关系
|
||||
func GetTenantUserByUid(uid uint64) (*models.SystemTenantUser, error) {
|
||||
var row models.SystemTenantUser
|
||||
err := models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
OrderBy("-is_default", "-id").
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// GetTenantByID 根据租户ID查询租户信息
|
||||
func GetTenantByID(id uint64) (*models.SystemTenant, error) {
|
||||
var row models.SystemTenant
|
||||
err := models.Orm.QueryTable(new(models.SystemTenant)).
|
||||
Filter("id", id).
|
||||
One(&row)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// SetDefaultTenant 设置用户默认租户(同一用户仅一个默认)
|
||||
func SetDefaultTenant(uid, tid uint64) error {
|
||||
_, err := models.Orm.QueryTable(new(models.SystemTenantUser)).Filter("uid", uid).Update(map[string]interface{}{
|
||||
"is_default": 0,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = models.Orm.QueryTable(new(models.SystemTenantUser)).
|
||||
Filter("uid", uid).
|
||||
Filter("tid", tid).
|
||||
Update(map[string]interface{}{"is_default": 1})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -175,10 +175,10 @@ func ConfirmVerifyCodeByCode(bindType string, bindID, bindTid uint64, code strin
|
||||
Filter("id", row.ID).
|
||||
Filter("status", models.WechatVerifyStatusScanned).
|
||||
Update(map[string]interface{}{
|
||||
"bind_type": bindType,
|
||||
"bind_id": bindID,
|
||||
"bind_tid": bindTid,
|
||||
"status": models.WechatVerifyStatusUsed,
|
||||
"bind_type": bindType,
|
||||
"bind_id": bindID,
|
||||
"bind_tid": bindTid,
|
||||
"status": models.WechatVerifyStatusUsed,
|
||||
"update_time": now,
|
||||
})
|
||||
if uerr != nil {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>第三方账号绑定 - 统一认证中心</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
width: 460px; max-width: 92vw; padding: 36px 32px;
|
||||
}
|
||||
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 6px; }
|
||||
.sub { font-size: 13px; color: #8a8fa3; margin-bottom: 22px; }
|
||||
.msg {
|
||||
padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px;
|
||||
}
|
||||
.msg.ok { background: #f0fff4; border: 1px solid #b7ebc8; color: #189a4a; }
|
||||
.msg.err { background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322; }
|
||||
.item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.info .name { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||
.info .meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
|
||||
.btn {
|
||||
padding: 7px 16px; border-radius: 6px; border: none; cursor: pointer;
|
||||
font-size: 13px; white-space: nowrap;
|
||||
}
|
||||
.btn-bind { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: #fff; }
|
||||
.btn-unbind { background: #fff; border: 1px solid #dcdfe8; color: #8a8fa3; }
|
||||
.btn-unbind:hover { border-color: #cf1322; color: #cf1322; }
|
||||
.empty { text-align: center; color: #8a8fa3; font-size: 13px; padding: 24px 0; }
|
||||
.back { text-align: center; margin-top: 18px; }
|
||||
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>第三方账号绑定</h1>
|
||||
<div class="sub">绑定后可使用对应平台一键登录</div>
|
||||
|
||||
{{if .Success}}<div class="msg ok">{{.Success}}</div>{{end}}
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Providers}}
|
||||
{{range .Providers}}
|
||||
<div class="item">
|
||||
<div class="info">
|
||||
<div class="name">{{.name}}</div>
|
||||
{{if .bound}}
|
||||
<div class="meta">已绑定{{if .nickname}} · {{.nickname}}{{end}}</div>
|
||||
{{else}}
|
||||
<div class="meta">未绑定</div>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .bound}}
|
||||
<a class="btn btn-unbind" href="/auth/third/{{.provider}}/unbind">解绑</a>
|
||||
{{else}}
|
||||
<a class="btn btn-bind" href="/auth/third/{{.provider}}/authorize?action=bind">绑定</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">尚未开通任何第三方登录方式</div>
|
||||
{{end}}
|
||||
|
||||
<div class="back"><a href="/auth/login">返回登录</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,86 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>在线设备 - 统一认证中心</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh; display: flex; align-items: center; justify-content: center;
|
||||
padding: 20px 0;
|
||||
}
|
||||
.card {
|
||||
background: #fff; border-radius: 12px; box-shadow: 0 20px 60px rgba(0,0,0,.2);
|
||||
width: 560px; max-width: 92vw; padding: 36px 32px;
|
||||
}
|
||||
h1 { font-size: 20px; color: #1a1a2e; margin-bottom: 6px; }
|
||||
.sub { font-size: 13px; color: #8a8fa3; margin-bottom: 20px; }
|
||||
.msg { padding: 10px 12px; border-radius: 6px; font-size: 13px; margin-bottom: 16px; }
|
||||
.msg.ok { background: #f0fff4; border: 1px solid #b7ebc8; color: #189a4a; }
|
||||
.msg.err { background: #fff2f0; border: 1px solid #ffccc7; color: #cf1322; }
|
||||
.item {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border: 1px solid #e4e7ee; border-radius: 8px; margin-bottom: 10px;
|
||||
}
|
||||
.item.current { border-color: #667eea; background: #f7f8ff; }
|
||||
.device { font-size: 15px; color: #1a1a2e; font-weight: 500; }
|
||||
.meta { font-size: 12px; color: #8a8fa3; margin-top: 4px; line-height: 1.6; }
|
||||
.tag {
|
||||
display: inline-block; font-size: 11px; padding: 1px 8px; border-radius: 10px;
|
||||
background: #eef1ff; color: #667eea; margin-left: 6px;
|
||||
}
|
||||
.btn {
|
||||
padding: 7px 14px; border-radius: 6px; border: 1px solid #dcdfe8;
|
||||
background: #fff; color: #8a8fa3; font-size: 13px; cursor: pointer; text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: #cf1322; color: #cf1322; }
|
||||
.empty { text-align: center; color: #8a8fa3; font-size: 13px; padding: 30px 0; }
|
||||
.footer { text-align: center; margin-top: 18px; display: flex; gap: 18px; justify-content: center; }
|
||||
.footer a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>在线设备</h1>
|
||||
<div class="sub">这里是当前账号的登录设备,发现异常可立即下线</div>
|
||||
|
||||
{{if .Success}}<div class="msg ok">{{.Success}}</div>{{end}}
|
||||
{{if .Error}}<div class="msg err">{{.Error}}</div>{{end}}
|
||||
|
||||
{{if .Sessions}}
|
||||
{{range .Sessions}}
|
||||
<div class="item {{if .Current}}current{{end}}">
|
||||
<div>
|
||||
<div class="device">
|
||||
{{.Device}}
|
||||
{{if .Current}}<span class="tag">当前设备</span>{{end}}
|
||||
{{if .Expired}}<span class="tag">已过期</span>{{end}}
|
||||
</div>
|
||||
<div class="meta">
|
||||
应用:{{.Client}} · IP:{{.IP}}<br>
|
||||
登录:{{.LoginAt}} · 最近活跃:{{.LastAt}}
|
||||
</div>
|
||||
</div>
|
||||
{{if .Current}}
|
||||
<span class="btn" style="opacity:.5;cursor:default">使用中</span>
|
||||
{{else}}
|
||||
<a class="btn" href="/auth/devices/kick?sid={{.Sid}}"
|
||||
onclick="return confirm('确定将该设备下线吗?')">下线</a>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<div class="empty">暂无在线设备</div>
|
||||
{{end}}
|
||||
|
||||
<div class="footer">
|
||||
<a href="/auth/bind">第三方账号</a>
|
||||
<a href="/auth/logout">退出登录</a>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -47,6 +47,21 @@
|
||||
.tenant-meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
|
||||
.tenant-arrow { color: #c0c4d0; font-size: 18px; }
|
||||
.tip { text-align: center; font-size: 12px; color: #8a8fa3; margin-top: 20px; }
|
||||
.divider {
|
||||
position: relative; text-align: center; margin: 24px 0 14px;
|
||||
font-size: 12px; color: #a0a5b5;
|
||||
}
|
||||
.divider::before {
|
||||
content: ''; position: absolute; top: 50%; left: 0; right: 0;
|
||||
height: 1px; background: #e4e7ee;
|
||||
}
|
||||
.divider span { position: relative; background: #fff; padding: 0 12px; }
|
||||
.third-list { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; }
|
||||
.third-btn {
|
||||
padding: 8px 16px; border: 1px solid #dcdfe8; border-radius: 20px;
|
||||
background: #fff; font-size: 13px; color: #5a6072; cursor: pointer;
|
||||
}
|
||||
.third-btn:hover { border-color: #667eea; color: #667eea; }
|
||||
.back { text-align: center; margin-top: 14px; }
|
||||
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
|
||||
</style>
|
||||
@@ -87,6 +102,12 @@
|
||||
</div>
|
||||
<button class="btn" id="submitBtn" type="submit">登 录</button>
|
||||
</form>
|
||||
|
||||
<!-- 第三方登录 -->
|
||||
<div class="third-area" id="thirdArea" style="display:none">
|
||||
<div class="divider"><span>其他登录方式</span></div>
|
||||
<div class="third-list" id="thirdList"></div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
<div class="tip">统一身份认证 · 一次登录,全平台通行</div>
|
||||
@@ -102,6 +123,34 @@
|
||||
el.style.display = 'block';
|
||||
}
|
||||
|
||||
// 加载已开通的第三方登录方式
|
||||
function loadThirdProviders() {
|
||||
fetch('/auth/third/list')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (res) {
|
||||
var list = (res && res.data) || [];
|
||||
var enabled = list.filter(function (i) { return i.enabled; });
|
||||
if (!enabled.length) return;
|
||||
var box = document.getElementById('thirdList');
|
||||
enabled.forEach(function (item) {
|
||||
var btn = document.createElement('button');
|
||||
btn.className = 'third-btn';
|
||||
btn.textContent = item.name;
|
||||
btn.onclick = function () { startThird(item.provider); };
|
||||
box.appendChild(btn);
|
||||
});
|
||||
document.getElementById('thirdArea').style.display = 'block';
|
||||
})
|
||||
.catch(function () { /* 静默失败,不影响账号密码登录 */ });
|
||||
}
|
||||
|
||||
function startThird(provider) {
|
||||
window.location.href = '/auth/third/' + encodeURIComponent(provider) +
|
||||
'/authorize?redirect=' + encodeURIComponent(REDIRECT);
|
||||
}
|
||||
|
||||
loadThirdProviders();
|
||||
|
||||
function submitLogin(e) {
|
||||
e.preventDefault();
|
||||
var account = document.getElementById('account').value.trim();
|
||||
|
||||
Reference in New Issue
Block a user