批量更新

This commit is contained in:
2026-09-20 00:19:08 +08:00
parent fa281363fb
commit c5ed596008
47 changed files with 5385 additions and 1199 deletions
+17 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"io"
"net/url"
"strings"
authsvc "server/services/auth"
@@ -200,20 +201,33 @@ func (c *AuthLoginController) LogoutPage() {
if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
}
// 走统一登出入口:吊销令牌并通知其他应用(单点登出)
if access != "" {
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout)
_ = authsvc.Logout(access, "")
}
clearSessionCookieForCtx(c.Ctx)
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
cid := strings.TrimSpace(c.GetString("client_id"))
if back != "" && cid != "" {
if client, err := findClient(cid); err == nil && allowRedirect(client, back) {
if client, err := findClient(cid); err == nil && allowLogoutRedirect(client, back) {
c.Redirect(back, 302)
return
}
// 回跳地址未登记时不要直接抛 JSON 给用户,退回登录页
target := "/auth/login?error=" + url.QueryEscape("登出回跳地址未登记,已返回登录页")
if cid != "" {
target += "&client_id=" + url.QueryEscape(cid)
}
c.Redirect(target, 302)
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"})
// 浏览器直接访问(无回跳参数):跳登录页,避免页面显示裸 JSON
target := "/auth/login"
if cid != "" {
target += "?client_id=" + url.QueryEscape(cid)
}
c.Redirect(target, 302)
}
// Sessions 在线设备列表
+70 -3
View File
@@ -10,6 +10,7 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"net/url"
"strings"
"time"
@@ -262,10 +263,36 @@ func (c *AuthOidcController) UserInfo() {
c.fail(500, "server_error")
return
}
// 业务接口与前端统一使用 identity_id 作为用户标识
account, name, groupID := "", "", uint64(0)
if bind, err := authsvc.GetTenantUser(identity.ID, uint64(claims.TenantId)); err == nil {
groupID = bind.GroupID
if bind.Account != nil {
account = *bind.Account
}
if bind.Name != nil {
name = *bind.Name
}
}
if account == "" {
account = profile.Mobile
}
if name == "" {
name = profile.Nickname
}
c.serveJSON(map[string]interface{}{
"sub": fmt.Sprintf("%d", identity.ID),
"union_id": identity.UnionID,
"tid": claims.TenantId,
"sub": fmt.Sprintf("%d", identity.ID),
"id": identity.ID,
"union_id": identity.UnionID,
"tid": claims.TenantId,
// 诊断用:业务接口按 user_type 判定权限(backend / app),
// 出现「无权访问」时可先看这里的值是否正确
"user_type": claims.UserType,
"group_id": groupID,
"account": account,
"name": name,
"nickname": profile.Nickname,
"mobile": profile.Mobile,
"email": profile.Email,
@@ -397,6 +424,46 @@ func allowRedirect(client *models.AuthClient, uri string) bool {
return false
}
// allowLogoutRedirect 登出回跳地址校验。
//
// 先精确匹配白名单,再按 origin(协议+主机+端口)放宽匹配:
// 实际使用中「末尾斜杠」「带 #/login 片段」等差异很常见,
// 只做精确匹配会导致明明同域却跳不回去,因此同域即放行。
func allowLogoutRedirect(client *models.AuthClient, uri string) bool {
raw := ""
if client.PostLogoutURIs != nil {
raw = *client.PostLogoutURIs
}
var list []string
if raw != "" {
_ = json.Unmarshal([]byte(raw), &list)
}
if len(list) == 0 {
return false
}
target := strings.TrimSpace(uri)
for _, item := range list {
if strings.TrimSpace(item) == target {
return true
}
}
targetURL, err := url.Parse(target)
if err != nil || targetURL.Scheme == "" || targetURL.Host == "" {
return false
}
for _, item := range list {
base, err := url.Parse(strings.TrimSpace(item))
if err != nil || base.Scheme == "" || base.Host == "" {
continue
}
if base.Scheme == targetURL.Scheme && base.Host == targetURL.Host {
return true
}
}
return false
}
// issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
plain, err := randomString(32)
+125
View File
@@ -0,0 +1,125 @@
package auth
import (
"net/url"
"strings"
"time"
"server/models"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// AuthSessionController 会话管理(用户自助:查看在线设备、踢下线)
type AuthSessionController struct {
beego.Controller
}
func (c *AuthSessionController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// DevicesPage 在线设备页(浏览器访问,依赖认证中心会话 Cookie)
// GET /auth/devices
func (c *AuthSessionController) DevicesPage() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
list, err := authsvc.ListActiveSessions(session.IdentityID)
if err != nil {
list = nil
}
items := make([]map[string]interface{}, 0, len(list))
now := time.Now()
for _, s := range list {
device := "未知设备"
if s.DeviceName != nil && strings.TrimSpace(*s.DeviceName) != "" {
device = *s.DeviceName
}
ip := "-"
if s.IP != nil {
ip = *s.IP
}
client := s.ClientID
if client == "" {
client = "-"
}
items = append(items, map[string]interface{}{
"Sid": s.Sid,
"Current": s.Sid == sid,
"Device": device,
"IP": ip,
"Client": client,
"LoginAt": s.LoginAt.Format("2006-01-02 15:04"),
"LastAt": s.LastAccessAt.Format("2006-01-02 15:04"),
"Expired": s.ExpiresAt.Before(now),
"Tid": s.Tid,
})
}
c.Data["Sessions"] = items
c.Data["Success"] = c.GetString("success")
c.Data["Error"] = c.GetString("error")
c.TplName = "auth/devices.tpl"
}
// KickPage 踢下线指定设备(页面入口,踢完后回到设备页)
// GET /auth/devices/kick?sid=
func (c *AuthSessionController) KickPage() {
target := strings.TrimSpace(c.GetString("sid"))
current := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if current == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(current)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
if target == "" {
c.Redirect("/auth/devices?error="+url.QueryEscape("参数错误"), 302)
return
}
// 只允许操作自己名下的会话
owned := false
if list, e := authsvc.ListActiveSessions(session.IdentityID); e == nil {
for _, s := range list {
if s.Sid == target {
owned = true
break
}
}
}
if !owned {
c.Redirect("/auth/devices?error="+url.QueryEscape("无权操作该设备"), 302)
return
}
if target == current {
c.Redirect("/auth/devices?error="+url.QueryEscape("不能踢掉当前设备,请直接登出"), 302)
return
}
if err := authsvc.RevokeSession(target, models.RevokeReasonAdmin); err != nil {
c.Redirect("/auth/devices?error="+url.QueryEscape("操作失败"), 302)
return
}
// 同步吊销该会话的刷新令牌,确保对方无法续期
_, _ = models.Orm.QueryTable(new(models.AuthRefreshToken)).
Filter("sid", target).
Update(map[string]interface{}{"revoked": 1})
c.Redirect("/auth/devices?success="+url.QueryEscape("已将该设备下线"), 302)
}
+592
View File
@@ -0,0 +1,592 @@
package auth
import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"server/models"
"server/pkg/jwtutil"
authsvc "server/services/auth"
"server/services/auth/idp"
beego "github.com/beego/beego/v2/server/web"
"github.com/beego/beego/v2/server/web/context"
)
// authBase 认证中心对外基址(用于拼接第三方回调地址)
func authBase(c *AuthThirdController) string {
if iss := jwtutil.Issuer(); iss != "" {
return iss
}
scheme := "https"
if c.Ctx.Request.TLS == nil {
scheme = "http"
}
return scheme + "://" + c.Ctx.Request.Host + "/auth"
}
// claimsFromCtxThird 从 Authorization 头解析登录态
func claimsFromCtxThird(ctx *context.Context) *jwtutil.Claims {
header := ctx.Request.Header.Get("Authorization")
if header == "" {
return nil
}
parts := strings.SplitN(header, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
return nil
}
claims, err := jwtutil.ParseToken(strings.TrimSpace(parts[1]))
if err != nil {
return nil
}
return claims
}
// 第三方登录未绑定时的错误
var ErrThirdNotBound = errors.New("该第三方账号尚未绑定,请先用账号密码登录后绑定")
// AuthThirdController 第三方登录(微信/钉钉/飞书/QQ/GitHub/Google)
type AuthThirdController struct {
beego.Controller
}
func (c *AuthThirdController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// thirdState 第三方授权流程中通过 state 透传的上下文(OIDC 原始参数)
type thirdState struct {
ClientID string `json:"client_id"`
RedirectURI string `json:"redirect_uri"`
CodeChallenge string `json:"code_challenge"`
Method string `json:"code_challenge_method"`
Scope string `json:"scope"`
Nonce string `json:"nonce"`
OIDCState string `json:"oidc_state"`
Tid uint64 `json:"tid"`
// Action:login=第三方登录(默认);bind=已登录用户绑定第三方账号
Action string `json:"action"`
}
// List 已开通的第三方登录方式(供登录页展示按钮)
// GET /auth/third/list?tid=
func (c *AuthThirdController) List() {
tid, _ := c.GetInt64("tid", 0)
list := make([]map[string]interface{}, 0)
for _, p := range idp.Supported() {
_, configured := idp.LoadConfig(p, uint64(tid))
list = append(list, map[string]interface{}{
"provider": p,
"name": providerName(p),
"enabled": configured,
})
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// Authorize 跳转到第三方授权页
// GET /auth/third/:provider/authorize?client_id=&redirect_uri=&code_challenge=&state=&tid=
func (c *AuthThirdController) Authorize() {
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
if !idp.IsSupported(provider) {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("不支持的登录方式: " + provider))
return
}
tid, _ := c.GetInt64("tid", 0)
cfg, configured := idp.LoadConfig(provider, uint64(tid))
if !configured || cfg.AppID == "" || cfg.AppSecret == "" {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("该登录方式尚未开通,请联系管理员配置"))
return
}
st := thirdState{
ClientID: c.GetString("client_id"),
RedirectURI: c.GetString("redirect_uri"),
CodeChallenge: c.GetString("code_challenge"),
Method: c.GetString("code_challenge_method"),
Scope: c.GetString("scope"),
Nonce: c.GetString("nonce"),
OIDCState: c.GetString("state"),
Tid: uint64(tid),
Action: strings.TrimSpace(c.GetString("action")),
}
// 登录页场景:参数封装在 redirect(base64 编码的 authorize URL)里,
// 这里解出来,使第三方登录完成后能回到原来的 OIDC 授权流程。
if enc := c.GetString("redirect"); enc != "" && st.ClientID == "" {
if raw, err := base64.RawURLEncoding.DecodeString(enc); err == nil {
if u, err := url.Parse(string(raw)); err == nil {
q := u.Query()
st.ClientID = q.Get("client_id")
st.RedirectURI = q.Get("redirect_uri")
st.CodeChallenge = q.Get("code_challenge")
st.Method = q.Get("code_challenge_method")
st.Scope = q.Get("scope")
st.Nonce = q.Get("nonce")
st.OIDCState = q.Get("state")
}
}
}
// 第三方登录仅面向租户域应用:平台端(platform.yunzer.cn)不走认证中心,
// 这里再校验一次 realm,避免平台域应用被误接入。
if st.ClientID != "" {
if client, err := findClient(st.ClientID); err == nil && client.Realm != models.AuthRealmTenant {
c.Ctx.Output.SetStatus(400)
_, _ = c.Ctx.ResponseWriter.Write([]byte("该应用不支持第三方登录"))
return
}
}
raw, err := json.Marshal(st)
if err != nil {
c.Ctx.Output.SetStatus(500)
return
}
state := base64.RawURLEncoding.EncodeToString(raw)
// 注意:authBase 已包含 /auth 前缀(issuer = https://api.yunzer.cn/auth),
// 这里不能再拼 /auth,否则回调地址会变成 /auth/auth/third/...
callbackURI := authBase(c) + "/third/" + provider + "/callback"
c.Redirect(idp.BuildAuthURL(cfg, callbackURI, state), 302)
}
// Callback 第三方回调:换取身份 → 建会话 → 回到 OIDC authorize 继续发码
// GET /auth/third/:provider/callback?code=&state=
func (c *AuthThirdController) Callback() {
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
code := c.GetString("code")
stateParam := c.GetString("state")
fail := func(msg string) {
c.Redirect("/auth/login?error="+url.QueryEscape(msg), 302)
}
if code == "" {
fail("第三方登录失败:缺少授权码")
return
}
var st thirdState
if raw, err := base64.RawURLEncoding.DecodeString(stateParam); err == nil {
_ = json.Unmarshal(raw, &st)
}
cfg, configured := idp.LoadConfig(provider, st.Tid)
if !configured {
fail("该登录方式尚未开通")
return
}
info, err := idp.Exchange(cfg, code)
if err != nil {
fail(err.Error())
return
}
// 绑定场景:把第三方账号挂到当前登录身份上
if st.Action == "bind" {
finishBind(c, provider, info)
return
}
// 查找绑定关系:open_id 优先,其次 union_id
bind, err := findThirdBinding(provider, info.OpenID, info.UnionID)
if err != nil {
fail(ErrThirdNotBound.Error())
return
}
// 校验身份状态
var identity models.AuthIdentity
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", bind.IdentityID).One(&identity); err != nil {
fail("账号不存在")
return
}
if identity.Status == models.AuthIdentityStatusDisabled {
fail("账号已禁用")
return
}
// 建会话(tid=0 表示待选企业,authorize 会引导用户选择)
sess, err := authsvc.CreateSession(authsvc.SessionInfo{
IdentityID: identity.ID,
Tid: authsvc.PendingTenantID,
ClientID: st.ClientID,
IP: c.Ctx.Input.IP(),
UserAgent: c.Ctx.Request.UserAgent(),
LoginType: authsvc.LoginTypeThird,
Amr: provider,
})
if err != nil {
fail("创建会话失败: " + err.Error())
return
}
// 更新绑定信息(昵称/头像可能变化)
update := map[string]interface{}{}
if info.Nickname != "" {
update["nickname"] = info.Nickname
}
if info.Avatar != "" {
update["avatar"] = info.Avatar
}
if info.Raw != "" {
update["raw"] = info.Raw
}
if len(update) > 0 {
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("id", bind.ID).Update(update)
}
setSessionCookieForCtx(c.Ctx, sess.Sid)
// 回到 OIDC 授权流程继续签发授权码
if st.ClientID != "" && st.RedirectURI != "" {
v := url.Values{}
v.Set("client_id", st.ClientID)
v.Set("redirect_uri", st.RedirectURI)
v.Set("response_type", ResponseTypeCode)
v.Set("code_challenge", st.CodeChallenge)
v.Set("code_challenge_method", defaultStr(st.Method, "S256"))
if st.Scope != "" {
v.Set("scope", st.Scope)
}
if st.Nonce != "" {
v.Set("nonce", st.Nonce)
}
if st.OIDCState != "" {
v.Set("state", st.OIDCState)
}
c.Redirect("/auth/authorize?"+v.Encode(), 302)
return
}
c.Redirect("/auth/login", 302)
}
// Bind 已登录用户绑定第三方账号
// POST /auth/third/bind { provider, code, tid }
func (c *AuthThirdController) Bind() {
claims := claimsFromCtxThird(c.Ctx)
if claims == nil || claims.UserID <= 0 {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
var req struct {
Provider string `json:"provider"`
Code string `json:"code"`
Tid uint64 `json:"tid"`
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
req.Provider = strings.TrimSpace(req.Provider)
if !idp.IsSupported(req.Provider) || req.Code == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
cfg, configured := idp.LoadConfig(req.Provider, req.Tid)
if !configured {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式尚未开通"})
return
}
info, err := idp.Exchange(cfg, req.Code)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": err.Error()})
return
}
// 该第三方账号若已绑定到别的身份,拒绝重复绑定
if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("provider", req.Provider).
Filter("open_id", info.OpenID).Exist(); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该第三方账号已被其他账号绑定"})
return
}
item := &models.AuthIdentityThird{
IdentityID: uint64(claims.UserID),
Provider: req.Provider,
OpenID: info.OpenID,
Nickname: strPtr(info.Nickname),
Avatar: strPtr(info.Avatar),
Raw: strPtr(info.Raw),
}
if info.UnionID != "" {
item.UnionID = &info.UnionID
}
if _, err := models.Orm.Insert(item); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "绑定失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "绑定成功"})
}
// BoundList 当前登录身份已绑定的第三方账号
// GET /auth/third/bound
func (c *AuthThirdController) BoundList() {
claims := claimsFromCtxThird(c.Ctx)
if claims == nil || claims.UserID <= 0 {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
var rows []models.AuthIdentityThird
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", uint64(claims.UserID)).All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败"})
return
}
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"provider": r.Provider,
"name": providerName(r.Provider),
"nickname": deref(r.Nickname),
"bind_time": r.BindTime,
})
}
c.serveJSON(map[string]interface{}{"code": 200, "data": list})
}
// Unbind 解绑第三方账号
// POST /auth/third/unbind { provider }
func (c *AuthThirdController) Unbind() {
claims := claimsFromCtxThird(c.Ctx)
if claims == nil || claims.UserID <= 0 {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
return
}
var req struct {
Provider string `json:"provider"`
}
_ = json.Unmarshal(c.Ctx.Input.RequestBody, &req)
if strings.TrimSpace(req.Provider) == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
// 至少保留一种登录方式:若未设置密码则不允许解绑最后一个第三方
var identity models.AuthIdentity
// 未设置密码的用户,解绑最后一个第三方账号后将无法登录
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", uint64(claims.UserID)).One(&identity); err == nil {
hasPassword := identity.PasswordHash != nil && *identity.PasswordHash != ""
if !hasPassword {
cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", identity.ID).Count()
if cnt <= 1 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "未设置密码,不能解绑最后一个第三方账号"})
return
}
}
}
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", uint64(claims.UserID)).
Filter("provider", strings.TrimSpace(req.Provider)).
Delete(); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "解绑失败"})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已解绑"})
}
// BindPage 第三方账号绑定管理页(浏览器访问,依赖认证中心会话 Cookie)
// GET /auth/bind
func (c *AuthThirdController) BindPage() {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
// 已绑定列表
var bound []models.AuthIdentityThird
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", session.IdentityID).All(&bound)
boundMap := map[string]models.AuthIdentityThird{}
for _, b := range bound {
boundMap[b.Provider] = b
}
// 全部平台(只展示已开通的)
items := make([]map[string]interface{}, 0)
for _, p := range idp.Supported() {
_, configured := idp.LoadConfig(p, session.Tid)
if !configured {
continue
}
item := map[string]interface{}{
"provider": p,
"name": providerName(p),
"bound": false,
"nickname": "",
}
if b, ok := boundMap[p]; ok {
item["bound"] = true
item["nickname"] = deref(b.Nickname)
}
items = append(items, item)
}
c.Data["Providers"] = items
c.Data["Success"] = c.GetString("success")
c.Data["Error"] = c.GetString("error")
c.TplName = "auth/bind.tpl"
}
// UnbindPage 解绑(页面入口,用会话 Cookie 鉴权,解绑后回到绑定页)
// GET /auth/third/:provider/unbind
func (c *AuthThirdController) UnbindPage() {
provider := strings.TrimSpace(c.Ctx.Input.Param(":provider"))
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
var identity models.AuthIdentity
hasPassword := true
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
Filter("id", session.IdentityID).One(&identity); err == nil {
hasPassword = identity.PasswordHash != nil && *identity.PasswordHash != ""
}
if !hasPassword {
cnt, _ := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", session.IdentityID).Count()
if cnt <= 1 {
c.Redirect("/auth/bind?error="+url.QueryEscape("未设置密码,不能解绑最后一个第三方账号"), 302)
return
}
}
if _, err := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", session.IdentityID).
Filter("provider", provider).Delete(); err != nil {
c.Redirect("/auth/bind?error="+url.QueryEscape("解绑失败"), 302)
return
}
c.Redirect("/auth/bind?success="+url.QueryEscape("已解绑"), 302)
}
// finishBind 绑定场景的回调处理:把第三方账号挂到当前登录身份
func finishBind(c *AuthThirdController, provider string, info *idp.UserInfo) {
sid := strings.TrimSpace(c.Ctx.GetCookie(sessionCookieName))
if sid == "" {
c.Redirect("/auth/login", 302)
return
}
session, err := authsvc.GetSession(sid)
if err != nil {
c.Redirect("/auth/login", 302)
return
}
// 该第三方账号已绑定到别的身份 → 拒绝
if exist := models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("provider", provider).
Filter("open_id", info.OpenID).Exist(); exist {
c.Redirect("/auth/bind?error="+url.QueryEscape("该"+providerName(provider)+"账号已被其他账号绑定"), 302)
return
}
// 同一身份重复绑定同一平台 → 先清掉旧的
_, _ = models.Orm.QueryTable(new(models.AuthIdentityThird)).
Filter("identity_id", session.IdentityID).
Filter("provider", provider).Delete()
item := &models.AuthIdentityThird{
IdentityID: session.IdentityID,
Provider: provider,
OpenID: info.OpenID,
Nickname: strPtr(info.Nickname),
Avatar: strPtr(info.Avatar),
Raw: strPtr(info.Raw),
}
if info.UnionID != "" {
item.UnionID = &info.UnionID
}
if _, err := models.Orm.Insert(item); err != nil {
c.Redirect("/auth/bind?error="+url.QueryEscape("绑定失败"), 302)
return
}
c.Redirect("/auth/bind?success="+url.QueryEscape("绑定成功"), 302)
}
// ---------------------------------------------------------------- 工具
// findThirdBinding 按 open_id 查找,其次按 union_id 查找
func findThirdBinding(provider, openID, unionID string) (*models.AuthIdentityThird, error) {
var bind models.AuthIdentityThird
qs := models.Orm.QueryTable(new(models.AuthIdentityThird)).Filter("provider", provider)
if openID != "" {
if err := qs.Filter("open_id", openID).One(&bind); err == nil {
return &bind, nil
}
}
if unionID != "" {
if err := qs.Filter("union_id", unionID).One(&bind); err == nil {
return &bind, nil
}
}
return nil, fmt.Errorf("not bound")
}
func providerName(p string) string {
switch p {
case models.IdPWechat:
return "微信"
case models.IdPDingTalk:
return "钉钉"
case models.IdPFeishu:
return "飞书"
case models.IdPQQ:
return "QQ"
case models.IdPGitHub:
return "GitHub"
case models.IdPGoogle:
return "Google"
}
return p
}
func defaultStr(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return v
}
func strPtr(s string) *string {
if s == "" {
return nil
}
return &s
}
func deref(p *string) string {
if p == nil {
return ""
}
return *p
}
+391
View File
@@ -0,0 +1,391 @@
package controllers
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthClientController 统一认证中心 —— 接入应用(OIDC Client)管理。
//
// 以后每开发一个新软件,只需在这里注册一条即可接入统一认证,
// 无需改代码、无需手写 SQL。
type PlatformAuthClientController struct {
beego.Controller
}
func (c *PlatformAuthClientController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:接入应用属于平台级敏感配置,仅平台管理员可操作。
// 不依赖全局中间件(其处于 warn 观察模式时不拦截),这里主动校验。
func (c *PlatformAuthClientController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
// 平台端应用注册只允许平台管理员操作;租户管理员无权访问
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
type authClientPayload struct {
ClientID string `json:"client_id"`
AppCode string `json:"app_code"`
Name string `json:"name"`
AppType int8 `json:"app_type"`
RedirectURIs *string `json:"redirect_uris"`
PostLogoutURIs *string `json:"post_logout_uris"`
BackchannelLogoutURI *string `json:"backchannel_logout_uri"`
GrantTypes string `json:"grant_types"`
Scope *string `json:"scope"`
AccessTTL int `json:"access_ttl"`
RefreshTTL int `json:"refresh_ttl"`
Realm string `json:"realm"`
Status *int8 `json:"status"`
}
func (c *PlatformAuthClientController) parsePayload() (authClientPayload, bool) {
var p authClientPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return p, false
}
}
return p, true
}
// List 接入应用列表(不返回 client_secret)
// GET /platform/authClient/list
func (c *PlatformAuthClientController) List() {
var rows []models.AuthClient
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
OrderBy("-id").All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
return
}
// 脱敏:只告知是否配置了密钥,不下发明文或哈希
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"client_id": r.ClientID,
"app_code": r.AppCode,
"name": r.Name,
"app_type": r.AppType,
"redirect_uris": derefStrAuth(r.RedirectURIs),
"post_logout_uris": derefStrAuth(r.PostLogoutURIs),
"backchannel_logout_uri": derefStrAuth(r.BackchannelLogoutURI),
"grant_types": r.GrantTypes,
"scope": derefStrAuth(r.Scope),
"access_ttl": r.AccessTTL,
"refresh_ttl": r.RefreshTTL,
"realm": r.Realm,
"status": r.Status,
"has_secret": r.ClientSecret != nil,
"create_time": r.CreateTime,
})
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
})
}
// Detail 应用详情
// GET /platform/authClient/detail/:id
func (c *PlatformAuthClientController) Detail() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
row.ClientSecret = nil
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": row})
}
// Create 新增应用
// POST /platform/authClient/create
func (c *PlatformAuthClientController) Create() {
p, ok := c.parsePayload()
if !ok {
return
}
p.ClientID = strings.TrimSpace(p.ClientID)
p.AppCode = strings.TrimSpace(p.AppCode)
p.Name = strings.TrimSpace(p.Name)
if p.ClientID == "" || p.AppCode == "" || p.Name == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id、应用编码、名称均不能为空"})
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
if exist := existsAuthClient("client_id", p.ClientID); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "client_id 已存在"})
return
}
if exist := existsAuthClient("app_code", p.AppCode); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "应用编码已存在"})
return
}
item := &models.AuthClient{
ClientID: p.ClientID,
AppCode: p.AppCode,
Name: p.Name,
AppType: p.AppType,
RedirectURIs: p.RedirectURIs,
PostLogoutURIs: p.PostLogoutURIs,
BackchannelLogoutURI: p.BackchannelLogoutURI,
GrantTypes: defaultStrAuth(p.GrantTypes, "authorization_code,refresh_token"),
Scope: p.Scope,
AccessTTL: defaultIntAuth(p.AccessTTL, 1800),
RefreshTTL: defaultIntAuth(p.RefreshTTL, 2592000),
Realm: defaultStrAuth(p.Realm, models.AuthRealmTenant),
Status: 1,
}
if p.Status != nil {
item.Status = *p.Status
}
// 机密客户端(有后端的 Web 应用)才下发密钥;SPA/APP 走 PKCE,不持有密钥
secretPlain := ""
if p.AppType == models.AuthAppTypeWeb {
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
secretPlain = plain
item.ClientSecret = &hashed
}
id, err := models.Orm.Insert(item)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "创建成功" + map[bool]string{true: "(请妥善保存密钥,仅此一次显示)", false: ""}[secretPlain != ""],
"data": map[string]interface{}{"id": id, "client_secret": secretPlain},
})
}
// Edit 编辑应用
// POST /platform/authClient/edit/:id
func (c *PlatformAuthClientController) Edit() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
p, ok := c.parsePayload()
if !ok {
return
}
if err := validateJSONStringArray(p.RedirectURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "redirect_uris 必须是 JSON 数组: " + err.Error()})
return
}
if err := validateJSONStringArray(p.PostLogoutURIs); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "post_logout_uris 必须是 JSON 数组: " + err.Error()})
return
}
update := map[string]interface{}{}
if v := strings.TrimSpace(p.Name); v != "" {
update["name"] = v
}
if p.AppType > 0 {
update["app_type"] = p.AppType
}
if p.RedirectURIs != nil {
update["redirect_uris"] = *p.RedirectURIs
}
if p.PostLogoutURIs != nil {
update["post_logout_uris"] = *p.PostLogoutURIs
}
if p.BackchannelLogoutURI != nil {
update["backchannel_logout_uri"] = *p.BackchannelLogoutURI
}
if v := strings.TrimSpace(p.GrantTypes); v != "" {
update["grant_types"] = v
}
if p.Scope != nil {
update["scope"] = *p.Scope
}
if p.AccessTTL > 0 {
update["access_ttl"] = p.AccessTTL
}
if p.RefreshTTL > 0 {
update["refresh_ttl"] = p.RefreshTTL
}
if v := strings.TrimSpace(p.Realm); v != "" {
update["realm"] = v
}
if p.Status != nil {
update["status"] = *p.Status
}
if len(update) == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无更新字段"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).Update(update); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// ResetSecret 重置密钥(仅机密客户端),返回新明文,仅此一次显示
// POST /platform/authClient/resetSecret/:id
func (c *PlatformAuthClientController) ResetSecret() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
var row models.AuthClient
if err := models.Orm.QueryTable(new(models.AuthClient)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
if row.AppType != models.AuthAppTypeWeb {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "仅「Web 后端」类型的应用需要密钥,SPA/APP 使用 PKCE"})
return
}
plain, hashed, err := generateClientSecret()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "生成密钥失败"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"client_secret": hashed}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "重置成功,请妥善保存新密钥",
"data": map[string]interface{}{"client_secret": plain},
})
}
// Delete 停用应用(逻辑删除:status=0,避免误删导致线上应用无法登录)
// DELETE /platform/authClient/delete/:id
func (c *PlatformAuthClientController) Delete() {
id, err := c.parseID()
if err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthClient)).
Filter("id", id).
Update(map[string]interface{}{"status": 0}); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "停用失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已停用"})
}
// ---------------------------------------------------------------- 工具
func (c *PlatformAuthClientController) parseID() (uint64, error) {
var id uint64
_, err := fmt.Sscanf(c.Ctx.Input.Param(":id"), "%d", &id)
if err != nil || id == 0 {
return 0, fmt.Errorf("invalid id")
}
return id, nil
}
func existsAuthClient(field, value string) bool {
return models.Orm.QueryTable(new(models.AuthClient)).Filter(field, value).Exist()
}
// generateClientSecret 生成密钥:明文只在创建/重置时返回一次,库里存 sha256 哈希
func generateClientSecret() (plain, hashed string, err error) {
buf := make([]byte, 24)
if _, err = rand.Read(buf); err != nil {
return "", "", err
}
plain = "yzs_" + hex.EncodeToString(buf)
sum := sha256.Sum256([]byte(plain))
return plain, hex.EncodeToString(sum[:]), nil
}
// validateJSONStringArray 校验字段为合法的字符串数组(为空时跳过)
func validateJSONStringArray(p *string) error {
if p == nil || strings.TrimSpace(*p) == "" {
return nil
}
var arr []string
return json.Unmarshal([]byte(*p), &arr)
}
func derefStrAuth(p *string) string {
if p == nil {
return ""
}
return *p
}
func defaultStrAuth(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
func defaultIntAuth(v, def int) int {
if v <= 0 {
return def
}
return v
}
+240
View File
@@ -0,0 +1,240 @@
package controllers
import (
"encoding/json"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
authsvc "server/services/auth"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthConfigController 统一认证 —— 租户登录策略配置(平台端管理)
//
// 可配置:登录验证码方式、密码强度、会话时长、同账号最大在线设备数(1号1机)
// 以及超限策略(踢掉旧会话 / 拒绝新登录)等。
type PlatformAuthConfigController struct {
beego.Controller
}
func (c *PlatformAuthConfigController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:仅平台管理员可配置
func (c *PlatformAuthConfigController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
// Detail 查询租户登录策略;未配置时返回系统默认值
// GET /platform/authConfig/detail?tid=
func (c *PlatformAuthConfigController) Detail() {
tid, _ := c.GetInt64("tid", 0)
if tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
return
}
var cfg models.AuthTenantAuthConfig
err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).One(&cfg)
if err != nil {
// 未配置:返回默认值
policy := authsvc.GetTenantSessionPolicy(uint64(tid))
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"tid": tid,
"verify_type": "captcha",
"open_verify": 1,
"pwd_min_len": 8,
"pwd_complexity": 0,
"session_ttl": policy.SessionTTL,
"max_session": policy.MaxSession,
"kick_strategy": policy.KickStrategy,
"mfa_required": 0,
"ip_whitelist": "",
"allow_third": "",
"configured": false,
},
})
return
}
ipList := ""
if cfg.IPWhitelist != nil {
ipList = *cfg.IPWhitelist
}
allowThird := ""
if cfg.AllowThird != nil {
allowThird = *cfg.AllowThird
}
c.serveJSON(map[string]interface{}{
"code": 200,
"msg": "success",
"data": map[string]interface{}{
"tid": cfg.Tid,
"verify_type": cfg.VerifyType,
"open_verify": cfg.OpenVerify,
"pwd_min_len": cfg.PwdMinLen,
"pwd_complexity": cfg.PwdComplexity,
"session_ttl": cfg.SessionTTL,
"max_session": cfg.MaxSession,
"kick_strategy": cfg.KickStrategy,
"mfa_required": cfg.MfaRequired,
"ip_whitelist": ipList,
"allow_third": allowThird,
"configured": true,
},
})
}
// Save 保存租户登录策略(不存在则创建)
// POST /platform/authConfig/save
func (c *PlatformAuthConfigController) Save() {
var p struct {
Tid uint64 `json:"tid"`
VerifyType string `json:"verify_type"`
OpenVerify *int8 `json:"open_verify"`
PwdMinLen int `json:"pwd_min_len"`
PwdComplexity int8 `json:"pwd_complexity"`
SessionTTL int `json:"session_ttl"`
MaxSession int `json:"max_session"`
KickStrategy int8 `json:"kick_strategy"`
MfaRequired int8 `json:"mfa_required"`
IPWhitelist string `json:"ip_whitelist"`
AllowThird string `json:"allow_third"`
}
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return
}
}
if p.Tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "tid 不能为空"})
return
}
// 校验 IP 白名单为合法 JSON 数组
ip := strings.TrimSpace(p.IPWhitelist)
if ip != "" {
var arr []string
if err := json.Unmarshal([]byte(ip), &arr); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "IP白名单必须是 JSON 数组,如 [\"1.2.3.4\"]"})
return
}
}
cfg := models.AuthTenantAuthConfig{
Tid: p.Tid,
VerifyType: defaultStrAuth2(p.VerifyType, "captcha"),
OpenVerify: 1,
PwdMinLen: p.PwdMinLen,
PwdComplexity: p.PwdComplexity,
SessionTTL: p.SessionTTL,
MaxSession: p.MaxSession,
KickStrategy: p.KickStrategy,
MfaRequired: p.MfaRequired,
}
if p.OpenVerify != nil {
cfg.OpenVerify = *p.OpenVerify
}
if cfg.PwdMinLen <= 0 {
cfg.PwdMinLen = 8
}
if cfg.SessionTTL <= 0 {
cfg.SessionTTL = authsvc.DefaultSessionTTL
}
if cfg.MaxSession <= 0 {
cfg.MaxSession = authsvc.DefaultMaxSession
}
if cfg.KickStrategy != models.KickStrategyReject {
cfg.KickStrategy = models.KickStrategyKickOld
}
if ip != "" {
cfg.IPWhitelist = &ip
}
if v := strings.TrimSpace(p.AllowThird); v != "" {
cfg.AllowThird = &v
}
exist := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).Filter("tid", p.Tid).Exist()
if exist {
fields := []string{"verify_type", "open_verify", "pwd_min_len", "pwd_complexity",
"session_ttl", "max_session", "kick_strategy", "mfa_required"}
if cfg.IPWhitelist != nil {
fields = append(fields, "ip_whitelist")
}
if cfg.AllowThird != nil {
fields = append(fields, "allow_third")
}
if _, err := models.Orm.Update(&cfg, fields...); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
return
}
} else {
if _, err := models.Orm.Insert(&cfg); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "保存失败: " + err.Error()})
return
}
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// Reset 恢复默认值(删除自定义配置)
// DELETE /platform/authConfig/reset/:tid
func (c *PlatformAuthConfigController) Reset() {
tid, err := strconv.ParseUint(c.Ctx.Input.Param(":tid"), 10, 64)
if err != nil || tid == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效 tid"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthTenantAuthConfig)).
Filter("tid", tid).Delete(); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "重置失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "已恢复默认"})
}
func defaultStrAuth2(v, def string) string {
if strings.TrimSpace(v) == "" {
return def
}
return strings.TrimSpace(v)
}
+257
View File
@@ -0,0 +1,257 @@
package controllers
import (
"encoding/json"
"fmt"
"io"
"strconv"
"strings"
"server/models"
"server/pkg/jwtutil"
beego "github.com/beego/beego/v2/server/web"
)
// PlatformAuthIdpController 统一认证 —— 第三方登录配置(平台端管理)
//
// 配置说明:
// - tid = 0 :平台全局配置,所有租户共用
// - tid > 0 :租户自带身份源,仅该租户可见(第10条需求:企业自带钉钉/飞书等)
//
// 配置对象只有「租户用户登录」,平台端自身不使用第三方登录。
type PlatformAuthIdpController struct {
beego.Controller
}
func (c *PlatformAuthIdpController) serveJSON(data map[string]interface{}) {
c.Data["json"] = data
_ = c.ServeJSON()
}
// Prepare 统一鉴权:仅平台管理员可维护第三方登录配置
func (c *PlatformAuthIdpController) Prepare() {
authHeader := c.Ctx.Request.Header.Get("Authorization")
if authHeader == "" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "未登录"})
c.StopRun()
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "认证信息格式错误"})
c.StopRun()
return
}
claims, err := jwtutil.ParseToken(parts[1])
if err != nil {
c.Ctx.Output.SetStatus(401)
c.serveJSON(map[string]interface{}{"code": 401, "msg": "登录已失效,请重新登录"})
c.StopRun()
return
}
if claims.UserType != "platform" {
c.Ctx.Output.SetStatus(403)
c.serveJSON(map[string]interface{}{"code": 403, "msg": "无权访问"})
c.StopRun()
return
}
}
type authIdpPayload struct {
Tid uint64 `json:"tid"`
Provider string `json:"provider"`
Name string `json:"name"`
AppID string `json:"app_id"`
AppSecret string `json:"app_secret"`
ProxyURL string `json:"proxy_url"`
Scopes string `json:"scopes"`
AuthURL string `json:"auth_url"`
TokenURL string `json:"token_url"`
UserinfoURL string `json:"userinfo_url"`
Status *int8 `json:"status"`
}
func (c *PlatformAuthIdpController) parsePayload() (authIdpPayload, bool) {
var p authIdpPayload
raw, _ := io.ReadAll(c.Ctx.Request.Body)
if len(raw) > 0 {
if err := json.Unmarshal(raw, &p); err != nil {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "参数错误"})
return p, false
}
}
return p, true
}
// List 配置列表
// GET /platform/authIdp/list?tid=0
func (c *PlatformAuthIdpController) List() {
tid, _ := c.GetInt64("tid", 0)
var rows []models.AuthTenantIdp
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).
Filter("tid", tid).OrderBy("id").All(&rows); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "查询失败: " + err.Error()})
return
}
// 脱敏:不下发 AppSecret
list := make([]map[string]interface{}, 0, len(rows))
for _, r := range rows {
list = append(list, map[string]interface{}{
"id": r.ID,
"tid": r.Tid,
"provider": r.Provider,
"name": derefAuthIdp(r.Name),
"app_id": derefAuthIdp(r.AppID),
"proxy_url": derefAuthIdp(r.ProxyURL),
"scopes": derefAuthIdp(r.Scopes),
"auth_url": derefAuthIdp(r.AuthURL),
"token_url": derefAuthIdp(r.TokenURL),
"userinfo_url": derefAuthIdp(r.UserinfoURL),
"status": r.Status,
"has_secret": r.AppSecret != nil && *r.AppSecret != "",
})
}
c.serveJSON(map[string]interface{}{
"code": 200, "msg": "success",
"data": map[string]interface{}{"list": list, "total": len(list)},
})
}
// Create 新增配置
// POST /platform/authIdp/create
func (c *PlatformAuthIdpController) Create() {
p, ok := c.parsePayload()
if !ok {
return
}
p.Provider = strings.TrimSpace(p.Provider)
if p.Provider == "" {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "provider 不能为空"})
return
}
if exist := models.Orm.QueryTable(new(models.AuthTenantIdp)).
Filter("tid", p.Tid).Filter("provider", p.Provider).Exist(); exist {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "该登录方式已配置,请直接编辑"})
return
}
item := &models.AuthTenantIdp{
Tid: p.Tid,
Provider: p.Provider,
Status: 1,
}
fillAuthIdp(item, &p)
if p.Status != nil {
item.Status = *p.Status
}
id, err := models.Orm.Insert(item)
if err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "创建失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success", "data": map[string]interface{}{"id": id}})
}
// Edit 编辑配置(app_secret 留空表示不修改)
// POST /platform/authIdp/edit/:id
func (c *PlatformAuthIdpController) Edit() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
p, ok := c.parsePayload()
if !ok {
return
}
var row models.AuthTenantIdp
if err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).One(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 404, "msg": "记录不存在"})
return
}
fillAuthIdp(&row, &p)
if p.Status != nil {
row.Status = *p.Status
}
if _, err := models.Orm.Update(&row); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "更新失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// Delete 删除配置
// DELETE /platform/authIdp/delete/:id
func (c *PlatformAuthIdpController) Delete() {
id, err := strconv.ParseUint(c.Ctx.Input.Param(":id"), 10, 64)
if err != nil || id == 0 {
c.serveJSON(map[string]interface{}{"code": 400, "msg": "无效ID"})
return
}
if _, err := models.Orm.QueryTable(new(models.AuthTenantIdp)).Filter("id", id).Delete(); err != nil {
c.serveJSON(map[string]interface{}{"code": 500, "msg": "删除失败: " + err.Error()})
return
}
c.serveJSON(map[string]interface{}{"code": 200, "msg": "success"})
}
// CallbackHint 返回各平台需要填写的回调地址(方便复制)
// GET /platform/authIdp/callbackHint?provider=wechat
func (c *PlatformAuthIdpController) CallbackHint() {
provider := strings.TrimSpace(c.GetString("provider"))
base := jwtutil.Issuer()
if base == "" {
scheme := "https"
if c.Ctx.Request.TLS == nil {
scheme = "http"
}
base = fmt.Sprintf("%s://%s/auth", scheme, c.Ctx.Request.Host)
}
c.serveJSON(map[string]interface{}{
"code": 200,
"data": map[string]interface{}{
"callback": fmt.Sprintf("%s/third/%s/callback", base, provider),
},
})
}
// ---------------------------------------------------------------- 工具
// fillAuthIdp 把请求参数写入模型;留空字段表示不修改
func fillAuthIdp(item *models.AuthTenantIdp, p *authIdpPayload) {
if v := strings.TrimSpace(p.Name); v != "" {
item.Name = &v
}
if v := strings.TrimSpace(p.AppID); v != "" {
item.AppID = &v
}
if v := strings.TrimSpace(p.AppSecret); v != "" {
item.AppSecret = &v
}
if v := strings.TrimSpace(p.ProxyURL); v != "" {
item.ProxyURL = &v
}
if v := strings.TrimSpace(p.Scopes); v != "" {
item.Scopes = &v
}
if v := strings.TrimSpace(p.AuthURL); v != "" {
item.AuthURL = &v
}
if v := strings.TrimSpace(p.TokenURL); v != "" {
item.TokenURL = &v
}
if v := strings.TrimSpace(p.UserinfoURL); v != "" {
item.UserinfoURL = &v
}
}
func derefAuthIdp(p *string) string {
if p == nil {
return ""
}
return *p
}
+104
View File
@@ -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-薪资单增减项';
+8 -2
View File
@@ -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,
+13
View File
@@ -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")
+20
View File
@@ -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")
+355
View File
@@ -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
View File
@@ -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
}
+19
View File
@@ -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)
+413
View File
@@ -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]
}
+29 -4
View File
@@ -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
}
+114
View File
@@ -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
}
+61 -2
View File
@@ -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 != "" {
+4 -4
View File
@@ -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 客户端管理器(全局单例,带连接缓存)
+8 -8
View File
@@ -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
View File
@@ -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
}
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+144 -144
View File
@@ -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
}
+4 -4
View File
@@ -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 {
+76
View File
@@ -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>
+86
View File
@@ -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>
+49
View File
@@ -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();