批量更新

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
+39
View File
@@ -0,0 +1,39 @@
<!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>
body {
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", sans-serif;
display: flex; align-items: center; justify-content: center;
min-height: 100vh; margin: 0; color: #5a6072; font-size: 14px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.box {
background: #fff; border-radius: 12px; padding: 32px 40px;
box-shadow: 0 20px 60px rgba(0,0,0,.2); text-align: center;
}
</style>
</head>
<body>
<div class="box">正在完成登录,请稍候…</div>
<script>
/**
* 统一认证中心回调中转页。
*
* 为什么不直接用 hash 路由 /#/auth/callback 作为 redirect_uri:
* URL 中的 "#" 会被浏览器当作片段标识符,其后的内容不会作为查询参数发送到服务端,
* 导致认证中心收到的 redirect_uri 被截断(只剩根路径),换不到授权码。
*
* 因此用一个不带 # 的静态页接收 code,再转发给 SPA 的 hash 路由处理。
*/
(function () {
var query = window.location.search || '';
var target = window.location.origin + '/#/auth/callback' + query;
window.location.replace(target);
})();
</script>
</body>
</html>
+12 -1
View File
@@ -1,6 +1,6 @@
import { createRouter, createWebHashHistory } from "vue-router"; import { createRouter, createWebHashHistory } from "vue-router";
import { convertMenusToRoutes } from "./dynamicRoutes"; import { convertMenusToRoutes } from "./dynamicRoutes";
import { isSSOEnabled, redirectToAuthorize } from "@/utils/authClient"; import { isSSOEnabled, redirectToAuthorize, ensureUserInfo } from "@/utils/authClient";
// 静态子路由:需要在 Main 框架内显示的页面 // 静态子路由:需要在 Main 框架内显示的页面
const staticMainChildren = [ const staticMainChildren = [
@@ -336,6 +336,17 @@ router.beforeEach(async (to, from, next) => {
return; return;
} }
// 统一认证模式:补全用户信息(旧会话可能只有 token 没有 userInfo,
// 或迁移后 userInfo 缺失 id,会导致菜单等接口报「用户ID不存在」)。
// 令牌失效时 ensureUserInfo 会清空登录态并返回 null,此时重新登录。
if (isSSOEnabled()) {
const info = await ensureUserInfo();
if (!info) {
await redirectToAuthorize();
return;
}
}
if (!dynamicRoutesAdded) { if (!dynamicRoutesAdded) {
await loadAndAddDynamicRoutes(); await loadAndAddDynamicRoutes();
// 路由加载后重新导航,确保路由匹配正确 // 路由加载后重新导航,确保路由匹配正确
+42
View File
@@ -171,6 +171,48 @@ export async function logoutSSO() {
window.location.href = `${AUTH_BASE}/logout?${params.toString()}`; window.location.href = `${AUTH_BASE}/logout?${params.toString()}`;
} }
/**
* 确保本地存在完整的用户信息(含 id / group_id)。
*
* 场景:统一认证上线或 ID 全量迁移后,浏览器里可能残留旧会话——
* 只有 token 没有 userInfo,或 userInfo 缺少 id,菜单等接口会报
*「用户ID不存在」。这里主动补全;若令牌已失效则清空登录态返回 null,
* 由调用方跳转登录。
*/
export async function ensureUserInfo() {
let info = null;
try {
const raw = localStorage.getItem('userInfo');
info = raw ? JSON.parse(raw) : null;
} catch (e) {
info = null;
}
if (info && info.id) {
return info;
}
try {
const data = await fetchUserInfo();
const normalized = {
id: data.id || data.sub || '',
account: data.account || data.mobile || '',
name: data.name || data.nickname || '',
group_id: data.group_id || '',
tid: data.tid || '',
tenant_name: (data.tenants || []).map((t) => t.tenant_name).join('、'),
avatar: data.avatar || '',
type: 'backend'
};
localStorage.setItem('userInfo', JSON.stringify(normalized));
return normalized;
} catch (e) {
// 令牌无效(例如迁移前的旧令牌):清掉本地状态,让调用方重新登录
clearTokens();
localStorage.removeItem('userInfo');
return null;
}
}
/** 拉取当前登录用户(认证中心视角) */ /** 拉取当前登录用户(认证中心视角) */
export async function fetchUserInfo() { export async function fetchUserInfo() {
const res = await fetch(`${AUTH_BASE}/userinfo`, { const res = await fetch(`${AUTH_BASE}/userinfo`, {
+10 -3
View File
@@ -1,5 +1,8 @@
import axios from 'axios'; import axios from 'axios';
import { isSSOEnabled, refreshAccessToken, clearTokens } from '@/utils/authClient'; import { isSSOEnabled, refreshAccessToken, clearTokens, redirectToAuthorize } from '@/utils/authClient';
// 统一认证模式下 401 后正在跳转登录的标志,避免并发请求同时触发跳转造成死循环
let ssoRedirecting = false;
// 获取API基础URL,添加调试信息 // 获取API基础URL,添加调试信息
const apiBaseURL = import.meta.env.VITE_API_BASE_URL; const apiBaseURL = import.meta.env.VITE_API_BASE_URL;
@@ -56,10 +59,14 @@ service.interceptors.response.use(
error.config.headers['Authorization'] = `Bearer ${newToken}`; error.config.headers['Authorization'] = `Bearer ${newToken}`;
return service.request(error.config); return service.request(error.config);
} catch (e) { } catch (e) {
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
clearTokens(); clearTokens();
localStorage.removeItem('userInfo'); localStorage.removeItem('userInfo');
window.location.href = '#/login'; if (!ssoRedirecting) {
return Promise.reject(new Error('token无效')); ssoRedirecting = true;
redirectToAuthorize();
}
return Promise.reject(new Error('token无效,请重新登录'));
} }
} }
console.error('未授权,请重新登录'); console.error('未授权,请重新登录');
@@ -327,6 +327,14 @@ const previewList = computed(() =>
imageList.value.map(img => resolveUrl(img.url)) imageList.value.map(img => resolveUrl(img.url))
); );
// ---------- 绑定提醒的状态回显与反馈 ----------
// 注意:这两个 ref 必须声明在下方 watch 之前。
// watch 带 immediate:true,会在 setup 执行到该处时立即回调 loadReminderInfo(),
// 而 loadReminderInfo 同步写入 reminderInfo / reminderLoading,
// 若此时它们尚未初始化,会抛出 "Cannot access 'x' before initialization"(暂时性死区)。
const reminderInfo = ref(null);
const reminderLoading = ref(false);
// ---------- 备注:创建 / 修改 / 完成后都可编辑 ---------- // ---------- 备注:创建 / 修改 / 完成后都可编辑 ----------
const remarkText = ref(""); const remarkText = ref("");
const remarkSaving = ref(false); const remarkSaving = ref(false);
@@ -374,10 +382,7 @@ async function saveRemark() {
} }
} }
// ---------- 绑定提醒的状态回显与反馈 ---------- // ---------- 提醒渠道文案映射 ----------
const reminderInfo = ref(null);
const reminderLoading = ref(false);
const channelTextMap = { const channelTextMap = {
SMS: "短信", SMS: "短信",
EMAIL: "邮件", EMAIL: "邮件",
+31 -1
View File
@@ -16,10 +16,39 @@
<script setup> <script setup>
import { onMounted, ref } from "vue"; import { onMounted, ref } from "vue";
import { handleAuthorizeCallback, redirectToAuthorize } from "@/utils/authClient"; import {
handleAuthorizeCallback,
redirectToAuthorize,
fetchUserInfo,
} from "@/utils/authClient";
const error = ref(""); const error = ref("");
/**
* 写入本地用户信息。
* 关键:id 必须用 legacy_uid(老表 yz_system_tenant_user.uid),
* 因为菜单、权限等现有业务接口都以它识别用户。
*/
async function saveUserInfo() {
try {
const info = await fetchUserInfo();
const userInfo = {
id: info.id || info.sub || "",
account: info.account || info.mobile || "",
name: info.name || info.nickname || "",
group_id: info.group_id || "",
tid: info.tid || "",
tenant_name: (info.tenants || []).map((t) => t.tenant_name).join("、"),
avatar: info.avatar || "",
type: "backend",
};
localStorage.setItem("userInfo", JSON.stringify(userInfo));
} catch (e) {
// 用户信息拉取失败不阻断登录,只记录;业务接口可能提示重新登录
console.error("获取用户信息失败:", e);
}
}
async function finish() { async function finish() {
// hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出 // hash 路由:参数位于 location.hash 之后,这里统一从 URL 中取出
const raw = window.location.href; const raw = window.location.href;
@@ -28,6 +57,7 @@ async function finish() {
try { try {
await handleAuthorizeCallback(query); await handleAuthorizeCallback(query);
await saveUserInfo();
// 登录成功:清掉 URL 上的 code/state,进入首页 // 登录成功:清掉 URL 上的 code/state,进入首页
window.location.href = "#/home"; window.location.href = "#/home";
window.location.reload(); window.location.reload();
+3 -1
View File
@@ -23,7 +23,9 @@ export default defineConfig({
}, },
server: { server: {
host: '127.0.0.1', host: '127.0.0.1',
port: 4001, port: 4401,
// 端口被占用时直接报错,避免自动漂移导致统一登录回跳地址不匹配
strictPort: true,
proxy: { proxy: {
'/api': { '/api': {
target: 'http://127.0.0.1:9000', target: 'http://127.0.0.1:9000',
+17 -3
View File
@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"net/url"
"strings" "strings"
authsvc "server/services/auth" authsvc "server/services/auth"
@@ -200,20 +201,33 @@ func (c *AuthLoginController) LogoutPage() {
if sid != "" { if sid != "" {
_ = authsvc.RevokeSession(sid, models.RevokeReasonLogout) _ = authsvc.RevokeSession(sid, models.RevokeReasonLogout)
} }
// 走统一登出入口:吊销令牌并通知其他应用(单点登出)
if access != "" { if access != "" {
_ = authsvc.RevokeTokenPair("", access, models.RevokeReasonLogout) _ = authsvc.Logout(access, "")
} }
clearSessionCookieForCtx(c.Ctx) clearSessionCookieForCtx(c.Ctx)
back := strings.TrimSpace(c.GetString("post_logout_redirect_uri")) back := strings.TrimSpace(c.GetString("post_logout_redirect_uri"))
cid := strings.TrimSpace(c.GetString("client_id")) cid := strings.TrimSpace(c.GetString("client_id"))
if back != "" && cid != "" { 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) c.Redirect(back, 302)
return 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 在线设备列表 // Sessions 在线设备列表
+70 -3
View File
@@ -10,6 +10,7 @@ import (
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/url"
"strings" "strings"
"time" "time"
@@ -262,10 +263,36 @@ func (c *AuthOidcController) UserInfo() {
c.fail(500, "server_error") c.fail(500, "server_error")
return 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{}{ c.serveJSON(map[string]interface{}{
"sub": fmt.Sprintf("%d", identity.ID), "sub": fmt.Sprintf("%d", identity.ID),
"union_id": identity.UnionID, "id": identity.ID,
"tid": claims.TenantId, "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, "nickname": profile.Nickname,
"mobile": profile.Mobile, "mobile": profile.Mobile,
"email": profile.Email, "email": profile.Email,
@@ -397,6 +424,46 @@ func allowRedirect(client *models.AuthClient, uri string) bool {
return false 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 生成一次性授权码(明文返回,库中只存哈希) // issueAuthCode 生成一次性授权码(明文返回,库中只存哈希)
func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) { func issueAuthCode(clientID string, identityID, tid uint64, redirectURI, challenge, method, scope, nonce string) (string, error) {
plain, err := randomString(32) 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"` Scope string `json:"scope,omitempty"`
Amr string `json:"amr,omitempty"` // 认证方式:pwd/sms/otp/wx/... 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 jwt.RegisteredClaims
} }
@@ -147,8 +151,9 @@ type TokenOptions struct {
Amr string Amr string
Subject string Subject string
Audience []string Audience []string
Jti string // JWT ID,用于吊销(登出/踢下线) Jti string // JWT ID,用于吊销(登出/踢下线)
TTL time.Duration // 留空默认 30 分钟 Events map[string]interface{} // OIDC 事件声明(Back-Channel Logout 用)
TTL time.Duration // 留空默认 30 分钟
Kid string Kid string
} }
@@ -178,6 +183,7 @@ func SignToken(opt TokenOptions) (string, error) {
Sid: opt.Sid, Sid: opt.Sid,
Scope: opt.Scope, Scope: opt.Scope,
Amr: opt.Amr, Amr: opt.Amr,
Events: opt.Events,
RegisteredClaims: jwt.RegisteredClaims{ RegisteredClaims: jwt.RegisteredClaims{
ID: opt.Jti, ID: opt.Jti,
Subject: opt.Subject, Subject: opt.Subject,
+13
View File
@@ -22,6 +22,19 @@ func Register() {
beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect") beego.Router("/auth/introspect", &authctl.AuthOidcController{}, "post:Introspect")
beego.Router("/auth/revoke", &authctl.AuthOidcController{}, "post:Revoke") 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/logout 只注册一次
beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit") beego.Router("/auth/login", &authctl.AuthLoginController{}, "get:LoginPage;post:LoginSubmit")
+20
View File
@@ -14,6 +14,26 @@ func Register() {
// 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调 // 微信公众号(服务号):配置 / 绑定 / 粉丝 / 推送 / 微信服务器回调
RegisterWechatMpRoutes() 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/login", &controllers.PlatformAuthController{}, "post:LoginPlatform")
beego.Router("/platform/currentUser", &controllers.PlatformAuthController{}, "get:GetCurrentUser") 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, ",")
}
-1
View File
@@ -70,4 +70,3 @@ func ListAdminUsers() ([]models.AdminUser, int64, error) {
_, err = models.Orm.QueryTable(new(models.AdminUser)).OrderBy("-id").All(&rows) _, err = models.Orm.QueryTable(new(models.AdminUser)).OrderBy("-id").All(&rows)
return rows, total, err return rows, total, err
} }
+19
View File
@@ -119,6 +119,25 @@ func GetTenantUser(identityID, tid uint64) (*models.AuthTenantUser, error) {
return &bind, nil 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 组装身份概要(含可选企业列表) // BuildProfile 组装身份概要(含可选企业列表)
func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) { func BuildProfile(m *models.AuthIdentity) (*IdentityProfile, error) {
tenants, err := ListTenantOptions(m.ID) 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" "time"
"server/models" "server/models"
"server/pkg/jwtutil"
"server/pkg/passwordutil" "server/pkg/passwordutil"
) )
@@ -127,9 +128,9 @@ func PasswordLogin(account, password, clientID, ip, userAgent string) (*LoginRes
ClientID: clientID, ClientID: clientID,
Sid: sess.Sid, Sid: sess.Sid,
Username: profile.Nickname, Username: profile.Nickname,
UserType: "tenant",
Amr: AmrPwd, Amr: AmrPwd,
AccessTTL: accessTTL, AccessTTL: accessTTL,
// 不指定 UserType:由 IssueTokens 按应用编码给出 backend / app
}) })
if err != nil { if err != nil {
return nil, err return nil, err
@@ -184,7 +185,6 @@ func ChooseTenant(sid string, tid uint64, clientID string) (*TokenPair, *Identit
ClientID: clientID, ClientID: clientID,
Sid: sid, Sid: sid,
Username: profile.Nickname, Username: profile.Nickname,
UserType: "tenant",
}) })
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
@@ -262,7 +262,32 @@ func writeLoginLog(tid uint64, identityID uint64, account, userName, clientID, l
return err return err
} }
// Logout 登出:吊销刷新令牌、会话与访问令牌 // Logout 登出:吊销刷新令牌、会话与访问令牌,并通知其他应用(单点登出)
func Logout(accessToken, refreshToken string) error { 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" "encoding/hex"
"errors" "errors"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
@@ -46,6 +47,10 @@ type TokenIssue struct {
// IssueTokens 签发访问令牌与刷新令牌。 // IssueTokens 签发访问令牌与刷新令牌。
// 刷新令牌明文只在本次返回,库中仅存哈希。 // 刷新令牌明文只在本次返回,库中仅存哈希。
//
// 令牌中的 user_id 为认证中心 identity_id(yz_auth_identity.id)。
// 业务表(文件/客户/合同/日程等)中的 uid 已由 scripts/uidmigrate 全量迁移为
// 同一套 ID,因此无需任何兼容换算。
func IssueTokens(opt TokenIssue) (*TokenPair, error) { func IssueTokens(opt TokenIssue) (*TokenPair, error) {
accessTTL := opt.AccessTTL accessTTL := opt.AccessTTL
if accessTTL <= 0 { if accessTTL <= 0 {
@@ -56,13 +61,27 @@ func IssueTokens(opt TokenIssue) (*TokenPair, error) {
refreshTTL = 2592000 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() jti := uuid.NewString()
access, err := jwtutil.SignToken(jwtutil.TokenOptions{ access, err := jwtutil.SignToken(jwtutil.TokenOptions{
Alg: jwtutil.AlgRS256, Alg: jwtutil.AlgRS256,
UserID: int(opt.IdentityID), UserID: userID,
Username: opt.Username, Username: opt.Username,
TenantID: int(opt.Tid), TenantID: int(opt.Tid),
UserType: opt.UserType, UserType: userType,
ClientID: opt.ClientID, ClientID: opt.ClientID,
Sid: opt.Sid, Sid: opt.Sid,
Amr: opt.Amr, Amr: opt.Amr,
@@ -159,6 +178,46 @@ func RefreshTokens(plain, clientID string) (*TokenPair, error) {
return pair, nil 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 加入黑名单。 // RevokeTokenPair 登出:吊销刷新令牌、会话,并把 access token 的 jti 加入黑名单。
func RevokeTokenPair(plain, accessToken, reason string) error { func RevokeTokenPair(plain, accessToken, reason string) error {
if plain != "" { if plain != "" {
+4 -4
View File
@@ -19,10 +19,10 @@ import (
// McpSession 一次已建立的 MCP 连接会话 // McpSession 一次已建立的 MCP 连接会话
type McpSession struct { type McpSession struct {
Server models.BackendMcpServer Server models.BackendMcpServer
Client *mcpclient.Client Client *mcpclient.Client
Tools []models.McpToolInfo Tools []models.McpToolInfo
Finger string // 配置指纹,配置变更时自动重连 Finger string // 配置指纹,配置变更时自动重连
} }
// McpManager MCP 客户端管理器(全局单例,带连接缓存) // McpManager MCP 客户端管理器(全局单例,带连接缓存)
+8 -8
View File
@@ -832,14 +832,14 @@ type OaDocSaveParams struct {
Tags string Tags string
Summary string Summary string
// Content 在线制作文档的编辑源(富文本 HTML),SetContent 为 true 时才覆盖 // Content 在线制作文档的编辑源(富文本 HTML),SetContent 为 true 时才覆盖
SetContent bool SetContent bool
Content string Content string
Status int8 Status int8
Version int Version int
OwnerID uint64 OwnerID uint64
OwnerName string OwnerName string
IsStar int8 IsStar int8
Visibility int8 // 0-租户公开 1-私密 Visibility int8 // 0-租户公开 1-私密
} }
// OaDocCreate 新增文档。 // OaDocCreate 新增文档。
-1
View File
@@ -48,4 +48,3 @@ func CheckUserPermission(userID int, permission string) (bool, error) {
return false, nil return false, nil
} }
+9 -1
View File
@@ -7,6 +7,7 @@ import (
"server/models" "server/models"
"server/pkg/jwtutil" "server/pkg/jwtutil"
"server/pkg/passwordutil" "server/pkg/passwordutil"
authsvc "server/services/auth"
) )
type PlatformLoginUser struct { type PlatformLoginUser struct {
@@ -133,7 +134,14 @@ func BackendLogin(tenantName, account, password string) (string, *PlatformLoginU
tenantID := int(tenant.ID) tenantID := int(tenant.ID)
const userType = "backend" 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 { if err != nil {
return "", nil, err return "", nil, err
} }
+4 -4
View File
@@ -175,10 +175,10 @@ func ConfirmVerifyCodeByCode(bindType string, bindID, bindTid uint64, code strin
Filter("id", row.ID). Filter("id", row.ID).
Filter("status", models.WechatVerifyStatusScanned). Filter("status", models.WechatVerifyStatusScanned).
Update(map[string]interface{}{ Update(map[string]interface{}{
"bind_type": bindType, "bind_type": bindType,
"bind_id": bindID, "bind_id": bindID,
"bind_tid": bindTid, "bind_tid": bindTid,
"status": models.WechatVerifyStatusUsed, "status": models.WechatVerifyStatusUsed,
"update_time": now, "update_time": now,
}) })
if uerr != nil { 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-meta { font-size: 12px; color: #8a8fa3; margin-top: 3px; }
.tenant-arrow { color: #c0c4d0; font-size: 18px; } .tenant-arrow { color: #c0c4d0; font-size: 18px; }
.tip { text-align: center; font-size: 12px; color: #8a8fa3; margin-top: 20px; } .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 { text-align: center; margin-top: 14px; }
.back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; } .back a { font-size: 13px; color: #667eea; text-decoration: none; cursor: pointer; }
</style> </style>
@@ -87,6 +102,12 @@
</div> </div>
<button class="btn" id="submitBtn" type="submit">登 录</button> <button class="btn" id="submitBtn" type="submit">登 录</button>
</form> </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}} {{end}}
<div class="tip">统一身份认证 · 一次登录,全平台通行</div> <div class="tip">统一身份认证 · 一次登录,全平台通行</div>
@@ -102,6 +123,34 @@
el.style.display = 'block'; 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) { function submitLogin(e) {
e.preventDefault(); e.preventDefault();
var account = document.getElementById('account').value.trim(); var account = document.getElementById('account').value.trim();
+50
View File
@@ -0,0 +1,50 @@
import request from "@/utils/request";
// 统一认证中心 - 接入应用(OIDC Client)管理
export function getAuthClientList(params) {
return request({
url: "/platform/authClient/list",
method: "get",
params,
});
}
export function getAuthClientDetail(id) {
return request({
url: `/platform/authClient/detail/${id}`,
method: "get",
});
}
export function createAuthClient(data) {
return request({
url: "/platform/authClient/create",
method: "post",
data,
});
}
export function editAuthClient(id, data) {
return request({
url: `/platform/authClient/edit/${id}`,
method: "post",
data,
});
}
/** 重置密钥:返回的新密钥仅此一次显示 */
export function resetAuthClientSecret(id) {
return request({
url: `/platform/authClient/resetSecret/${id}`,
method: "post",
});
}
/** 停用应用(逻辑删除) */
export function deleteAuthClient(id) {
return request({
url: `/platform/authClient/delete/${id}`,
method: "delete",
});
}
+27
View File
@@ -0,0 +1,27 @@
import request from "@/utils/request";
// 统一认证中心 - 租户登录策略配置
export function getAuthConfig(tid) {
return request({
url: "/platform/authConfig/detail",
method: "get",
params: { tid },
});
}
export function saveAuthConfig(data) {
return request({
url: "/platform/authConfig/save",
method: "post",
data,
});
}
/** 恢复默认值(删除该租户的自定义配置) */
export function resetAuthConfig(tid) {
return request({
url: `/platform/authConfig/reset/${tid}`,
method: "delete",
});
}
+43
View File
@@ -0,0 +1,43 @@
import request from "@/utils/request";
// 统一认证中心 - 第三方登录配置(tid=0 全局 / tid>0 租户自带)
export function getAuthIdpList(tid) {
return request({
url: "/platform/authIdp/list",
method: "get",
params: { tid },
});
}
export function createAuthIdp(data) {
return request({
url: "/platform/authIdp/create",
method: "post",
data,
});
}
export function editAuthIdp(id, data) {
return request({
url: `/platform/authIdp/edit/${id}`,
method: "post",
data,
});
}
export function deleteAuthIdp(id) {
return request({
url: `/platform/authIdp/delete/${id}`,
method: "delete",
});
}
/** 获取某平台需要填写的回调地址 */
export function getAuthIdpCallbackHint(provider) {
return request({
url: "/platform/authIdp/callbackHint",
method: "get",
params: { provider },
});
}
+21
View File
@@ -21,6 +21,27 @@ const staticMainChildren = [
component: () => import("@/views/system/email/index.vue"), component: () => import("@/views/system/email/index.vue"),
meta: { requiresAuth: true, title: "邮箱管理" } meta: { requiresAuth: true, title: "邮箱管理" }
}, },
// 统一认证中心:接入应用(OIDC Client)注册管理
{
path: "/system/authClient",
name: "AuthClientManage",
component: () => import("@/views/system/authClient/index.vue"),
meta: { requiresAuth: true, title: "应用注册" }
},
// 统一认证中心:第三方登录配置(全局 / 租户自带)
{
path: "/system/authIdp",
name: "AuthIdpManage",
component: () => import("@/views/system/authIdp/index.vue"),
meta: { requiresAuth: true, title: "第三方登录" }
},
// 统一认证中心:租户登录策略(验证方式/会话时长/1号1机)
{
path: "/system/authConfig",
name: "AuthConfigManage",
component: () => import("@/views/system/authConfig/index.vue"),
meta: { requiresAuth: true, title: "登录策略" }
},
{ {
path: "/tools/passwordStore", path: "/tools/passwordStore",
name: "PlatformPasswordStore", name: "PlatformPasswordStore",
@@ -0,0 +1,313 @@
<template>
<div class="auth-client-page">
<div class="page-header">
<div class="title">接入应用(统一认证)</div>
<el-button type="primary" @click="openCreate">新增应用</el-button>
</div>
<div class="tips">
以后每开发一个新软件,只需在这里注册一条即可接入统一认证:填写 Client ID、应用编码和回跳地址白名单。
</div>
<el-table :data="list" v-loading="loading" border size="default">
<el-table-column prop="client_id" label="Client ID" min-width="150" />
<el-table-column prop="app_code" label="应用编码" width="110" />
<el-table-column prop="name" label="应用名称" min-width="130" />
<el-table-column label="类型" width="100">
<template #default="{ row }">{{ appTypeText(row.app_type) }}</template>
</el-table-column>
<el-table-column prop="realm" label="域" width="90" />
<el-table-column label="回跳地址白名单" min-width="200" show-overflow-tooltip>
<template #default="{ row }">{{ row.redirect_uris || "-" }}</template>
</el-table-column>
<el-table-column label="令牌有效期" width="140">
<template #default="{ row }">
{{ row.access_ttl }}s / {{ Math.floor(row.refresh_ttl / 86400) }}天
</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button
link
type="warning"
:disabled="row.app_type !== 1"
@click="handleResetSecret(row)"
>重置密钥</el-button
>
<el-button link type="danger" :disabled="row.status !== 1" @click="handleDelete(row)"
>停用</el-button
>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="editing ? '编辑应用' : '新增应用'"
width="640px"
destroy-on-close
>
<el-form :model="form" label-width="130px">
<el-form-item label="Client ID" required>
<el-input
v-model="form.client_id"
placeholder="如 yz-crm"
:disabled="editing"
/>
</el-form-item>
<el-form-item label="应用编码" required>
<el-input v-model="form.app_code" placeholder="如 crm" :disabled="editing" />
</el-form-item>
<el-form-item label="应用名称" required>
<el-input v-model="form.name" placeholder="如 客户关系管理" />
</el-form-item>
<el-form-item label="应用类型">
<el-select v-model="form.app_type">
<el-option :value="1" label="Web 后端(持有密钥)" />
<el-option :value="2" label="单页应用 SPA(PKCE)" />
<el-option :value="3" label="原生 APP(PKCE)" />
<el-option :value="4" label="小程序(PKCE)" />
</el-select>
</el-form-item>
<el-form-item label="域">
<el-select v-model="form.realm">
<el-option value="tenant" label="租户域" />
<el-option value="platform" label="平台域" />
</el-select>
</el-form-item>
<el-form-item label="回跳地址白名单">
<el-input
v-model="form.redirect_uris"
type="textarea"
:rows="3"
placeholder='JSON 数组,如 ["https://back.yunzer.cn/#/auth/callback"]'
/>
</el-form-item>
<el-form-item label="登出回跳地址">
<el-input
v-model="form.post_logout_uris"
type="textarea"
:rows="2"
placeholder='JSON 数组,如 ["https://back.yunzer.cn/#/login"]'
/>
</el-form-item>
<el-form-item label="后端登出通知">
<el-input
v-model="form.backchannel_logout_uri"
placeholder="单点登出回调地址(服务端通知,可留空)"
/>
</el-form-item>
<el-form-item label="访问令牌有效期">
<el-input-number v-model="form.access_ttl" :min="300" :step="300" /> 秒
</el-form-item>
<el-form-item label="刷新令牌有效期">
<el-input-number v-model="form.refresh_ttl" :min="3600" :step="86400" /> 秒
</el-form-item>
<el-form-item label="状态" v-if="editing">
<el-switch v-model="form.statusOn" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
getAuthClientList,
createAuthClient,
editAuthClient,
resetAuthClientSecret,
deleteAuthClient,
} from "@/api/authClient";
const list = ref([]);
const loading = ref(false);
const dialogVisible = ref(false);
const editing = ref(false);
const submitting = ref(false);
const currentId = ref(0);
const emptyForm = () => ({
client_id: "",
app_code: "",
name: "",
app_type: 2,
realm: "tenant",
redirect_uris: "",
post_logout_uris: "",
backchannel_logout_uri: "",
access_ttl: 1800,
refresh_ttl: 2592000,
statusOn: true,
});
const form = reactive(emptyForm());
function appTypeText(t) {
return { 1: "Web 后端", 2: "SPA", 3: "原生 APP", 4: "小程序" }[t] || "-";
}
async function loadList() {
loading.value = true;
try {
const res = await getAuthClientList();
list.value = res?.data?.list || [];
} catch (e) {
ElMessage.error(e?.message || "加载失败");
} finally {
loading.value = false;
}
}
function openCreate() {
editing.value = false;
Object.assign(form, emptyForm());
dialogVisible.value = true;
}
function openEdit(row) {
editing.value = true;
currentId.value = row.id;
Object.assign(form, {
client_id: row.client_id || "",
app_code: row.app_code || "",
name: row.name || "",
app_type: row.app_type || 2,
realm: row.realm || "tenant",
redirect_uris: row.redirect_uris || "",
post_logout_uris: row.post_logout_uris || "",
backchannel_logout_uri: row.backchannel_logout_uri || "",
access_ttl: row.access_ttl || 1800,
refresh_ttl: row.refresh_ttl || 2592000,
statusOn: row.status === 1,
});
dialogVisible.value = true;
}
async function submit() {
if (!form.client_id.trim() || !form.app_code.trim() || !form.name.trim()) {
ElMessage.warning("Client ID、应用编码、名称不能为空");
return;
}
submitting.value = true;
try {
const payload = {
name: form.name.trim(),
app_type: form.app_type,
realm: form.realm,
redirect_uris: form.redirect_uris.trim(),
post_logout_uris: form.post_logout_uris.trim(),
backchannel_logout_uri: form.backchannel_logout_uri.trim(),
access_ttl: form.access_ttl,
refresh_ttl: form.refresh_ttl,
};
if (editing.value) {
payload.status = form.statusOn ? 1 : 0;
await editAuthClient(currentId.value, payload);
ElMessage.success("保存成功");
} else {
payload.client_id = form.client_id.trim();
payload.app_code = form.app_code.trim();
const res = await createAuthClient(payload);
ElMessage.success("创建成功");
const secret = res?.data?.client_secret;
if (secret) {
await ElMessageBox.alert(
`Client Secret:${secret}`,
"请立即保存密钥(仅此一次显示)",
{ confirmButtonText: "我已保存" }
);
}
}
dialogVisible.value = false;
loadList();
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function handleResetSecret(row) {
try {
await ElMessageBox.confirm(
`重置后旧密钥立即失效,使用旧密钥的应用需要更新配置。确定重置「${row.name}」的密钥吗?`,
"重置密钥",
{ type: "warning" }
);
} catch {
return;
}
try {
const res = await resetAuthClientSecret(row.id);
await ElMessageBox.alert(
`新的 Client Secret:${res?.data?.client_secret}`,
"请立即保存密钥(仅此一次显示)",
{ confirmButtonText: "我已保存" }
);
} catch (e) {
ElMessage.error(e?.message || "重置失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`停用后该应用将无法登录,确定停用「${row.name}」吗?`,
"停用应用",
{ type: "warning" }
);
} catch {
return;
}
try {
await deleteAuthClient(row.id);
ElMessage.success("已停用");
loadList();
} catch (e) {
ElMessage.error(e?.message || "操作失败");
}
}
onMounted(loadList);
</script>
<style scoped>
.auth-client-page {
padding: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.title {
font-size: 18px;
font-weight: 600;
}
.tips {
background: #f4f6ff;
border: 1px solid #d9e0ff;
color: #5a6072;
font-size: 13px;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 12px;
}
</style>
@@ -0,0 +1,256 @@
<template>
<div class="auth-config-page">
<div class="page-header">
<div class="title">租户登录策略</div>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</div>
<div class="scope-bar">
<span class="label">选择租户</span>
<el-select
v-model="tid"
filterable
style="width: 280px"
placeholder="请选择租户"
@change="loadConfig"
>
<el-option
v-for="t in tenants"
:key="t.id"
:value="t.id"
:label="t.tenant_name"
/>
</el-select>
<el-button link type="danger" :disabled="!tid || !configured" @click="handleReset">
恢复默认
</el-button>
</div>
<div v-if="!tid" class="empty">请先选择一个租户</div>
<el-form v-else v-loading="loading" :model="form" label-width="160px" class="form-body">
<el-divider content-position="left">登录验证</el-divider>
<el-form-item label="开启登录验证">
<el-switch v-model="form.openVerifyOn" active-text="开启" inactive-text="关闭" />
</el-form-item>
<el-form-item label="验证方式">
<el-select v-model="form.verify_type" style="width: 220px">
<el-option value="captcha" label="图形验证码" />
<el-option value="sms" label="短信验证码" />
<el-option value="email" label="邮箱验证码" />
<el-option value="geetest" label="极验人机验证" />
<el-option value="none" label="不开启" />
</el-select>
</el-form-item>
<el-form-item label="开启二次验证(MFA)">
<el-switch v-model="form.mfaOn" active-text="必须" inactive-text="不要求" />
<span class="hint-inline">MFA 功能尚未上线,此配置暂不生效</span>
</el-form-item>
<el-divider content-position="left">密码策略</el-divider>
<el-form-item label="密码最小长度">
<el-input-number v-model="form.pwd_min_len" :min="6" :max="32" />
</el-form-item>
<el-form-item label="密码复杂度">
<el-select v-model="form.pwd_complexity" style="width: 220px">
<el-option :value="0" label="不限制" />
<el-option :value="1" label="字母 + 数字" />
<el-option :value="2" label="大小写 + 数字 + 符号" />
</el-select>
</el-form-item>
<el-divider content-position="left">会话与设备</el-divider>
<el-form-item label="会话有效期">
<el-input-number v-model="form.session_ttl" :min="600" :step="600" /> 秒
<span class="hint-inline">约 {{ Math.floor(form.session_ttl / 3600) }} 小时</span>
</el-form-item>
<el-form-item label="最大同时在线">
<el-input-number v-model="form.max_session" :min="1" :max="10" />
<span class="hint-inline">1 = 一号一机(新登录踢掉旧设备)</span>
</el-form-item>
<el-form-item label="超出限制时">
<el-radio-group v-model="form.kick_strategy">
<el-radio :value="1">踢掉最早的会话</el-radio>
<el-radio :value="2">拒绝新登录</el-radio>
</el-radio-group>
</el-form-item>
<el-divider content-position="left">访问控制</el-divider>
<el-form-item label="IP 白名单">
<el-input
v-model="form.ip_whitelist"
type="textarea"
:rows="2"
placeholder='JSON 数组,如 ["1.2.3.4","8.8.8.8"],留空表示不限制'
/>
</el-form-item>
<el-form-item label="允许的第三方登录">
<el-input
v-model="form.allow_third"
placeholder="逗号分隔,如 wechat,dingtalk;留空表示按全局配置"
/>
</el-form-item>
</el-form>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { getAuthConfig, saveAuthConfig, resetAuthConfig } from "@/api/authConfig";
import { getTenantList } from "@/api/tenant";
const tenants = ref([]);
const tid = ref(0);
const loading = ref(false);
const submitting = ref(false);
const configured = ref(false);
const emptyForm = () => ({
verify_type: "captcha",
openVerifyOn: true,
mfaOn: false,
pwd_min_len: 8,
pwd_complexity: 0,
session_ttl: 7200,
max_session: 1,
kick_strategy: 1,
ip_whitelist: "",
allow_third: "",
});
const form = reactive(emptyForm());
async function loadTenants() {
try {
const res = await getTenantList({ page: 1, pageSize: 200 });
const data = res?.data || res || {};
tenants.value = data.list || data.rows || [];
} catch (e) {
/* 忽略 */
}
}
async function loadConfig() {
if (!tid.value) return;
loading.value = true;
try {
const res = await getAuthConfig(tid.value);
const d = res?.data || {};
configured.value = !!d.configured;
Object.assign(form, {
verify_type: d.verify_type || "captcha",
openVerifyOn: d.open_verify !== 0,
mfaOn: d.mfa_required === 1,
pwd_min_len: d.pwd_min_len || 8,
pwd_complexity: d.pwd_complexity || 0,
session_ttl: d.session_ttl || 7200,
max_session: d.max_session || 1,
kick_strategy: d.kick_strategy || 1,
ip_whitelist: d.ip_whitelist || "",
allow_third: d.allow_third || "",
});
} catch (e) {
ElMessage.error(e?.message || "加载失败");
} finally {
loading.value = false;
}
}
async function submit() {
if (!tid.value) {
ElMessage.warning("请先选择租户");
return;
}
submitting.value = true;
try {
await saveAuthConfig({
tid: tid.value,
verify_type: form.verify_type,
open_verify: form.openVerifyOn ? 1 : 0,
mfa_required: form.mfaOn ? 1 : 0,
pwd_min_len: form.pwd_min_len,
pwd_complexity: form.pwd_complexity,
session_ttl: form.session_ttl,
max_session: form.max_session,
kick_strategy: form.kick_strategy,
ip_whitelist: form.ip_whitelist.trim(),
allow_third: form.allow_third.trim(),
});
ElMessage.success("保存成功");
configured.value = true;
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function handleReset() {
try {
await ElMessageBox.confirm(
"恢复默认后将删除该租户的自定义策略,使用系统默认值。确定吗?",
"恢复默认",
{ type: "warning" }
);
} catch {
return;
}
try {
await resetAuthConfig(tid.value);
ElMessage.success("已恢复默认");
loadConfig();
} catch (e) {
ElMessage.error(e?.message || "操作失败");
}
}
onMounted(loadTenants);
</script>
<style scoped>
.auth-config-page {
padding: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.title {
font-size: 18px;
font-weight: 600;
}
.scope-bar {
display: flex;
align-items: center;
gap: 10px;
background: #f4f6ff;
border: 1px solid #d9e0ff;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.label {
font-size: 13px;
color: #5a6072;
}
.form-body {
max-width: 720px;
}
.hint-inline {
margin-left: 10px;
font-size: 12px;
color: #8a8fa3;
}
.empty {
text-align: center;
color: #8a8fa3;
padding: 40px 0;
}
</style>
+303
View File
@@ -0,0 +1,303 @@
<template>
<div class="auth-idp-page">
<div class="page-header">
<div class="title">第三方登录配置</div>
<el-button type="primary" @click="openCreate">新增配置</el-button>
</div>
<div class="scope-bar">
<span class="label">配置范围</span>
<el-select v-model="tid" style="width: 260px" @change="loadList">
<el-option :value="0" label="平台全局(所有租户共用)" />
<el-option
v-for="t in tenants"
:key="t.id"
:value="t.id"
:label="`租户:${t.tenant_name}`"
/>
</el-select>
<span class="hint">
全局配置对所有租户生效;租户自带配置仅对该租户生效,且优先级更高。
</span>
</div>
<el-table :data="list" v-loading="loading" border>
<el-table-column prop="provider" label="登录方式" width="120">
<template #default="{ row }">{{ providerName(row.provider) }}</template>
</el-table-column>
<el-table-column prop="name" label="名称" width="130" />
<el-table-column prop="app_id" label="AppID" min-width="180" show-overflow-tooltip />
<el-table-column label="密钥" width="90">
<template #default="{ row }">
<el-tag :type="row.has_secret ? 'success' : 'info'">
{{ row.has_secret ? "已配置" : "未配置" }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="proxy_url" label="代理" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ row.proxy_url || "-" }}</template>
</el-table-column>
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'info'">
{{ row.status === 1 ? "启用" : "停用" }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="info" @click="showCallback(row)">回调地址</el-button>
<el-button link type="danger" @click="handleDelete(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog
v-model="dialogVisible"
:title="editing ? '编辑配置' : '新增配置'"
width="620px"
destroy-on-close
>
<el-form :model="form" label-width="120px">
<el-form-item label="登录方式" required>
<el-select v-model="form.provider" :disabled="editing" style="width: 100%">
<el-option v-for="p in providers" :key="p.value" :value="p.value" :label="p.label" />
</el-select>
</el-form-item>
<el-form-item label="显示名称">
<el-input v-model="form.name" placeholder="如 微信登录" />
</el-form-item>
<el-form-item label="AppID / ClientID" required>
<el-input v-model="form.app_id" placeholder="第三方平台申请的 AppID" />
</el-form-item>
<el-form-item label="AppSecret">
<el-input
v-model="form.app_secret"
type="password"
show-password
:placeholder="editing && form.has_secret ? '留空表示不修改' : '第三方平台申请的密钥'"
/>
</el-form-item>
<el-form-item label="代理地址">
<el-input
v-model="form.proxy_url"
placeholder="GitHub/Google 在国内服务器需要,如 http://127.0.0.1:7890"
/>
</el-form-item>
<el-form-item label="授权范围">
<el-input v-model="form.scopes" placeholder="留空使用预设值,多个用逗号分隔" />
</el-form-item>
<el-form-item label="状态" v-if="editing">
<el-switch v-model="form.statusOn" active-text="启用" inactive-text="停用" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submit">保存</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from "vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
getAuthIdpList,
createAuthIdp,
editAuthIdp,
deleteAuthIdp,
getAuthIdpCallbackHint,
} from "@/api/authIdp";
import { getTenantList } from "@/api/tenant";
const providers = [
{ value: "wechat", label: "微信" },
{ value: "dingtalk", label: "钉钉" },
{ value: "feishu", label: "飞书" },
{ value: "qq", label: "QQ" },
{ value: "github", label: "GitHub" },
{ value: "google", label: "Google" },
// 租户自带身份源(企业自有)
{ value: "wework", label: "企业微信(租户自带)" },
{ value: "oidc", label: "标准 OIDC(租户自带)" },
{ value: "saml", label: "SAML(租户自带)" },
];
const list = ref([]);
const tenants = ref([]);
const loading = ref(false);
const tid = ref(0);
const dialogVisible = ref(false);
const editing = ref(false);
const submitting = ref(false);
const currentId = ref(0);
const emptyForm = () => ({
provider: "wechat",
name: "",
app_id: "",
app_secret: "",
proxy_url: "",
scopes: "",
statusOn: true,
has_secret: false,
});
const form = reactive(emptyForm());
function providerName(p) {
const hit = providers.find((i) => i.value === p);
return hit ? hit.label : p;
}
async function loadList() {
loading.value = true;
try {
const res = await getAuthIdpList(tid.value);
list.value = res?.data?.list || [];
} catch (e) {
ElMessage.error(e?.message || "加载失败");
} finally {
loading.value = false;
}
}
async function loadTenants() {
try {
const res = await getTenantList({ page: 1, pageSize: 200 });
const data = res?.data || res || {};
tenants.value = data.list || data.rows || [];
} catch (e) {
// 租户列表加载失败不影响主功能
}
}
function openCreate() {
editing.value = false;
Object.assign(form, emptyForm());
dialogVisible.value = true;
}
function openEdit(row) {
editing.value = true;
currentId.value = row.id;
Object.assign(form, {
provider: row.provider || "wechat",
name: row.name || "",
app_id: row.app_id || "",
app_secret: "",
proxy_url: row.proxy_url || "",
scopes: row.scopes || "",
statusOn: row.status === 1,
has_secret: !!row.has_secret,
});
dialogVisible.value = true;
}
async function submit() {
if (!form.provider || !form.app_id.trim()) {
ElMessage.warning("登录方式和 AppID 不能为空");
return;
}
submitting.value = true;
try {
const payload = {
provider: form.provider,
name: form.name.trim(),
app_id: form.app_id.trim(),
proxy_url: form.proxy_url.trim(),
scopes: form.scopes.trim(),
};
if (form.app_secret.trim()) payload.app_secret = form.app_secret.trim();
if (editing.value) {
payload.status = form.statusOn ? 1 : 0;
await editAuthIdp(currentId.value, payload);
} else {
payload.tid = tid.value;
await createAuthIdp(payload);
}
ElMessage.success("保存成功");
dialogVisible.value = false;
loadList();
} catch (e) {
ElMessage.error(e?.message || "保存失败");
} finally {
submitting.value = false;
}
}
async function showCallback(row) {
try {
const res = await getAuthIdpCallbackHint(row.provider);
await ElMessageBox.alert(
res?.data?.callback || "",
`${providerName(row.provider)} 回调地址(填到第三方平台)`,
{ confirmButtonText: "复制好了" }
);
} catch (e) {
ElMessage.error(e?.message || "获取失败");
}
}
async function handleDelete(row) {
try {
await ElMessageBox.confirm(
`确定删除「${providerName(row.provider)}」的配置吗?删除后该登录方式将不可用。`,
"删除配置",
{ type: "warning" }
);
} catch {
return;
}
try {
await deleteAuthIdp(row.id);
ElMessage.success("已删除");
loadList();
} catch (e) {
ElMessage.error(e?.message || "删除失败");
}
}
onMounted(() => {
loadTenants();
loadList();
});
</script>
<style scoped>
.auth-idp-page {
padding: 16px;
}
.page-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.title {
font-size: 18px;
font-weight: 600;
}
.scope-bar {
display: flex;
align-items: center;
gap: 10px;
background: #f4f6ff;
border: 1px solid #d9e0ff;
padding: 10px 12px;
border-radius: 6px;
margin-bottom: 12px;
flex-wrap: wrap;
}
.label {
font-size: 13px;
color: #5a6072;
}
.hint {
font-size: 12px;
color: #8a8fa3;
}
</style>
+4 -2
View File
@@ -28,8 +28,10 @@ export default defineConfig({
}, },
server: { server: {
host: "127.0.0.1", host: "127.0.0.1",
port: 4000, port: 4400,
// 开发时前端在 4000,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=9000) // 端口被占用时直接报错,不再自动漂移到其他端口(避免回跳地址与实际端口不一致)
strictPort: true,
// 开发时前端在 4400,接口走相对路径 /platform/*、/backend/*,转发到本地 Go(当前 httpport=9000)
proxy: { proxy: {
"/platform": { "/platform": {
target: "http://127.0.0.1:9000", target: "http://127.0.0.1:9000",
+3
View File
@@ -13,6 +13,9 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: '127.0.0.1',
port: 4403,
strictPort: true,
proxy: { proxy: {
// H5 开发:api/config.js 默认 baseURL 为 /proxy-api,转发到 Go 后端 // H5 开发:api/config.js 默认 baseURL 为 /proxy-api,转发到 Go 后端
'/proxy-api': { '/proxy-api': {
+2 -1
View File
@@ -21,7 +21,8 @@ export default defineConfig({
server: { server: {
open: true, open: true,
host: '127.0.0.1', host: '127.0.0.1',
port: 4002, port: 4402,
strictPort: true,
hmr: true, hmr: true,
proxy: { proxy: {
'/api': { '/api': {