批量更新

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.serveJSON(map[string]interface{}{"code": 200, "msg": "已登出"}) c.Redirect(target, 302)
return
}
// 浏览器直接访问(无回跳参数):跳登录页,避免页面显示裸 JSON
target := "/auth/login"
if cid != "" {
target += "?client_id=" + url.QueryEscape(cid)
}
c.Redirect(target, 302)
} }
// Sessions 在线设备列表 // Sessions 在线设备列表
+67
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),
"id": identity.ID,
"union_id": identity.UnionID, "union_id": identity.UnionID,
"tid": claims.TenantId, "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-薪资单增减项';
+6
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
} }
@@ -148,6 +152,7 @@ type TokenOptions struct {
Subject string Subject string
Audience []string Audience []string
Jti string // JWT ID,用于吊销(登出/踢下线) Jti string // JWT ID,用于吊销(登出/踢下线)
Events map[string]interface{} // OIDC 事件声明(Back-Channel Logout 用)
TTL time.Duration // 留空默认 30 分钟 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 != "" {
-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
} }
+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': {